Create New Room
diff --git a/WebHostLib/upload.py b/WebHostLib/upload.py
index 89a839cfa364..e7ac033913e8 100644
--- a/WebHostLib/upload.py
+++ b/WebHostLib/upload.py
@@ -104,13 +104,21 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
# Factorio
elif file.filename.endswith(".zip"):
- _, _, slot_id, *_ = file.filename.split('_')[0].split('-', 3)
+ try:
+ _, _, slot_id, *_ = file.filename.split('_')[0].split('-', 3)
+ except ValueError:
+ flash("Error: Unexpected file found in .zip: " + file.filename)
+ return
data = zfile.open(file, "r").read()
files[int(slot_id[1:])] = data
# All other files using the standard MultiWorld.get_out_file_name_base method
else:
- _, _, slot_id, *_ = file.filename.split('.')[0].split('_', 3)
+ try:
+ _, _, slot_id, *_ = file.filename.split('.')[0].split('_', 3)
+ except ValueError:
+ flash("Error: Unexpected file found in .zip: " + file.filename)
+ return
data = zfile.open(file, "r").read()
files[int(slot_id[1:])] = data
diff --git a/data/lua/base64.lua b/data/lua/base64.lua
new file mode 100644
index 000000000000..ebe80643531b
--- /dev/null
+++ b/data/lua/base64.lua
@@ -0,0 +1,119 @@
+-- This file originates from this repository: https://github.com/iskolbin/lbase64
+-- It was modified to translate between base64 strings and lists of bytes instead of base64 strings and strings.
+
+local base64 = {}
+
+local extract = _G.bit32 and _G.bit32.extract -- Lua 5.2/Lua 5.3 in compatibility mode
+if not extract then
+ if _G._VERSION == "Lua 5.4" then
+ extract = load[[return function( v, from, width )
+ return ( v >> from ) & ((1 << width) - 1)
+ end]]()
+ elseif _G.bit then -- LuaJIT
+ local shl, shr, band = _G.bit.lshift, _G.bit.rshift, _G.bit.band
+ extract = function( v, from, width )
+ return band( shr( v, from ), shl( 1, width ) - 1 )
+ end
+ elseif _G._VERSION == "Lua 5.1" then
+ extract = function( v, from, width )
+ local w = 0
+ local flag = 2^from
+ for i = 0, width-1 do
+ local flag2 = flag + flag
+ if v % flag2 >= flag then
+ w = w + 2^i
+ end
+ flag = flag2
+ end
+ return w
+ end
+ end
+end
+
+
+function base64.makeencoder( s62, s63, spad )
+ local encoder = {}
+ for b64code, char in pairs{[0]='A','B','C','D','E','F','G','H','I','J',
+ 'K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y',
+ 'Z','a','b','c','d','e','f','g','h','i','j','k','l','m','n',
+ 'o','p','q','r','s','t','u','v','w','x','y','z','0','1','2',
+ '3','4','5','6','7','8','9',s62 or '+',s63 or'/',spad or'='} do
+ encoder[b64code] = char:byte()
+ end
+ return encoder
+end
+
+function base64.makedecoder( s62, s63, spad )
+ local decoder = {}
+ for b64code, charcode in pairs( base64.makeencoder( s62, s63, spad )) do
+ decoder[charcode] = b64code
+ end
+ return decoder
+end
+
+local DEFAULT_ENCODER = base64.makeencoder()
+local DEFAULT_DECODER = base64.makedecoder()
+
+local char, concat = string.char, table.concat
+
+function base64.encode( arr, encoder )
+ encoder = encoder or DEFAULT_ENCODER
+ local t, k, n = {}, 1, #arr
+ local lastn = n % 3
+ for i = 1, n-lastn, 3 do
+ local a, b, c = arr[i], arr[i + 1], arr[i + 2]
+ local v = a*0x10000 + b*0x100 + c
+ local s
+ s = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[extract(v,0,6)])
+ t[k] = s
+ k = k + 1
+ end
+ if lastn == 2 then
+ local a, b = arr[n-1], arr[n]
+ local v = a*0x10000 + b*0x100
+ t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[extract(v,6,6)], encoder[64])
+ elseif lastn == 1 then
+ local v = arr[n]*0x10000
+ t[k] = char(encoder[extract(v,18,6)], encoder[extract(v,12,6)], encoder[64], encoder[64])
+ end
+ return concat( t )
+end
+
+function base64.decode( b64, decoder )
+ decoder = decoder or DEFAULT_DECODER
+ local pattern = '[^%w%+%/%=]'
+ if decoder then
+ local s62, s63
+ for charcode, b64code in pairs( decoder ) do
+ if b64code == 62 then s62 = charcode
+ elseif b64code == 63 then s63 = charcode
+ end
+ end
+ pattern = ('[^%%w%%%s%%%s%%=]'):format( char(s62), char(s63) )
+ end
+ b64 = b64:gsub( pattern, '' )
+ local t, k = {}, 1
+ local n = #b64
+ local padding = b64:sub(-2) == '==' and 2 or b64:sub(-1) == '=' and 1 or 0
+ for i = 1, padding > 0 and n-4 or n, 4 do
+ local a, b, c, d = b64:byte( i, i+3 )
+ local s
+ local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40 + decoder[d]
+ table.insert(t,extract(v,16,8))
+ table.insert(t,extract(v,8,8))
+ table.insert(t,extract(v,0,8))
+ end
+ if padding == 1 then
+ local a, b, c = b64:byte( n-3, n-1 )
+ local v = decoder[a]*0x40000 + decoder[b]*0x1000 + decoder[c]*0x40
+ table.insert(t,extract(v,16,8))
+ table.insert(t,extract(v,8,8))
+ elseif padding == 2 then
+ local a, b = b64:byte( n-3, n-2 )
+ local v = decoder[a]*0x40000 + decoder[b]*0x1000
+ table.insert(t,extract(v,16,8))
+ end
+ return t
+end
+
+return base64
diff --git a/data/lua/connector_bizhawk_generic.lua b/data/lua/connector_bizhawk_generic.lua
new file mode 100644
index 000000000000..b0b06de447bb
--- /dev/null
+++ b/data/lua/connector_bizhawk_generic.lua
@@ -0,0 +1,564 @@
+--[[
+Copyright (c) 2023 Zunawe
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+]]
+
+local SCRIPT_VERSION = 1
+
+--[[
+This script expects to receive JSON and will send JSON back. A message should
+be a list of 1 or more requests which will be executed in order. Each request
+will have a corresponding response in the same order.
+
+Every individual request and response is a JSON object with at minimum one
+field `type`. The value of `type` determines what other fields may exist.
+
+To get the script version, instead of JSON, send "VERSION" to get the script
+version directly (e.g. "2").
+
+#### Ex. 1
+
+Request: `[{"type": "PING"}]`
+
+Response: `[{"type": "PONG"}]`
+
+---
+
+#### Ex. 2
+
+Request: `[{"type": "LOCK"}, {"type": "HASH"}]`
+
+Response: `[{"type": "LOCKED"}, {"type": "HASH_RESPONSE", "value": "F7D18982"}]`
+
+---
+
+#### Ex. 3
+
+Request:
+
+```json
+[
+ {"type": "GUARD", "address": 100, "expected_data": "aGVsbG8=", "domain": "System Bus"},
+ {"type": "READ", "address": 500, "size": 4, "domain": "ROM"}
+]
+```
+
+Response:
+
+```json
+[
+ {"type": "GUARD_RESPONSE", "address": 100, "value": true},
+ {"type": "READ_RESPONSE", "value": "dGVzdA=="}
+]
+```
+
+---
+
+#### Ex. 4
+
+Request:
+
+```json
+[
+ {"type": "GUARD", "address": 100, "expected_data": "aGVsbG8=", "domain": "System Bus"},
+ {"type": "READ", "address": 500, "size": 4, "domain": "ROM"}
+]
+```
+
+Response:
+
+```json
+[
+ {"type": "GUARD_RESPONSE", "address": 100, "value": false},
+ {"type": "GUARD_RESPONSE", "address": 100, "value": false}
+]
+```
+
+---
+
+### Supported Request Types
+
+- `PING`
+ Does nothing; resets timeout.
+
+ Expected Response Type: `PONG`
+
+- `SYSTEM`
+ Returns the system of the currently loaded ROM (N64, GBA, etc...).
+
+ Expected Response Type: `SYSTEM_RESPONSE`
+
+- `PREFERRED_CORES`
+ Returns the user's default cores for systems with multiple cores. If the
+ current ROM's system has multiple cores, the one that is currently
+ running is very probably the preferred core.
+
+ Expected Response Type: `PREFERRED_CORES_RESPONSE`
+
+- `HASH`
+ Returns the hash of the currently loaded ROM calculated by BizHawk.
+
+ Expected Response Type: `HASH_RESPONSE`
+
+- `GUARD`
+ Checks a section of memory against `expected_data`. If the bytes starting
+ at `address` do not match `expected_data`, the response will have `value`
+ set to `false`, and all subsequent requests will not be executed and
+ receive the same `GUARD_RESPONSE`.
+
+ Expected Response Type: `GUARD_RESPONSE`
+
+ Additional Fields:
+ - `address` (`int`): The address of the memory to check
+ - `expected_data` (string): A base64 string of contiguous data
+ - `domain` (`string`): The name of the memory domain the address
+ corresponds to
+
+- `LOCK`
+ Halts emulation and blocks on incoming requests until an `UNLOCK` request
+ is received or the client times out. All requests processed while locked
+ will happen on the same frame.
+
+ Expected Response Type: `LOCKED`
+
+- `UNLOCK`
+ Resumes emulation after the current list of requests is done being
+ executed.
+
+ Expected Response Type: `UNLOCKED`
+
+- `READ`
+ Reads an array of bytes at the provided address.
+
+ Expected Response Type: `READ_RESPONSE`
+
+ Additional Fields:
+ - `address` (`int`): The address of the memory to read
+ - `size` (`int`): The number of bytes to read
+ - `domain` (`string`): The name of the memory domain the address
+ corresponds to
+
+- `WRITE`
+ Writes an array of bytes to the provided address.
+
+ Expected Response Type: `WRITE_RESPONSE`
+
+ Additional Fields:
+ - `address` (`int`): The address of the memory to write to
+ - `value` (`string`): A base64 string representing the data to write
+ - `domain` (`string`): The name of the memory domain the address
+ corresponds to
+
+- `DISPLAY_MESSAGE`
+ Adds a message to the message queue which will be displayed using
+ `gui.addmessage` according to the message interval.
+
+ Expected Response Type: `DISPLAY_MESSAGE_RESPONSE`
+
+ Additional Fields:
+ - `message` (`string`): The string to display
+
+- `SET_MESSAGE_INTERVAL`
+ Sets the minimum amount of time to wait between displaying messages.
+ Potentially useful if you add many messages quickly but want players
+ to be able to read each of them.
+
+ Expected Response Type: `SET_MESSAGE_INTERVAL_RESPONSE`
+
+ Additional Fields:
+ - `value` (`number`): The number of seconds to set the interval to
+
+
+### Response Types
+
+- `PONG`
+ Acknowledges `PING`.
+
+- `SYSTEM_RESPONSE`
+ Contains the name of the system for currently running ROM.
+
+ Additional Fields:
+ - `value` (`string`): The returned system name
+
+- `PREFERRED_CORES_RESPONSE`
+ Contains the user's preferred cores for systems with multiple supported
+ cores. Currently includes NES, SNES, GB, GBC, DGB, SGB, PCE, PCECD, and
+ SGX.
+
+ Additional Fields:
+ - `value` (`{[string]: [string]}`): A dictionary map from system name to
+ core name
+
+- `HASH_RESPONSE`
+ Contains the hash of the currently loaded ROM calculated by BizHawk.
+
+ Additional Fields:
+ - `value` (`string`): The returned hash
+
+- `GUARD_RESPONSE`
+ The result of an attempted `GUARD` request.
+
+ Additional Fields:
+ - `value` (`boolean`): true if the memory was validated, false if not
+ - `address` (`int`): The address of the memory that was invalid (the same
+ address provided by the `GUARD`, not the address of the individual invalid
+ byte)
+
+- `LOCKED`
+ Acknowledges `LOCK`.
+
+- `UNLOCKED`
+ Acknowledges `UNLOCK`.
+
+- `READ_RESPONSE`
+ Contains the result of a `READ` request.
+
+ Additional Fields:
+ - `value` (`string`): A base64 string representing the read data
+
+- `WRITE_RESPONSE`
+ Acknowledges `WRITE`.
+
+- `DISPLAY_MESSAGE_RESPONSE`
+ Acknowledges `DISPLAY_MESSAGE`.
+
+- `SET_MESSAGE_INTERVAL_RESPONSE`
+ Acknowledges `SET_MESSAGE_INTERVAL`.
+
+- `ERROR`
+ Signifies that something has gone wrong while processing a request.
+
+ Additional Fields:
+ - `err` (`string`): A description of the problem
+]]
+
+local base64 = require("base64")
+local socket = require("socket")
+local json = require("json")
+
+-- Set to log incoming requests
+-- Will cause lag due to large console output
+local DEBUG = false
+
+local SOCKET_PORT = 43055
+
+local STATE_NOT_CONNECTED = 0
+local STATE_CONNECTED = 1
+
+local server = nil
+local client_socket = nil
+
+local current_state = STATE_NOT_CONNECTED
+
+local timeout_timer = 0
+local message_timer = 0
+local message_interval = 0
+local prev_time = 0
+local current_time = 0
+
+local locked = false
+
+local rom_hash = nil
+
+local lua_major, lua_minor = _VERSION:match("Lua (%d+)%.(%d+)")
+lua_major = tonumber(lua_major)
+lua_minor = tonumber(lua_minor)
+
+if lua_major > 5 or (lua_major == 5 and lua_minor >= 3) then
+ require("lua_5_3_compat")
+end
+
+local bizhawk_version = client.getversion()
+local bizhawk_major, bizhawk_minor, bizhawk_patch = bizhawk_version:match("(%d+)%.(%d+)%.?(%d*)")
+bizhawk_major = tonumber(bizhawk_major)
+bizhawk_minor = tonumber(bizhawk_minor)
+if bizhawk_patch == "" then
+ bizhawk_patch = 0
+else
+ bizhawk_patch = tonumber(bizhawk_patch)
+end
+
+function queue_push (self, value)
+ self[self.right] = value
+ self.right = self.right + 1
+end
+
+function queue_is_empty (self)
+ return self.right == self.left
+end
+
+function queue_shift (self)
+ value = self[self.left]
+ self[self.left] = nil
+ self.left = self.left + 1
+ return value
+end
+
+function new_queue ()
+ local queue = {left = 1, right = 1}
+ return setmetatable(queue, {__index = {is_empty = queue_is_empty, push = queue_push, shift = queue_shift}})
+end
+
+local message_queue = new_queue()
+
+function lock ()
+ locked = true
+ client_socket:settimeout(2)
+end
+
+function unlock ()
+ locked = false
+ client_socket:settimeout(0)
+end
+
+function process_request (req)
+ local res = {}
+
+ if req["type"] == "PING" then
+ res["type"] = "PONG"
+
+ elseif req["type"] == "SYSTEM" then
+ res["type"] = "SYSTEM_RESPONSE"
+ res["value"] = emu.getsystemid()
+
+ elseif req["type"] == "PREFERRED_CORES" then
+ local preferred_cores = client.getconfig().PreferredCores
+ res["type"] = "PREFERRED_CORES_RESPONSE"
+ res["value"] = {}
+ res["value"]["NES"] = preferred_cores.NES
+ res["value"]["SNES"] = preferred_cores.SNES
+ res["value"]["GB"] = preferred_cores.GB
+ res["value"]["GBC"] = preferred_cores.GBC
+ res["value"]["DGB"] = preferred_cores.DGB
+ res["value"]["SGB"] = preferred_cores.SGB
+ res["value"]["PCE"] = preferred_cores.PCE
+ res["value"]["PCECD"] = preferred_cores.PCECD
+ res["value"]["SGX"] = preferred_cores.SGX
+
+ elseif req["type"] == "HASH" then
+ res["type"] = "HASH_RESPONSE"
+ res["value"] = rom_hash
+
+ elseif req["type"] == "GUARD" then
+ res["type"] = "GUARD_RESPONSE"
+ local expected_data = base64.decode(req["expected_data"])
+
+ local actual_data = memory.read_bytes_as_array(req["address"], #expected_data, req["domain"])
+
+ local data_is_validated = true
+ for i, byte in ipairs(actual_data) do
+ if byte ~= expected_data[i] then
+ data_is_validated = false
+ break
+ end
+ end
+
+ res["value"] = data_is_validated
+ res["address"] = req["address"]
+
+ elseif req["type"] == "LOCK" then
+ res["type"] = "LOCKED"
+ lock()
+
+ elseif req["type"] == "UNLOCK" then
+ res["type"] = "UNLOCKED"
+ unlock()
+
+ elseif req["type"] == "READ" then
+ res["type"] = "READ_RESPONSE"
+ res["value"] = base64.encode(memory.read_bytes_as_array(req["address"], req["size"], req["domain"]))
+
+ elseif req["type"] == "WRITE" then
+ res["type"] = "WRITE_RESPONSE"
+ memory.write_bytes_as_array(req["address"], base64.decode(req["value"]), req["domain"])
+
+ elseif req["type"] == "DISPLAY_MESSAGE" then
+ res["type"] = "DISPLAY_MESSAGE_RESPONSE"
+ message_queue:push(req["message"])
+
+ elseif req["type"] == "SET_MESSAGE_INTERVAL" then
+ res["type"] = "SET_MESSAGE_INTERVAL_RESPONSE"
+ message_interval = req["value"]
+
+ else
+ res["type"] = "ERROR"
+ res["err"] = "Unknown command: "..req["type"]
+ end
+
+ return res
+end
+
+-- Receive data from AP client and send message back
+function send_receive ()
+ local message, err = client_socket:receive()
+
+ -- Handle errors
+ if err == "closed" then
+ if current_state == STATE_CONNECTED then
+ print("Connection to client closed")
+ end
+ current_state = STATE_NOT_CONNECTED
+ return
+ elseif err == "timeout" then
+ unlock()
+ return
+ elseif err ~= nil then
+ print(err)
+ current_state = STATE_NOT_CONNECTED
+ unlock()
+ return
+ end
+
+ -- Reset timeout timer
+ timeout_timer = 5
+
+ -- Process received data
+ if DEBUG then
+ print("Received Message ["..emu.framecount().."]: "..'"'..message..'"')
+ end
+
+ if message == "VERSION" then
+ local result, err client_socket:send(tostring(SCRIPT_VERSION).."\n")
+ else
+ local res = {}
+ local data = json.decode(message)
+ local failed_guard_response = nil
+ for i, req in ipairs(data) do
+ if failed_guard_response ~= nil then
+ res[i] = failed_guard_response
+ else
+ -- An error is more likely to cause an NLua exception than to return an error here
+ local status, response = pcall(process_request, req)
+ if status then
+ res[i] = response
+
+ -- If the GUARD validation failed, skip the remaining commands
+ if response["type"] == "GUARD_RESPONSE" and not response["value"] then
+ failed_guard_response = response
+ end
+ else
+ res[i] = {type = "ERROR", err = response}
+ end
+ end
+ end
+
+ client_socket:send(json.encode(res).."\n")
+ end
+end
+
+function main ()
+ server, err = socket.bind("localhost", SOCKET_PORT)
+ if err ~= nil then
+ print(err)
+ return
+ end
+
+ while true do
+ current_time = socket.socket.gettime()
+ timeout_timer = timeout_timer - (current_time - prev_time)
+ message_timer = message_timer - (current_time - prev_time)
+ prev_time = current_time
+
+ if message_timer <= 0 and not message_queue:is_empty() then
+ gui.addmessage(message_queue:shift())
+ message_timer = message_interval
+ end
+
+ if current_state == STATE_NOT_CONNECTED then
+ if emu.framecount() % 60 == 0 then
+ server:settimeout(2)
+ local client, timeout = server:accept()
+ if timeout == nil then
+ print("Client connected")
+ current_state = STATE_CONNECTED
+ client_socket = client
+ client_socket:settimeout(0)
+ else
+ print("No client found. Trying again...")
+ end
+ end
+ else
+ repeat
+ send_receive()
+ until not locked
+
+ if timeout_timer <= 0 then
+ print("Client timed out")
+ current_state = STATE_NOT_CONNECTED
+ end
+ end
+
+ coroutine.yield()
+ end
+end
+
+event.onexit(function ()
+ print("\n-- Restarting Script --\n")
+ if server ~= nil then
+ server:close()
+ end
+end)
+
+if bizhawk_major < 2 or (bizhawk_major == 2 and bizhawk_minor < 7) then
+ print("Must use BizHawk 2.7.0 or newer")
+elseif bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 9) then
+ print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.9.")
+else
+ if emu.getsystemid() == "NULL" then
+ print("No ROM is loaded. Please load a ROM.")
+ while emu.getsystemid() == "NULL" do
+ emu.frameadvance()
+ end
+ end
+
+ rom_hash = gameinfo.getromhash()
+
+ print("Waiting for client to connect. Emulation will freeze intermittently until a client is found.\n")
+
+ local co = coroutine.create(main)
+ function tick ()
+ local status, err = coroutine.resume(co)
+
+ if not status then
+ print("\nERROR: "..err)
+ print("Consider reporting this crash.\n")
+
+ if server ~= nil then
+ server:close()
+ end
+
+ co = coroutine.create(main)
+ end
+ end
+
+ -- Gambatte has a setting which can cause script execution to become
+ -- misaligned, so for GB and GBC we explicitly set the callback on
+ -- vblank instead.
+ -- https://github.com/TASEmulators/BizHawk/issues/3711
+ if emu.getsystemid() == "GB" or emu.getsystemid() == "GBC" then
+ event.onmemoryexecute(tick, 0x40, "tick", "System Bus")
+ else
+ event.onframeend(tick)
+ end
+
+ while true do
+ emu.frameadvance()
+ end
+end
diff --git a/docs/contributing.md b/docs/contributing.md
index 899c06b92279..4f7af029cce8 100644
--- a/docs/contributing.md
+++ b/docs/contributing.md
@@ -1,9 +1,11 @@
# Contributing
Contributions are welcome. We have a few requests of any new contributors.
+* Follow styling as designated in our [styling documentation](/docs/style.md).
* Ensure that all changes which affect logic are covered by unit tests.
* Do not introduce any unit test failures/regressions.
-* Follow styling as designated in our [styling documentation](/docs/style.md).
+* Turn on automated github actions in your fork to have github run all the unit tests after pushing. See example below:
+![Github actions example](./img/github-actions-example.png)
Otherwise, we tend to judge code on a case to case basis.
diff --git a/docs/img/github-actions-example.png b/docs/img/github-actions-example.png
new file mode 100644
index 000000000000..2363a3ed4c56
Binary files /dev/null and b/docs/img/github-actions-example.png differ
diff --git a/docs/options api.md b/docs/options api.md
index fdabd9facd8a..2c86833800c7 100644
--- a/docs/options api.md
+++ b/docs/options api.md
@@ -28,19 +28,23 @@ Choice, and defining `alias_true = option_full`.
and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`.
As an example, suppose we want an option that lets the user start their game with a sword in their inventory. Let's
-create our option class (with a docstring), give it a `display_name`, and add it to a dictionary that keeps track of our
-options:
+create our option class (with a docstring), give it a `display_name`, and add it to our game's options dataclass:
```python
# Options.py
+from dataclasses import dataclass
+
+from Options import Toggle, PerGameCommonOptions
+
+
class StartingSword(Toggle):
"""Adds a sword to your starting inventory."""
display_name = "Start With Sword"
-example_options = {
- "starting_sword": StartingSword
-}
+@dataclass
+class ExampleGameOptions(PerGameCommonOptions):
+ starting_sword: StartingSword
```
This will create a `Toggle` option, internally called `starting_sword`. To then submit this to the multiworld, we add it
@@ -48,27 +52,30 @@ to our world's `__init__.py`:
```python
from worlds.AutoWorld import World
-from .Options import options
+from .Options import ExampleGameOptions
class ExampleWorld(World):
- option_definitions = options
+ # this gives the generator all the definitions for our options
+ options_dataclass = ExampleGameOptions
+ # this gives us typing hints for all the options we defined
+ options: ExampleGameOptions
```
### Option Checking
Options are parsed by `Generate.py` before the worlds are created, and then the option classes are created shortly after
world instantiation. These are created as attributes on the MultiWorld and can be accessed with
-`self.multiworld.my_option_name[self.player]`. This is the option class, which supports direct comparison methods to
+`self.options.my_option_name`. This is an instance of the option class, which supports direct comparison methods to
relevant objects (like comparing a Toggle class to a `bool`). If you need to access the option result directly, this is
the option class's `value` attribute. For our example above we can do a simple check:
```python
-if self.multiworld.starting_sword[self.player]:
+if self.options.starting_sword:
do_some_things()
```
or if I need a boolean object, such as in my slot_data I can access it as:
```python
-start_with_sword = bool(self.multiworld.starting_sword[self.player].value)
+start_with_sword = bool(self.options.starting_sword.value)
```
## Generic Option Classes
@@ -120,7 +127,7 @@ Like Toggle, but 1 (true) is the default value.
A numeric option allowing you to define different sub options. Values are stored as integers, but you can also do
comparison methods with the class and strings, so if you have an `option_early_sword`, this can be compared with:
```python
-if self.multiworld.sword_availability[self.player] == "early_sword":
+if self.options.sword_availability == "early_sword":
do_early_sword_things()
```
@@ -128,7 +135,7 @@ or:
```python
from .Options import SwordAvailability
-if self.multiworld.sword_availability[self.player] == SwordAvailability.option_early_sword:
+if self.options.sword_availability == SwordAvailability.option_early_sword:
do_early_sword_things()
```
@@ -160,7 +167,7 @@ within the world.
Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any
user defined string as a valid option, so will either need to be validated by adding a validation step to the option
class or within world, if necessary. Value for this class is `Union[str, int]` so if you need the value at a specified
-point, `self.multiworld.my_option[self.player].current_key` will always return a string.
+point, `self.options.my_option.current_key` will always return a string.
### PlandoBosses
An option specifically built for handling boss rando, if your game can use it. Is a subclass of TextChoice so supports
diff --git a/docs/running from source.md b/docs/running from source.md
index c0f4bf580227..b7367308d8db 100644
--- a/docs/running from source.md
+++ b/docs/running from source.md
@@ -8,7 +8,7 @@ use that version. These steps are for developers or platforms without compiled r
What you'll need:
* [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version
- * **Python 3.11 does not work currently**
+ * **Python 3.12 is currently unsupported**
* pip: included in downloads from python.org, separate in many Linux distributions
* Matching C compiler
* possibly optional, read operating system specific sections
@@ -30,7 +30,7 @@ After this, you should be able to run the programs.
Recommended steps
* Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads)
- * **Python 3.11 does not work currently**
+ * **Python 3.12 is currently unsupported**
* **Optional**: Download and install Visual Studio Build Tools from
[Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/).
diff --git a/docs/world api.md b/docs/world api.md
index 7a7f37b17ce4..6fb5b3ac9c6d 100644
--- a/docs/world api.md
+++ b/docs/world api.md
@@ -86,9 +86,11 @@ inside a `World` object.
### Player Options
Players provide customized settings for their World in the form of yamls.
-Those are accessible through `self.multiworld.[self.player]`. A dict
-of valid options has to be provided in `self.option_definitions`. Options are automatically
-added to the `World` object for easy access.
+A `dataclass` of valid options definitions has to be provided in `self.options_dataclass`.
+(It must be a subclass of `PerGameCommonOptions`.)
+Option results are automatically added to the `World` object for easy access.
+Those are accessible through `self.options.`, and you can get a dictionary of the option values via
+`self.options.as_dict()`, passing the desired options as strings.
### World Settings
@@ -221,11 +223,11 @@ See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requireme
AP will only import the `__init__.py`. Depending on code size it makes sense to
use multiple files and use relative imports to access them.
-e.g. `from .Options import mygame_options` from your `__init__.py` will load
-`worlds//Options.py` and make its `mygame_options` accessible.
+e.g. `from .Options import MyGameOptions` from your `__init__.py` will load
+`world/[world_name]/Options.py` and make its `MyGameOptions` accessible.
When imported names pile up it may be easier to use `from . import Options`
-and access the variable as `Options.mygame_options`.
+and access the variable as `Options.MyGameOptions`.
Imports from directories outside your world should use absolute imports.
Correct use of relative / absolute imports is required for zipped worlds to
@@ -273,8 +275,9 @@ Each option has its own class, inherits from a base option type, has a docstring
to describe it and a `display_name` property for display on the website and in
spoiler logs.
-The actual name as used in the yaml is defined in a `Dict[str, AssembleOptions]`, that is
-assigned to the world under `self.option_definitions`.
+The actual name as used in the yaml is defined via the field names of a `dataclass` that is
+assigned to the world under `self.options_dataclass`. By convention, the strings
+that define your option names should be in `snake_case`.
Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`.
For more see `Options.py` in AP's base directory.
@@ -309,8 +312,8 @@ default = 0
```python
# Options.py
-from Options import Toggle, Range, Choice, Option
-import typing
+from dataclasses import dataclass
+from Options import Toggle, Range, Choice, PerGameCommonOptions
class Difficulty(Choice):
"""Sets overall game difficulty."""
@@ -333,23 +336,27 @@ class FixXYZGlitch(Toggle):
"""Fixes ABC when you do XYZ"""
display_name = "Fix XYZ Glitch"
-# By convention we call the options dict variable `_options`.
-mygame_options: typing.Dict[str, AssembleOptions] = {
- "difficulty": Difficulty,
- "final_boss_hp": FinalBossHP,
- "fix_xyz_glitch": FixXYZGlitch,
-}
+# By convention, we call the options dataclass `Options`.
+# It has to be derived from 'PerGameCommonOptions'.
+@dataclass
+class MyGameOptions(PerGameCommonOptions):
+ difficulty: Difficulty
+ final_boss_hp: FinalBossHP
+ fix_xyz_glitch: FixXYZGlitch
```
+
```python
# __init__.py
from worlds.AutoWorld import World
-from .Options import mygame_options # import the options dict
+from .Options import MyGameOptions # import the options dataclass
+
class MyGameWorld(World):
- #...
- option_definitions = mygame_options # assign the options dict to the world
- #...
+ # ...
+ options_dataclass = MyGameOptions # assign the options dataclass to the world
+ options: MyGameOptions # typing for option results
+ # ...
```
### A World Class Skeleton
@@ -359,13 +366,14 @@ class MyGameWorld(World):
import settings
import typing
-from .Options import mygame_options # the options we defined earlier
+from .Options import MyGameOptions # the options we defined earlier
from .Items import mygame_items # data used below to add items to the World
from .Locations import mygame_locations # same as above
from worlds.AutoWorld import World
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
+
class MyGameItem(Item): # or from Items import MyGameItem
game = "My Game" # name of the game/world this item is from
@@ -374,6 +382,7 @@ class MyGameLocation(Location): # or from Locations import MyGameLocation
game = "My Game" # name of the game/world this location is in
+
class MyGameSettings(settings.Group):
class RomFile(settings.SNESRomPath):
"""Insert help text for host.yaml here."""
@@ -384,7 +393,8 @@ class MyGameSettings(settings.Group):
class MyGameWorld(World):
"""Insert description of the world/game here."""
game = "My Game" # name of the game/world
- option_definitions = mygame_options # options the player can set
+ options_dataclass = MyGameOptions # options the player can set
+ options: MyGameOptions # typing hints for option results
settings: typing.ClassVar[MyGameSettings] # will be automatically assigned from type hint
topology_present = True # show path to required location checks in spoiler
@@ -460,7 +470,7 @@ In addition, the following methods can be implemented and are called in this ord
```python
def generate_early(self) -> None:
# read player settings to world instance
- self.final_boss_hp = self.multiworld.final_boss_hp[self.player].value
+ self.final_boss_hp = self.options.final_boss_hp.value
```
#### create_item
@@ -559,6 +569,12 @@ def generate_basic(self) -> None:
# in most cases it's better to do this at the same time the itempool is
# filled to avoid accidental duplicates:
# manually placed and still in the itempool
+
+ # for debugging purposes, you may want to visualize the layout of your world. Uncomment the following code to
+ # write a PlantUML diagram to the file "my_world.puml" that can help you see whether your regions and locations
+ # are connected and placed as desired
+ # from Utils import visualize_regions
+ # visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
```
### Setting Rules
@@ -681,9 +697,9 @@ def generate_output(self, output_directory: str):
in self.multiworld.precollected_items[self.player]],
"final_boss_hp": self.final_boss_hp,
# store option name "easy", "normal" or "hard" for difficuly
- "difficulty": self.multiworld.difficulty[self.player].current_key,
+ "difficulty": self.options.difficulty.current_key,
# store option value True or False for fixing a glitch
- "fix_xyz_glitch": self.multiworld.fix_xyz_glitch[self.player].value,
+ "fix_xyz_glitch": self.options.fix_xyz_glitch.value,
}
# point to a ROM specified by the installation
src = self.settings.rom_file
@@ -696,6 +712,26 @@ def generate_output(self, output_directory: str):
generate_mod(src, out_file, data)
```
+### Slot Data
+
+If the game client needs to know information about the generated seed, a preferred method of transferring the data
+is through the slot data. This can be filled from the `fill_slot_data` method of your world by returning a `Dict[str, Any]`,
+but should be limited to data that is absolutely necessary to not waste resources. Slot data is sent to your client once
+it has successfully [connected](network%20protocol.md#connected).
+If you need to know information about locations in your world, instead
+of propagating the slot data, it is preferable to use [LocationScouts](network%20protocol.md#locationscouts) since that
+data already exists on the server. The most common usage of slot data is to send option results that the client needs
+to be aware of.
+
+```python
+def fill_slot_data(self):
+ # in order for our game client to handle the generated seed correctly we need to know what the user selected
+ # for their difficulty and final boss HP
+ # a dictionary returned from this method gets set as the slot_data and will be sent to the client after connecting
+ # the options dataclass has a method to return a `Dict[str, Any]` of each option name provided and the option's value
+ return self.options.as_dict("difficulty", "final_boss_hp")
+```
+
### Documentation
Each world implementation should have a tutorial and a game info page. These are both rendered on the website by reading
diff --git a/inno_setup.iss b/inno_setup.iss
index 147cd74dca07..3c1bdc4571e0 100644
--- a/inno_setup.iss
+++ b/inno_setup.iss
@@ -74,6 +74,7 @@ Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup";
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/l2ac"; Description: "SNI Client - Lufia II Ancient Cave Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
+Name: "client/bizhawk"; Description: "BizHawk Client"; Types: full playing
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "client/kh2"; Description: "Kingdom Hearts 2"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
@@ -122,6 +123,7 @@ Source: "{#source_path}\ArchipelagoServer.exe"; DestDir: "{app}"; Flags: ignorev
Source: "{#source_path}\ArchipelagoFactorioClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/factorio
Source: "{#source_path}\ArchipelagoTextClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/text
Source: "{#source_path}\ArchipelagoSNIClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni
+Source: "{#source_path}\ArchipelagoBizHawkClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/bizhawk
Source: "{#source_path}\ArchipelagoLinksAwakeningClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ladx
Source: "{#source_path}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni/lttp or generator/lttp
Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
@@ -146,6 +148,7 @@ Name: "{group}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLauncher.exe"
Name: "{group}\{#MyAppName} Server"; Filename: "{app}\ArchipelagoServer"; Components: server
Name: "{group}\{#MyAppName} Text Client"; Filename: "{app}\ArchipelagoTextClient.exe"; Components: client/text
Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Components: client/sni
+Name: "{group}\{#MyAppName} BizHawk Client"; Filename: "{app}\ArchipelagoBizHawkClient.exe"; Components: client/bizhawk
Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
@@ -166,6 +169,7 @@ Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopic
Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLauncher.exe"; Tasks: desktopicon
Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\ArchipelagoServer"; Tasks: desktopicon; Components: server
Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Tasks: desktopicon; Components: client/sni
+Name: "{commondesktop}\{#MyAppName} BizHawk Client"; Filename: "{app}\ArchipelagoBizHawkClient.exe"; Tasks: desktopicon; Components: client/bizhawk
Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot
diff --git a/kvui.py b/kvui.py
index 835f0dad45f4..71bf80c86d9b 100644
--- a/kvui.py
+++ b/kvui.py
@@ -7,7 +7,10 @@
import ctypes
# kivy 2.2.0 introduced DPI awareness on Windows, but it makes the UI enter an infinitely recursive re-layout
# by setting the application to not DPI Aware, Windows handles scaling the entire window on its own, ignoring kivy's
- ctypes.windll.shcore.SetProcessDpiAwareness(0)
+ try:
+ ctypes.windll.shcore.SetProcessDpiAwareness(0)
+ except FileNotFoundError: # shcore may not be found on <= Windows 7
+ pass # TODO: remove silent except when Python 3.8 is phased out.
os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1"
diff --git a/setup.py b/setup.py
index ce35c0f1cc5d..6d4d947dbd1f 100644
--- a/setup.py
+++ b/setup.py
@@ -370,6 +370,10 @@ def run(self):
assert not non_apworlds - set(AutoWorldRegister.world_types), \
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
folders_to_remove: typing.List[str] = []
+ disabled_worlds_folder = "worlds_disabled"
+ for entry in os.listdir(disabled_worlds_folder):
+ if os.path.isdir(os.path.join(disabled_worlds_folder, entry)):
+ folders_to_remove.append(entry)
generate_yaml_templates(self.buildfolder / "Players" / "Templates", False)
for worldname, worldtype in AutoWorldRegister.world_types.items():
if worldname not in non_apworlds:
diff --git a/test/TestBase.py b/test/TestBase.py
index 856428fb57ed..e6fbafd95aa0 100644
--- a/test/TestBase.py
+++ b/test/TestBase.py
@@ -1,17 +1,12 @@
-import pathlib
import typing
import unittest
from argparse import Namespace
-import Utils
from test.general import gen_steps
from worlds import AutoWorld
from worlds.AutoWorld import call_all
-file_path = pathlib.Path(__file__).parent.parent
-Utils.local_path.cached_path = file_path
-
-from BaseClasses import MultiWorld, CollectionState, ItemClassification, Item
+from BaseClasses import Location, MultiWorld, CollectionState, ItemClassification, Item
from worlds.alttp.Items import ItemFactory
@@ -130,13 +125,13 @@ def world_setup(self, seed: typing.Optional[int] = None) -> None:
self.multiworld.game[1] = self.game
self.multiworld.player_name = {1: "Tester"}
self.multiworld.set_seed(seed)
+ self.multiworld.state = CollectionState(self.multiworld)
args = Namespace()
- for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].option_definitions.items():
+ for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].options_dataclass.type_hints.items():
setattr(args, name, {
1: option.from_any(self.options.get(name, getattr(option, "default")))
})
self.multiworld.set_options(args)
- self.multiworld.set_default_common_options()
for step in gen_steps:
call_all(self.multiworld, step)
@@ -194,12 +189,16 @@ def remove(self, items: typing.Union[Item, typing.Iterable[Item]]) -> None:
self.multiworld.state.remove(item)
def can_reach_location(self, location: str) -> bool:
- """Determines if the current state can reach the provide location name"""
+ """Determines if the current state can reach the provided location name"""
return self.multiworld.state.can_reach(location, "Location", 1)
def can_reach_entrance(self, entrance: str) -> bool:
"""Determines if the current state can reach the provided entrance name"""
return self.multiworld.state.can_reach(entrance, "Entrance", 1)
+
+ def can_reach_region(self, region: str) -> bool:
+ """Determines if the current state can reach the provided region name"""
+ return self.multiworld.state.can_reach(region, "Region", 1)
def count(self, item_name: str) -> int:
"""Returns the amount of an item currently in state"""
@@ -276,3 +275,37 @@ def testEmptyStateCanReachSomething(self):
locations = self.multiworld.get_reachable_locations(state, 1)
self.assertGreater(len(locations), 0,
"Need to be able to reach at least one location to get started.")
+
+ def testFill(self):
+ """Generates a multiworld and validates placements with the defined options"""
+ # don't run this test if accessibility is set manually
+ if not (self.run_default_tests and self.constructed):
+ return
+ from Fill import distribute_items_restrictive
+
+ # basically a shortened reimplementation of this method from core, in order to force the check is done
+ def fulfills_accessibility():
+ locations = self.multiworld.get_locations(1).copy()
+ state = CollectionState(self.multiworld)
+ while locations:
+ sphere: typing.List[Location] = []
+ for n in range(len(locations) - 1, -1, -1):
+ if locations[n].can_reach(state):
+ sphere.append(locations.pop(n))
+ self.assertTrue(sphere or self.multiworld.accessibility[1] == "minimal",
+ f"Unreachable locations: {locations}")
+ if not sphere:
+ break
+ for location in sphere:
+ if location.item:
+ state.collect(location.item, True, location)
+
+ return self.multiworld.has_beaten_game(state, 1)
+
+ with self.subTest("Game", game=self.game):
+ distribute_items_restrictive(self.multiworld)
+ call_all(self.multiworld, "post_fill")
+ self.assertTrue(fulfills_accessibility(), "Collected all locations, but can't beat the game.")
+ placed_items = [loc.item for loc in self.multiworld.get_locations() if loc.item and loc.item.code]
+ self.assertLessEqual(len(self.multiworld.itempool), len(placed_items),
+ "Unplaced Items remaining in itempool")
diff --git a/test/__init__.py b/test/__init__.py
index 32622f65a927..37ebe3f62743 100644
--- a/test/__init__.py
+++ b/test/__init__.py
@@ -1,3 +1,4 @@
+import pathlib
import warnings
import settings
@@ -5,3 +6,13 @@
warnings.simplefilter("always")
settings.no_gui = True
settings.skip_autosave = True
+
+import ModuleUpdate
+
+ModuleUpdate.update_ran = True # don't upgrade
+
+import Utils
+
+file_path = pathlib.Path(__file__).parent.parent
+Utils.local_path.cached_path = file_path
+Utils.user_path() # initialize cached_path
diff --git a/test/general/TestFill.py b/test/general/TestFill.py
index 99f48cd0c70f..0933603dfdd0 100644
--- a/test/general/TestFill.py
+++ b/test/general/TestFill.py
@@ -1,16 +1,20 @@
from typing import List, Iterable
import unittest
+
+import Options
+from Options import Accessibility
from worlds.AutoWorld import World
from Fill import FillError, balance_multiworld_progression, fill_restrictive, \
distribute_early_items, distribute_items_restrictive
from BaseClasses import Entrance, LocationProgressType, MultiWorld, Region, Item, Location, \
- ItemClassification
+ ItemClassification, CollectionState
from worlds.generic.Rules import CollectionRule, add_item_rule, locality_rules, set_rule
def generate_multi_world(players: int = 1) -> MultiWorld:
multi_world = MultiWorld(players)
multi_world.player_name = {}
+ multi_world.state = CollectionState(multi_world)
for i in range(players):
player_id = i+1
world = World(multi_world, player_id)
@@ -19,9 +23,16 @@ def generate_multi_world(players: int = 1) -> MultiWorld:
multi_world.player_name[player_id] = "Test Player " + str(player_id)
region = Region("Menu", player_id, multi_world, "Menu Region Hint")
multi_world.regions.append(region)
+ for option_key, option in Options.PerGameCommonOptions.type_hints.items():
+ if hasattr(multi_world, option_key):
+ getattr(multi_world, option_key).setdefault(player_id, option.from_any(getattr(option, "default")))
+ else:
+ setattr(multi_world, option_key, {player_id: option.from_any(getattr(option, "default"))})
+ # TODO - remove this loop once all worlds use options dataclasses
+ world.options = world.options_dataclass(**{option_key: getattr(multi_world, option_key)[player_id]
+ for option_key in world.options_dataclass.type_hints})
multi_world.set_seed(0)
- multi_world.set_default_common_options()
return multi_world
@@ -186,7 +197,7 @@ def test_minimal_fill(self):
items = player1.prog_items
locations = player1.locations
- multi_world.accessibility[player1.id].value = multi_world.accessibility[player1.id].option_minimal
+ multi_world.worlds[player1.id].options.accessibility = Accessibility.from_any(Accessibility.option_minimal)
multi_world.completion_condition[player1.id] = lambda state: state.has(
items[1].name, player1.id)
set_rule(locations[1], lambda state: state.has(
diff --git a/test/general/TestHelpers.py b/test/general/TestHelpers.py
index c0b560c7e4a6..17fdce653c8c 100644
--- a/test/general/TestHelpers.py
+++ b/test/general/TestHelpers.py
@@ -1,3 +1,4 @@
+from argparse import Namespace
from typing import Dict, Optional, Callable
from BaseClasses import MultiWorld, CollectionState, Region
@@ -13,7 +14,6 @@ def setUp(self) -> None:
self.multiworld.game[self.player] = "helper_test_game"
self.multiworld.player_name = {1: "Tester"}
self.multiworld.set_seed()
- self.multiworld.set_default_common_options()
def testRegionHelpers(self) -> None:
regions: Dict[str, str] = {
diff --git a/test/general/TestOptions.py b/test/general/TestOptions.py
index b7058183e09c..4a3bd0b02a0a 100644
--- a/test/general/TestOptions.py
+++ b/test/general/TestOptions.py
@@ -6,6 +6,6 @@ class TestOptions(unittest.TestCase):
def testOptionsHaveDocString(self):
for gamename, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden:
- for option_key, option in world_type.option_definitions.items():
+ for option_key, option in world_type.options_dataclass.type_hints.items():
with self.subTest(game=gamename, option=option_key):
self.assertTrue(option.__doc__)
diff --git a/test/general/__init__.py b/test/general/__init__.py
index b0fb7ca32e76..d7ecc9574930 100644
--- a/test/general/__init__.py
+++ b/test/general/__init__.py
@@ -1,7 +1,7 @@
from argparse import Namespace
from typing import Type, Tuple
-from BaseClasses import MultiWorld
+from BaseClasses import MultiWorld, CollectionState
from worlds.AutoWorld import call_all, World
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
@@ -12,11 +12,11 @@ def setup_solo_multiworld(world_type: Type[World], steps: Tuple[str, ...] = gen_
multiworld.game[1] = world_type.game
multiworld.player_name = {1: "Tester"}
multiworld.set_seed()
+ multiworld.state = CollectionState(multiworld)
args = Namespace()
- for name, option in world_type.option_definitions.items():
+ for name, option in world_type.options_dataclass.type_hints.items():
setattr(args, name, {1: option.from_any(option.default)})
multiworld.set_options(args)
- multiworld.set_default_common_options()
for step in steps:
call_all(multiworld, step)
return multiworld
diff --git a/test/programs/TestGenerate.py b/test/programs/TestGenerate.py
index d04e1f2c5bf4..73e1d3b8348c 100644
--- a/test/programs/TestGenerate.py
+++ b/test/programs/TestGenerate.py
@@ -1,13 +1,13 @@
# Tests for Generate.py (ArchipelagoGenerate.exe)
import unittest
+import os
+import os.path
import sys
+
from pathlib import Path
from tempfile import TemporaryDirectory
-import os.path
-import os
-import ModuleUpdate
-ModuleUpdate.update_ran = True # don't upgrade
+
import Generate
diff --git a/test/worlds/__init__.py b/test/worlds/__init__.py
index d1817cc67489..cf396111bfd3 100644
--- a/test/worlds/__init__.py
+++ b/test/worlds/__init__.py
@@ -1,7 +1,7 @@
def load_tests(loader, standard_tests, pattern):
import os
import unittest
- from ..TestBase import file_path
+ from .. import file_path
from worlds.AutoWorld import AutoWorldRegister
suite = unittest.TestSuite()
diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py
index e2fda16b87d3..9a8b6a56ef36 100644
--- a/worlds/AutoWorld.py
+++ b/worlds/AutoWorld.py
@@ -4,11 +4,12 @@
import logging
import pathlib
import sys
-from typing import Any, Callable, ClassVar, Dict, FrozenSet, List, Optional, Set, TYPE_CHECKING, TextIO, Tuple, Type, \
+from dataclasses import make_dataclass
+from typing import Any, Callable, ClassVar, Dict, Set, Tuple, FrozenSet, List, Optional, TYPE_CHECKING, TextIO, Type, \
Union
+from Options import PerGameCommonOptions
from BaseClasses import CollectionState
-from Options import AssembleOptions
if TYPE_CHECKING:
import random
@@ -63,6 +64,12 @@ def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> Aut
dct["required_client_version"] = max(dct["required_client_version"],
base.__dict__["required_client_version"])
+ # create missing options_dataclass from legacy option_definitions
+ # TODO - remove this once all worlds use options dataclasses
+ if "options_dataclass" not in dct and "option_definitions" in dct:
+ dct["options_dataclass"] = make_dataclass(f"{name}Options", dct["option_definitions"].items(),
+ bases=(PerGameCommonOptions,))
+
# construct class
new_class = super().__new__(mcs, name, bases, dct)
if "game" in dct:
@@ -163,8 +170,11 @@ class World(metaclass=AutoWorldRegister):
"""A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
A Game should have its own subclass of World in which it defines the required data structures."""
- option_definitions: ClassVar[Dict[str, AssembleOptions]] = {}
+ options_dataclass: ClassVar[Type[PerGameCommonOptions]] = PerGameCommonOptions
"""link your Options mapping"""
+ options: PerGameCommonOptions
+ """resulting options for the player of this world"""
+
game: ClassVar[str]
"""name the game"""
topology_present: ClassVar[bool] = False
@@ -362,16 +372,14 @@ def get_filler_item_name(self) -> str:
def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: Set[int]) -> World:
"""Creates a group, which is an instance of World that is responsible for multiple others.
An example case is ItemLinks creating these."""
- import Options
-
- for option_key, option in cls.option_definitions.items():
- getattr(multiworld, option_key)[new_player_id] = option(option.default)
- for option_key, option in Options.common_options.items():
- getattr(multiworld, option_key)[new_player_id] = option(option.default)
- for option_key, option in Options.per_game_common_options.items():
+ # TODO remove loop when worlds use options dataclass
+ for option_key, option in cls.options_dataclass.type_hints.items():
getattr(multiworld, option_key)[new_player_id] = option(option.default)
+ group = cls(multiworld, new_player_id)
+ group.options = cls.options_dataclass(**{option_key: option(option.default)
+ for option_key, option in cls.options_dataclass.type_hints.items()})
- return cls(multiworld, new_player_id)
+ return group
# decent place to implement progressive items, in most cases can stay as-is
def collect_item(self, state: "CollectionState", item: "Item", remove: bool = False) -> Optional[str]:
diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py
index c3ae2b0495b0..2d445a77b8e0 100644
--- a/worlds/LauncherComponents.py
+++ b/worlds/LauncherComponents.py
@@ -89,6 +89,9 @@ def launch_textclient():
Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3',
'.apsmw', '.apl2ac')),
+ # BizHawk
+ Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT,
+ file_identifier=SuffixIdentifier()),
Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')),
Component('LttP Adjuster', 'LttPAdjuster'),
diff --git a/worlds/_bizhawk/__init__.py b/worlds/_bizhawk/__init__.py
new file mode 100644
index 000000000000..cdf227ec7bdc
--- /dev/null
+++ b/worlds/_bizhawk/__init__.py
@@ -0,0 +1,326 @@
+"""
+A module for interacting with BizHawk through `connector_bizhawk_generic.lua`.
+
+Any mention of `domain` in this module refers to the names BizHawk gives to memory domains in its own lua api. They are
+naively passed to BizHawk without validation or modification.
+"""
+
+import asyncio
+import base64
+import enum
+import json
+import typing
+
+
+BIZHAWK_SOCKET_PORT = 43055
+EXPECTED_SCRIPT_VERSION = 1
+
+
+class ConnectionStatus(enum.IntEnum):
+ NOT_CONNECTED = 1
+ TENTATIVE = 2
+ CONNECTED = 3
+
+
+class BizHawkContext:
+ streams: typing.Optional[typing.Tuple[asyncio.StreamReader, asyncio.StreamWriter]]
+ connection_status: ConnectionStatus
+
+ def __init__(self) -> None:
+ self.streams = None
+ self.connection_status = ConnectionStatus.NOT_CONNECTED
+
+
+class NotConnectedError(Exception):
+ """Raised when something tries to make a request to the connector script before a connection has been established"""
+ pass
+
+
+class RequestFailedError(Exception):
+ """Raised when the connector script did not respond to a request"""
+ pass
+
+
+class ConnectorError(Exception):
+ """Raised when the connector script encounters an error while processing a request"""
+ pass
+
+
+class SyncError(Exception):
+ """Raised when the connector script responded with a mismatched response type"""
+ pass
+
+
+async def connect(ctx: BizHawkContext) -> bool:
+ """Attempts to establish a connection with the connector script. Returns True if successful."""
+ try:
+ ctx.streams = await asyncio.open_connection("localhost", BIZHAWK_SOCKET_PORT)
+ ctx.connection_status = ConnectionStatus.TENTATIVE
+ return True
+ except (TimeoutError, ConnectionRefusedError):
+ ctx.streams = None
+ ctx.connection_status = ConnectionStatus.NOT_CONNECTED
+ return False
+
+
+def disconnect(ctx: BizHawkContext) -> None:
+ """Closes the connection to the connector script."""
+ if ctx.streams is not None:
+ ctx.streams[1].close()
+ ctx.streams = None
+ ctx.connection_status = ConnectionStatus.NOT_CONNECTED
+
+
+async def get_script_version(ctx: BizHawkContext) -> int:
+ if ctx.streams is None:
+ raise NotConnectedError("You tried to send a request before a connection to BizHawk was made")
+
+ try:
+ reader, writer = ctx.streams
+ writer.write("VERSION".encode("ascii") + b"\n")
+ await asyncio.wait_for(writer.drain(), timeout=5)
+
+ version = await asyncio.wait_for(reader.readline(), timeout=5)
+
+ if version == b"":
+ writer.close()
+ ctx.streams = None
+ ctx.connection_status = ConnectionStatus.NOT_CONNECTED
+ raise RequestFailedError("Connection closed")
+
+ return int(version.decode("ascii"))
+ except asyncio.TimeoutError as exc:
+ writer.close()
+ ctx.streams = None
+ ctx.connection_status = ConnectionStatus.NOT_CONNECTED
+ raise RequestFailedError("Connection timed out") from exc
+ except ConnectionResetError as exc:
+ writer.close()
+ ctx.streams = None
+ ctx.connection_status = ConnectionStatus.NOT_CONNECTED
+ raise RequestFailedError("Connection reset") from exc
+
+
+async def send_requests(ctx: BizHawkContext, req_list: typing.List[typing.Dict[str, typing.Any]]) -> typing.List[typing.Dict[str, typing.Any]]:
+ """Sends a list of requests to the BizHawk connector and returns their responses.
+
+ It's likely you want to use the wrapper functions instead of this."""
+ if ctx.streams is None:
+ raise NotConnectedError("You tried to send a request before a connection to BizHawk was made")
+
+ try:
+ reader, writer = ctx.streams
+ writer.write(json.dumps(req_list).encode("utf-8") + b"\n")
+ await asyncio.wait_for(writer.drain(), timeout=5)
+
+ res = await asyncio.wait_for(reader.readline(), timeout=5)
+
+ if res == b"":
+ writer.close()
+ ctx.streams = None
+ ctx.connection_status = ConnectionStatus.NOT_CONNECTED
+ raise RequestFailedError("Connection closed")
+
+ if ctx.connection_status == ConnectionStatus.TENTATIVE:
+ ctx.connection_status = ConnectionStatus.CONNECTED
+
+ ret = json.loads(res.decode("utf-8"))
+ for response in ret:
+ if response["type"] == "ERROR":
+ raise ConnectorError(response["err"])
+
+ return ret
+ except asyncio.TimeoutError as exc:
+ writer.close()
+ ctx.streams = None
+ ctx.connection_status = ConnectionStatus.NOT_CONNECTED
+ raise RequestFailedError("Connection timed out") from exc
+ except ConnectionResetError as exc:
+ writer.close()
+ ctx.streams = None
+ ctx.connection_status = ConnectionStatus.NOT_CONNECTED
+ raise RequestFailedError("Connection reset") from exc
+
+
+async def ping(ctx: BizHawkContext) -> None:
+ """Sends a PING request and receives a PONG response."""
+ res = (await send_requests(ctx, [{"type": "PING"}]))[0]
+
+ if res["type"] != "PONG":
+ raise SyncError(f"Expected response of type PONG but got {res['type']}")
+
+
+async def get_hash(ctx: BizHawkContext) -> str:
+ """Gets the system name for the currently loaded ROM"""
+ res = (await send_requests(ctx, [{"type": "HASH"}]))[0]
+
+ if res["type"] != "HASH_RESPONSE":
+ raise SyncError(f"Expected response of type HASH_RESPONSE but got {res['type']}")
+
+ return res["value"]
+
+
+async def get_system(ctx: BizHawkContext) -> str:
+ """Gets the system name for the currently loaded ROM"""
+ res = (await send_requests(ctx, [{"type": "SYSTEM"}]))[0]
+
+ if res["type"] != "SYSTEM_RESPONSE":
+ raise SyncError(f"Expected response of type SYSTEM_RESPONSE but got {res['type']}")
+
+ return res["value"]
+
+
+async def get_cores(ctx: BizHawkContext) -> typing.Dict[str, str]:
+ """Gets the preferred cores for systems with multiple cores. Only systems with multiple available cores have
+ entries."""
+ res = (await send_requests(ctx, [{"type": "PREFERRED_CORES"}]))[0]
+
+ if res["type"] != "PREFERRED_CORES_RESPONSE":
+ raise SyncError(f"Expected response of type PREFERRED_CORES_RESPONSE but got {res['type']}")
+
+ return res["value"]
+
+
+async def lock(ctx: BizHawkContext) -> None:
+ """Locks BizHawk in anticipation of receiving more requests this frame.
+
+ Consider using guarded reads and writes instead of locks if possible.
+
+ While locked, emulation will halt and the connector will block on incoming requests until an `UNLOCK` request is
+ sent. Remember to unlock when you're done, or the emulator will appear to freeze.
+
+ Sending multiple lock commands is the same as sending one."""
+ res = (await send_requests(ctx, [{"type": "LOCK"}]))[0]
+
+ if res["type"] != "LOCKED":
+ raise SyncError(f"Expected response of type LOCKED but got {res['type']}")
+
+
+async def unlock(ctx: BizHawkContext) -> None:
+ """Unlocks BizHawk to allow it to resume emulation. See `lock` for more info.
+
+ Sending multiple unlock commands is the same as sending one."""
+ res = (await send_requests(ctx, [{"type": "UNLOCK"}]))[0]
+
+ if res["type"] != "UNLOCKED":
+ raise SyncError(f"Expected response of type UNLOCKED but got {res['type']}")
+
+
+async def display_message(ctx: BizHawkContext, message: str) -> None:
+ """Displays the provided message in BizHawk's message queue."""
+ res = (await send_requests(ctx, [{"type": "DISPLAY_MESSAGE", "message": message}]))[0]
+
+ if res["type"] != "DISPLAY_MESSAGE_RESPONSE":
+ raise SyncError(f"Expected response of type DISPLAY_MESSAGE_RESPONSE but got {res['type']}")
+
+
+async def set_message_interval(ctx: BizHawkContext, value: float) -> None:
+ """Sets the minimum amount of time in seconds to wait between queued messages. The default value of 0 will allow one
+ new message to display per frame."""
+ res = (await send_requests(ctx, [{"type": "SET_MESSAGE_INTERVAL", "value": value}]))[0]
+
+ if res["type"] != "SET_MESSAGE_INTERVAL_RESPONSE":
+ raise SyncError(f"Expected response of type SET_MESSAGE_INTERVAL_RESPONSE but got {res['type']}")
+
+
+async def guarded_read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int, str]],
+ guard_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> typing.Optional[typing.List[bytes]]:
+ """Reads an array of bytes at 1 or more addresses if and only if every byte in guard_list matches its expected
+ value.
+
+ Items in read_list should be organized (address, size, domain) where
+ - `address` is the address of the first byte of data
+ - `size` is the number of bytes to read
+ - `domain` is the name of the region of memory the address corresponds to
+
+ Items in `guard_list` should be organized `(address, expected_data, domain)` where
+ - `address` is the address of the first byte of data
+ - `expected_data` is the bytes that the data starting at this address is expected to match
+ - `domain` is the name of the region of memory the address corresponds to
+
+ Returns None if any item in guard_list failed to validate. Otherwise returns a list of bytes in the order they
+ were requested."""
+ res = await send_requests(ctx, [{
+ "type": "GUARD",
+ "address": address,
+ "expected_data": base64.b64encode(bytes(expected_data)).decode("ascii"),
+ "domain": domain
+ } for address, expected_data, domain in guard_list] + [{
+ "type": "READ",
+ "address": address,
+ "size": size,
+ "domain": domain
+ } for address, size, domain in read_list])
+
+ ret: typing.List[bytes] = []
+ for item in res:
+ if item["type"] == "GUARD_RESPONSE":
+ if not item["value"]:
+ return None
+ else:
+ if item["type"] != "READ_RESPONSE":
+ raise SyncError(f"Expected response of type READ_RESPONSE or GUARD_RESPONSE but got {res['type']}")
+
+ ret.append(base64.b64decode(item["value"]))
+
+ return ret
+
+
+async def read(ctx: BizHawkContext, read_list: typing.List[typing.Tuple[int, int, str]]) -> typing.List[bytes]:
+ """Reads data at 1 or more addresses.
+
+ Items in `read_list` should be organized `(address, size, domain)` where
+ - `address` is the address of the first byte of data
+ - `size` is the number of bytes to read
+ - `domain` is the name of the region of memory the address corresponds to
+
+ Returns a list of bytes in the order they were requested."""
+ return await guarded_read(ctx, read_list, [])
+
+
+async def guarded_write(ctx: BizHawkContext, write_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]],
+ guard_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> bool:
+ """Writes data to 1 or more addresses if and only if every byte in guard_list matches its expected value.
+
+ Items in `write_list` should be organized `(address, value, domain)` where
+ - `address` is the address of the first byte of data
+ - `value` is a list of bytes to write, in order, starting at `address`
+ - `domain` is the name of the region of memory the address corresponds to
+
+ Items in `guard_list` should be organized `(address, expected_data, domain)` where
+ - `address` is the address of the first byte of data
+ - `expected_data` is the bytes that the data starting at this address is expected to match
+ - `domain` is the name of the region of memory the address corresponds to
+
+ Returns False if any item in guard_list failed to validate. Otherwise returns True."""
+ res = await send_requests(ctx, [{
+ "type": "GUARD",
+ "address": address,
+ "expected_data": base64.b64encode(bytes(expected_data)).decode("ascii"),
+ "domain": domain
+ } for address, expected_data, domain in guard_list] + [{
+ "type": "WRITE",
+ "address": address,
+ "value": base64.b64encode(bytes(value)).decode("ascii"),
+ "domain": domain
+ } for address, value, domain in write_list])
+
+ for item in res:
+ if item["type"] == "GUARD_RESPONSE":
+ if not item["value"]:
+ return False
+ else:
+ if item["type"] != "WRITE_RESPONSE":
+ raise SyncError(f"Expected response of type WRITE_RESPONSE or GUARD_RESPONSE but got {res['type']}")
+
+ return True
+
+
+async def write(ctx: BizHawkContext, write_list: typing.List[typing.Tuple[int, typing.Iterable[int], str]]) -> None:
+ """Writes data to 1 or more addresses.
+
+ Items in write_list should be organized `(address, value, domain)` where
+ - `address` is the address of the first byte of data
+ - `value` is a list of bytes to write, in order, starting at `address`
+ - `domain` is the name of the region of memory the address corresponds to"""
+ await guarded_write(ctx, write_list, [])
diff --git a/worlds/_bizhawk/client.py b/worlds/_bizhawk/client.py
new file mode 100644
index 000000000000..b614c083ba4e
--- /dev/null
+++ b/worlds/_bizhawk/client.py
@@ -0,0 +1,87 @@
+"""
+A module containing the BizHawkClient base class and metaclass
+"""
+
+
+from __future__ import annotations
+
+import abc
+from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional, Tuple, Union
+
+from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch_subprocess
+
+if TYPE_CHECKING:
+ from .context import BizHawkClientContext
+else:
+ BizHawkClientContext = object
+
+
+class AutoBizHawkClientRegister(abc.ABCMeta):
+ game_handlers: ClassVar[Dict[Tuple[str, ...], Dict[str, BizHawkClient]]] = {}
+
+ def __new__(cls, name: str, bases: Tuple[type, ...], namespace: Dict[str, Any]) -> AutoBizHawkClientRegister:
+ new_class = super().__new__(cls, name, bases, namespace)
+
+ if "system" in namespace:
+ systems = (namespace["system"],) if type(namespace["system"]) is str else tuple(sorted(namespace["system"]))
+ if systems not in AutoBizHawkClientRegister.game_handlers:
+ AutoBizHawkClientRegister.game_handlers[systems] = {}
+
+ if "game" in namespace:
+ AutoBizHawkClientRegister.game_handlers[systems][namespace["game"]] = new_class()
+
+ return new_class
+
+ @staticmethod
+ async def get_handler(ctx: BizHawkClientContext, system: str) -> Optional[BizHawkClient]:
+ for systems, handlers in AutoBizHawkClientRegister.game_handlers.items():
+ if system in systems:
+ for handler in handlers.values():
+ if await handler.validate_rom(ctx):
+ return handler
+
+ return None
+
+
+class BizHawkClient(abc.ABC, metaclass=AutoBizHawkClientRegister):
+ system: ClassVar[Union[str, Tuple[str, ...]]]
+ """The system that the game this client is for runs on"""
+
+ game: ClassVar[str]
+ """The game this client is for"""
+
+ @abc.abstractmethod
+ async def validate_rom(self, ctx: BizHawkClientContext) -> bool:
+ """Should return whether the currently loaded ROM should be handled by this client. You might read the game name
+ from the ROM header, for example. This function will only be asked to validate ROMs from the system set by the
+ client class, so you do not need to check the system yourself.
+
+ Once this function has determined that the ROM should be handled by this client, it should also modify `ctx`
+ as necessary (such as setting `ctx.game = self.game`, modifying `ctx.items_handling`, etc...)."""
+ ...
+
+ async def set_auth(self, ctx: BizHawkClientContext) -> None:
+ """Should set ctx.auth in anticipation of sending a `Connected` packet. You may override this if you store slot
+ name in your patched ROM. If ctx.auth is not set after calling, the player will be prompted to enter their
+ username."""
+ pass
+
+ @abc.abstractmethod
+ async def game_watcher(self, ctx: BizHawkClientContext) -> None:
+ """Runs on a loop with the approximate interval `ctx.watcher_timeout`. The currently loaded ROM is guaranteed
+ to have passed your validator when this function is called, and the emulator is very likely to be connected."""
+ ...
+
+ def on_package(self, ctx: BizHawkClientContext, cmd: str, args: dict) -> None:
+ """For handling packages from the server. Called from `BizHawkClientContext.on_package`."""
+ pass
+
+
+def launch_client(*args) -> None:
+ from .context import launch
+ launch_subprocess(launch, name="BizHawkClient")
+
+
+if not any(component.script_name == "BizHawkClient" for component in components):
+ components.append(Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
+ file_identifier=SuffixIdentifier()))
diff --git a/worlds/_bizhawk/context.py b/worlds/_bizhawk/context.py
new file mode 100644
index 000000000000..6e53b370af1c
--- /dev/null
+++ b/worlds/_bizhawk/context.py
@@ -0,0 +1,188 @@
+"""
+A module containing context and functions relevant to running the client. This module should only be imported for type
+checking or launching the client, otherwise it will probably cause circular import issues.
+"""
+
+
+import asyncio
+import traceback
+from typing import Any, Dict, Optional
+
+from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled
+import Patch
+import Utils
+
+from . import BizHawkContext, ConnectionStatus, RequestFailedError, connect, disconnect, get_hash, get_script_version, \
+ get_system, ping
+from .client import BizHawkClient, AutoBizHawkClientRegister
+
+
+EXPECTED_SCRIPT_VERSION = 1
+
+
+class BizHawkClientCommandProcessor(ClientCommandProcessor):
+ def _cmd_bh(self):
+ """Shows the current status of the client's connection to BizHawk"""
+ if isinstance(self.ctx, BizHawkClientContext):
+ if self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED:
+ logger.info("BizHawk Connection Status: Not Connected")
+ elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.TENTATIVE:
+ logger.info("BizHawk Connection Status: Tentatively Connected")
+ elif self.ctx.bizhawk_ctx.connection_status == ConnectionStatus.CONNECTED:
+ logger.info("BizHawk Connection Status: Connected")
+
+
+class BizHawkClientContext(CommonContext):
+ command_processor = BizHawkClientCommandProcessor
+ client_handler: Optional[BizHawkClient]
+ slot_data: Optional[Dict[str, Any]] = None
+ rom_hash: Optional[str] = None
+ bizhawk_ctx: BizHawkContext
+
+ watcher_timeout: float
+ """The maximum amount of time the game watcher loop will wait for an update from the server before executing"""
+
+ def __init__(self, server_address: Optional[str], password: Optional[str]):
+ super().__init__(server_address, password)
+ self.client_handler = None
+ self.bizhawk_ctx = BizHawkContext()
+ self.watcher_timeout = 0.5
+
+ def run_gui(self):
+ from kvui import GameManager
+
+ class BizHawkManager(GameManager):
+ base_title = "Archipelago BizHawk Client"
+
+ self.ui = BizHawkManager(self)
+ self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
+
+ def on_package(self, cmd, args):
+ if cmd == "Connected":
+ self.slot_data = args.get("slot_data", None)
+
+ if self.client_handler is not None:
+ self.client_handler.on_package(self, cmd, args)
+
+
+async def _game_watcher(ctx: BizHawkClientContext):
+ showed_connecting_message = False
+ showed_connected_message = False
+ showed_no_handler_message = False
+
+ while not ctx.exit_event.is_set():
+ try:
+ await asyncio.wait_for(ctx.watcher_event.wait(), ctx.watcher_timeout)
+ except asyncio.TimeoutError:
+ pass
+
+ ctx.watcher_event.clear()
+
+ try:
+ if ctx.bizhawk_ctx.connection_status == ConnectionStatus.NOT_CONNECTED:
+ showed_connected_message = False
+
+ if not showed_connecting_message:
+ logger.info("Waiting to connect to BizHawk...")
+ showed_connecting_message = True
+
+ if not await connect(ctx.bizhawk_ctx):
+ continue
+
+ showed_no_handler_message = False
+
+ script_version = await get_script_version(ctx.bizhawk_ctx)
+
+ if script_version != EXPECTED_SCRIPT_VERSION:
+ logger.info(f"Connector script is incompatible. Expected version {EXPECTED_SCRIPT_VERSION} but got {script_version}. Disconnecting.")
+ disconnect(ctx.bizhawk_ctx)
+ continue
+
+ showed_connecting_message = False
+
+ await ping(ctx.bizhawk_ctx)
+
+ if not showed_connected_message:
+ showed_connected_message = True
+ logger.info("Connected to BizHawk")
+
+ rom_hash = await get_hash(ctx.bizhawk_ctx)
+ if ctx.rom_hash is not None and ctx.rom_hash != rom_hash:
+ if ctx.server is not None:
+ logger.info(f"ROM changed. Disconnecting from server.")
+ await ctx.disconnect(True)
+
+ ctx.auth = None
+ ctx.username = None
+ ctx.rom_hash = rom_hash
+
+ if ctx.client_handler is None:
+ system = await get_system(ctx.bizhawk_ctx)
+ ctx.client_handler = await AutoBizHawkClientRegister.get_handler(ctx, system)
+
+ if ctx.client_handler is None:
+ if not showed_no_handler_message:
+ logger.info("No handler was found for this game")
+ showed_no_handler_message = True
+ continue
+ else:
+ showed_no_handler_message = False
+ logger.info(f"Running handler for {ctx.client_handler.game}")
+
+ except RequestFailedError as exc:
+ logger.info(f"Lost connection to BizHawk: {exc.args[0]}")
+ continue
+
+ # Get slot name and send `Connect`
+ if ctx.server is not None and ctx.username is None:
+ await ctx.client_handler.set_auth(ctx)
+
+ if ctx.auth is None:
+ await ctx.get_username()
+
+ await ctx.send_connect()
+
+ await ctx.client_handler.game_watcher(ctx)
+
+
+async def _run_game(rom: str):
+ import webbrowser
+ webbrowser.open(rom)
+
+
+async def _patch_and_run_game(patch_file: str):
+ metadata, output_file = Patch.create_rom_file(patch_file)
+ Utils.async_start(_run_game(output_file))
+
+
+def launch() -> None:
+ async def main():
+ parser = get_base_parser()
+ parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file")
+ args = parser.parse_args()
+
+ ctx = BizHawkClientContext(args.connect, args.password)
+ ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
+
+ if gui_enabled:
+ ctx.run_gui()
+ ctx.run_cli()
+
+ if args.patch_file != "":
+ Utils.async_start(_patch_and_run_game(args.patch_file))
+
+ watcher_task = asyncio.create_task(_game_watcher(ctx), name="GameWatcher")
+
+ try:
+ await watcher_task
+ except Exception as e:
+ logger.error("".join(traceback.format_exception(e)))
+
+ await ctx.exit_event.wait()
+ await ctx.shutdown()
+
+ Utils.init_logging("BizHawkClient", exception_logger="Client")
+ import colorama
+ colorama.init()
+ asyncio.run(main())
+ colorama.deinit()
diff --git a/worlds/alttp/test/__init__.py b/worlds/alttp/test/__init__.py
index e69de29bb2d1..5baaa7e88e61 100644
--- a/worlds/alttp/test/__init__.py
+++ b/worlds/alttp/test/__init__.py
@@ -0,0 +1,16 @@
+import unittest
+from argparse import Namespace
+
+from BaseClasses import MultiWorld, CollectionState
+from worlds import AutoWorldRegister
+
+
+class LTTPTestBase(unittest.TestCase):
+ def world_setup(self):
+ self.multiworld = MultiWorld(1)
+ self.multiworld.state = CollectionState(self.multiworld)
+ self.multiworld.set_seed(None)
+ args = Namespace()
+ for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items():
+ setattr(args, name, {1: option.from_any(getattr(option, "default"))})
+ self.multiworld.set_options(args)
diff --git a/worlds/alttp/test/dungeons/TestDungeon.py b/worlds/alttp/test/dungeons/TestDungeon.py
index 81085ab10a16..94c30c349398 100644
--- a/worlds/alttp/test/dungeons/TestDungeon.py
+++ b/worlds/alttp/test/dungeons/TestDungeon.py
@@ -1,25 +1,16 @@
-import unittest
-from argparse import Namespace
-
-from BaseClasses import MultiWorld, CollectionState, ItemClassification
-from worlds.alttp.Dungeons import get_dungeon_item_pool
+from BaseClasses import CollectionState, ItemClassification
+from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import mandatory_connections, connect_simple
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Regions import create_regions
from worlds.alttp.Shops import create_shops
-from worlds import AutoWorld
+from worlds.alttp.test import LTTPTestBase
-class TestDungeon(unittest.TestCase):
+class TestDungeon(LTTPTestBase):
def setUp(self):
- self.multiworld = MultiWorld(1)
- self.multiworld.set_seed(None)
- args = Namespace()
- for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
- setattr(args, name, {1: option.from_any(option.default)})
- self.multiworld.set_options(args)
- self.multiworld.set_default_common_options()
+ self.world_setup()
self.starting_regions = [] # Where to start exploring
self.remove_exits = [] # Block dungeon exits
self.multiworld.difficulty_requirements[1] = difficulties['normal']
diff --git a/worlds/alttp/test/inverted/TestInverted.py b/worlds/alttp/test/inverted/TestInverted.py
index ad7458202ead..f5608ba07b2d 100644
--- a/worlds/alttp/test/inverted/TestInverted.py
+++ b/worlds/alttp/test/inverted/TestInverted.py
@@ -1,6 +1,3 @@
-from argparse import Namespace
-
-from BaseClasses import MultiWorld
from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import link_inverted_entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
@@ -10,17 +7,12 @@
from worlds.alttp.Shops import create_shops
from test.TestBase import TestBase
-from worlds import AutoWorld
+from worlds.alttp.test import LTTPTestBase
+
-class TestInverted(TestBase):
+class TestInverted(TestBase, LTTPTestBase):
def setUp(self):
- self.multiworld = MultiWorld(1)
- self.multiworld.set_seed(None)
- args = Namespace()
- for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
- setattr(args, name, {1: option.from_any(option.default)})
- self.multiworld.set_options(args)
- self.multiworld.set_default_common_options()
+ self.world_setup()
self.multiworld.difficulty_requirements[1] = difficulties['normal']
self.multiworld.mode[1] = "inverted"
create_inverted_regions(self.multiworld, 1)
diff --git a/worlds/alttp/test/inverted/TestInvertedBombRules.py b/worlds/alttp/test/inverted/TestInvertedBombRules.py
index 89c5d7860323..d9eacb5ad98b 100644
--- a/worlds/alttp/test/inverted/TestInvertedBombRules.py
+++ b/worlds/alttp/test/inverted/TestInvertedBombRules.py
@@ -1,27 +1,17 @@
-import unittest
-from argparse import Namespace
-
-from BaseClasses import MultiWorld
from worlds.alttp.Dungeons import create_dungeons
from worlds.alttp.EntranceShuffle import connect_entrance, Inverted_LW_Entrances, Inverted_LW_Dungeon_Entrances, Inverted_LW_Single_Cave_Doors, Inverted_Old_Man_Entrances, Inverted_DW_Entrances, Inverted_DW_Dungeon_Entrances, Inverted_DW_Single_Cave_Doors, \
Inverted_LW_Entrances_Must_Exit, Inverted_LW_Dungeon_Entrances_Must_Exit, Inverted_Bomb_Shop_Multi_Cave_Doors, Inverted_Bomb_Shop_Single_Cave_Doors, Blacksmith_Single_Cave_Doors, Inverted_Blacksmith_Multi_Cave_Doors
from worlds.alttp.InvertedRegions import create_inverted_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Rules import set_inverted_big_bomb_rules
-from worlds import AutoWorld
+from worlds.alttp.test import LTTPTestBase
-class TestInvertedBombRules(unittest.TestCase):
+class TestInvertedBombRules(LTTPTestBase):
def setUp(self):
- self.multiworld = MultiWorld(1)
- self.multiworld.set_seed(None)
+ self.world_setup()
self.multiworld.mode[1] = "inverted"
- args = Namespace
- for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
- setattr(args, name, {1: option.from_any(option.default)})
- self.multiworld.set_options(args)
- self.multiworld.set_default_common_options()
self.multiworld.difficulty_requirements[1] = difficulties['normal']
create_inverted_regions(self.multiworld, 1)
self.multiworld.worlds[1].create_dungeons()
diff --git a/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py b/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py
index 72049e17742c..33e582298185 100644
--- a/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py
+++ b/worlds/alttp/test/inverted_minor_glitches/TestInvertedMinor.py
@@ -1,27 +1,18 @@
-from argparse import Namespace
-
-from BaseClasses import MultiWorld
from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import link_inverted_entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
-from worlds.alttp.ItemPool import generate_itempool, difficulties
+from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Regions import mark_light_world_regions
from worlds.alttp.Shops import create_shops
-from worlds.alttp.Rules import set_rules
from test.TestBase import TestBase
-from worlds import AutoWorld
+from worlds.alttp.test import LTTPTestBase
+
-class TestInvertedMinor(TestBase):
+class TestInvertedMinor(TestBase, LTTPTestBase):
def setUp(self):
- self.multiworld = MultiWorld(1)
- self.multiworld.set_seed(None)
- args = Namespace()
- for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
- setattr(args, name, {1: option.from_any(option.default)})
- self.multiworld.set_options(args)
- self.multiworld.set_default_common_options()
+ self.world_setup()
self.multiworld.mode[1] = "inverted"
self.multiworld.logic[1] = "minorglitches"
self.multiworld.difficulty_requirements[1] = difficulties['normal']
diff --git a/worlds/alttp/test/inverted_owg/TestInvertedOWG.py b/worlds/alttp/test/inverted_owg/TestInvertedOWG.py
index 77a551db6f87..a4e84fce9b62 100644
--- a/worlds/alttp/test/inverted_owg/TestInvertedOWG.py
+++ b/worlds/alttp/test/inverted_owg/TestInvertedOWG.py
@@ -1,28 +1,18 @@
-from argparse import Namespace
-
-from BaseClasses import MultiWorld
from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool
from worlds.alttp.EntranceShuffle import link_inverted_entrances
from worlds.alttp.InvertedRegions import create_inverted_regions
-from worlds.alttp.ItemPool import generate_itempool, difficulties
+from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Regions import mark_light_world_regions
from worlds.alttp.Shops import create_shops
-from worlds.alttp.Rules import set_rules
from test.TestBase import TestBase
-from worlds import AutoWorld
+from worlds.alttp.test import LTTPTestBase
-class TestInvertedOWG(TestBase):
+class TestInvertedOWG(TestBase, LTTPTestBase):
def setUp(self):
- self.multiworld = MultiWorld(1)
- self.multiworld.set_seed(None)
- args = Namespace()
- for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
- setattr(args, name, {1: option.from_any(option.default)})
- self.multiworld.set_options(args)
- self.multiworld.set_default_common_options()
+ self.world_setup()
self.multiworld.logic[1] = "owglitches"
self.multiworld.mode[1] = "inverted"
self.multiworld.difficulty_requirements[1] = difficulties['normal']
diff --git a/worlds/alttp/test/minor_glitches/TestMinor.py b/worlds/alttp/test/minor_glitches/TestMinor.py
index fdf626fe9d37..d5cfd3095b9c 100644
--- a/worlds/alttp/test/minor_glitches/TestMinor.py
+++ b/worlds/alttp/test/minor_glitches/TestMinor.py
@@ -1,27 +1,15 @@
-from argparse import Namespace
-
-from BaseClasses import MultiWorld
-from worlds.alttp.Dungeons import create_dungeons, get_dungeon_item_pool
-from worlds.alttp.EntranceShuffle import link_entrances
+from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.InvertedRegions import mark_dark_world_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
-from worlds.alttp.Regions import create_regions
-from worlds.alttp.Shops import create_shops
from test.TestBase import TestBase
-from worlds import AutoWorld
+from worlds.alttp.test import LTTPTestBase
-class TestMinor(TestBase):
+class TestMinor(TestBase, LTTPTestBase):
def setUp(self):
- self.multiworld = MultiWorld(1)
- self.multiworld.set_seed(None)
- args = Namespace()
- for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
- setattr(args, name, {1: option.from_any(option.default)})
- self.multiworld.set_options(args)
- self.multiworld.set_default_common_options()
+ self.world_setup()
self.multiworld.logic[1] = "minorglitches"
self.multiworld.difficulty_requirements[1] = difficulties['normal']
self.multiworld.worlds[1].er_seed = 0
diff --git a/worlds/alttp/test/owg/TestVanillaOWG.py b/worlds/alttp/test/owg/TestVanillaOWG.py
index c0888aa32fe6..37b0b6ccb868 100644
--- a/worlds/alttp/test/owg/TestVanillaOWG.py
+++ b/worlds/alttp/test/owg/TestVanillaOWG.py
@@ -1,24 +1,15 @@
-from argparse import Namespace
-
-from BaseClasses import MultiWorld
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.InvertedRegions import mark_dark_world_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
from test.TestBase import TestBase
-from worlds import AutoWorld
+from worlds.alttp.test import LTTPTestBase
-class TestVanillaOWG(TestBase):
+class TestVanillaOWG(TestBase, LTTPTestBase):
def setUp(self):
- self.multiworld = MultiWorld(1)
- self.multiworld.set_seed(None)
- args = Namespace()
- for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
- setattr(args, name, {1: option.from_any(option.default)})
- self.multiworld.set_options(args)
- self.multiworld.set_default_common_options()
+ self.world_setup()
self.multiworld.difficulty_requirements[1] = difficulties['normal']
self.multiworld.logic[1] = "owglitches"
self.multiworld.worlds[1].er_seed = 0
diff --git a/worlds/alttp/test/vanilla/TestVanilla.py b/worlds/alttp/test/vanilla/TestVanilla.py
index e338410df208..3c983e98504c 100644
--- a/worlds/alttp/test/vanilla/TestVanilla.py
+++ b/worlds/alttp/test/vanilla/TestVanilla.py
@@ -1,22 +1,14 @@
-from argparse import Namespace
-
-from BaseClasses import MultiWorld
from worlds.alttp.Dungeons import get_dungeon_item_pool
from worlds.alttp.InvertedRegions import mark_dark_world_regions
from worlds.alttp.ItemPool import difficulties
from worlds.alttp.Items import ItemFactory
from test.TestBase import TestBase
-from worlds import AutoWorld
+from worlds.alttp.test import LTTPTestBase
+
-class TestVanilla(TestBase):
+class TestVanilla(TestBase, LTTPTestBase):
def setUp(self):
- self.multiworld = MultiWorld(1)
- self.multiworld.set_seed(None)
- args = Namespace()
- for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
- setattr(args, name, {1: option.from_any(option.default)})
- self.multiworld.set_options(args)
- self.multiworld.set_default_common_options()
+ self.world_setup()
self.multiworld.logic[1] = "noglitches"
self.multiworld.difficulty_requirements[1] = difficulties['normal']
self.multiworld.worlds[1].er_seed = 0
diff --git a/worlds/checksfinder/__init__.py b/worlds/checksfinder/__init__.py
index 9ca16ca0d2ae..feff1486514a 100644
--- a/worlds/checksfinder/__init__.py
+++ b/worlds/checksfinder/__init__.py
@@ -45,7 +45,7 @@ def _get_checksfinder_data(self):
'race': self.multiworld.is_race,
}
- def generate_basic(self):
+ def create_items(self):
# Generate item pool
itempool = []
diff --git a/worlds/dlcquest/Items.py b/worlds/dlcquest/Items.py
index 61f4cd30fabf..61d1be54cbd4 100644
--- a/worlds/dlcquest/Items.py
+++ b/worlds/dlcquest/Items.py
@@ -1,11 +1,12 @@
import csv
import enum
import math
-from typing import Protocol, Union, Dict, List, Set
-from BaseClasses import Item, ItemClassification
-from . import Options, data
from dataclasses import dataclass, field
from random import Random
+from typing import Dict, List, Set
+
+from BaseClasses import Item, ItemClassification
+from . import Options, data
class DLCQuestItem(Item):
@@ -93,38 +94,35 @@ def create_trap_items(world, World_Options: Options.DLCQuestOptions, trap_needed
def create_items(world, World_Options: Options.DLCQuestOptions, locations_count: int, random: Random):
created_items = []
- if World_Options[Options.Campaign] == Options.Campaign.option_basic or World_Options[
- Options.Campaign] == Options.Campaign.option_both:
+ if World_Options.campaign == Options.Campaign.option_basic or World_Options.campaign == Options.Campaign.option_both:
for item in items_by_group[Group.DLCQuest]:
if item.has_any_group(Group.DLC):
created_items.append(world.create_item(item))
- if item.has_any_group(Group.Item) and World_Options[
- Options.ItemShuffle] == Options.ItemShuffle.option_shuffled:
+ if item.has_any_group(Group.Item) and World_Options.item_shuffle == Options.ItemShuffle.option_shuffled:
created_items.append(world.create_item(item))
- if World_Options[Options.CoinSanity] == Options.CoinSanity.option_coin:
- coin_bundle_needed = math.floor(825 / World_Options[Options.CoinSanityRange])
+ if World_Options.coinsanity == Options.CoinSanity.option_coin:
+ coin_bundle_needed = math.floor(825 / World_Options.coinbundlequantity)
for item in items_by_group[Group.DLCQuest]:
if item.has_any_group(Group.Coin):
for i in range(coin_bundle_needed):
created_items.append(world.create_item(item))
- if 825 % World_Options[Options.CoinSanityRange] != 0:
+ if 825 % World_Options.coinbundlequantity != 0:
created_items.append(world.create_item(item))
- if World_Options[Options.Campaign] == Options.Campaign.option_live_freemium_or_die or World_Options[
- Options.Campaign] == Options.Campaign.option_both:
+ if (World_Options.campaign == Options.Campaign.option_live_freemium_or_die or
+ World_Options.campaign == Options.Campaign.option_both):
for item in items_by_group[Group.Freemium]:
if item.has_any_group(Group.DLC):
created_items.append(world.create_item(item))
- if item.has_any_group(Group.Item) and World_Options[
- Options.ItemShuffle] == Options.ItemShuffle.option_shuffled:
+ if item.has_any_group(Group.Item) and World_Options.item_shuffle == Options.ItemShuffle.option_shuffled:
created_items.append(world.create_item(item))
- if World_Options[Options.CoinSanity] == Options.CoinSanity.option_coin:
- coin_bundle_needed = math.floor(889 / World_Options[Options.CoinSanityRange])
+ if World_Options.coinsanity == Options.CoinSanity.option_coin:
+ coin_bundle_needed = math.floor(889 / World_Options.coinbundlequantity)
for item in items_by_group[Group.Freemium]:
if item.has_any_group(Group.Coin):
for i in range(coin_bundle_needed):
created_items.append(world.create_item(item))
- if 889 % World_Options[Options.CoinSanityRange] != 0:
+ if 889 % World_Options.coinbundlequantity != 0:
created_items.append(world.create_item(item))
trap_items = create_trap_items(world, World_Options, locations_count - len(created_items), random)
diff --git a/worlds/dlcquest/Locations.py b/worlds/dlcquest/Locations.py
index 08d37e781216..a9fdd00a202c 100644
--- a/worlds/dlcquest/Locations.py
+++ b/worlds/dlcquest/Locations.py
@@ -1,5 +1,4 @@
-from BaseClasses import Location, MultiWorld
-from . import Options
+from BaseClasses import Location
class DLCQuestLocation(Location):
diff --git a/worlds/dlcquest/Options.py b/worlds/dlcquest/Options.py
index a1674a4d5a8b..ce728b4e9244 100644
--- a/worlds/dlcquest/Options.py
+++ b/worlds/dlcquest/Options.py
@@ -1,22 +1,6 @@
-from typing import Union, Dict, runtime_checkable, Protocol
-from Options import Option, DeathLink, Choice, Toggle, SpecialRange
from dataclasses import dataclass
-
-@runtime_checkable
-class DLCQuestOption(Protocol):
- internal_name: str
-
-
-@dataclass
-class DLCQuestOptions:
- options: Dict[str, Union[bool, int]]
-
- def __getitem__(self, item: Union[str, DLCQuestOption]) -> Union[bool, int]:
- if isinstance(item, DLCQuestOption):
- item = item.internal_name
-
- return self.options.get(item, None)
+from Options import Choice, DeathLink, PerGameCommonOptions, SpecialRange
class DoubleJumpGlitch(Choice):
@@ -94,31 +78,13 @@ class ItemShuffle(Choice):
default = 0
-DLCQuest_options: Dict[str, type(Option)] = {
- option.internal_name: option
- for option in [
- DoubleJumpGlitch,
- CoinSanity,
- CoinSanityRange,
- TimeIsMoney,
- EndingChoice,
- Campaign,
- ItemShuffle,
- ]
-}
-default_options = {option.internal_name: option.default for option in DLCQuest_options.values()}
-DLCQuest_options["death_link"] = DeathLink
-
-
-def fetch_options(world, player: int) -> DLCQuestOptions:
- return DLCQuestOptions({option: get_option_value(world, player, option) for option in DLCQuest_options})
-
-
-def get_option_value(world, player: int, name: str) -> Union[bool, int]:
- assert name in DLCQuest_options, f"{name} is not a valid option for DLC Quest."
-
- value = getattr(world, name)
-
- if issubclass(DLCQuest_options[name], Toggle):
- return bool(value[player].value)
- return value[player].value
+@dataclass
+class DLCQuestOptions(PerGameCommonOptions):
+ double_jump_glitch: DoubleJumpGlitch
+ coinsanity: CoinSanity
+ coinbundlequantity: CoinSanityRange
+ time_is_money: TimeIsMoney
+ ending_choice: EndingChoice
+ campaign: Campaign
+ item_shuffle: ItemShuffle
+ death_link: DeathLink
diff --git a/worlds/dlcquest/Regions.py b/worlds/dlcquest/Regions.py
index 8135a1c362c5..dfb5f6c021be 100644
--- a/worlds/dlcquest/Regions.py
+++ b/worlds/dlcquest/Regions.py
@@ -1,8 +1,9 @@
import math
-from BaseClasses import MultiWorld, Region, Location, Entrance, ItemClassification
+
+from BaseClasses import Entrance, MultiWorld, Region
+from . import Options
from .Locations import DLCQuestLocation, location_table
from .Rules import create_event
-from . import Options
DLCQuestRegion = ["Movement Pack", "Behind Tree", "Psychological Warfare", "Double Jump Left",
"Double Jump Behind the Tree", "The Forest", "Final Room"]
@@ -26,16 +27,16 @@ def add_coin_dlcquest(region: Region, Coin: int, player: int):
def create_regions(world: MultiWorld, player: int, World_Options: Options.DLCQuestOptions):
Regmenu = Region("Menu", player, world)
- if World_Options[Options.Campaign] == Options.Campaign.option_basic or World_Options[
- Options.Campaign] == Options.Campaign.option_both:
+ if (World_Options.campaign == Options.Campaign.option_basic or World_Options.campaign
+ == Options.Campaign.option_both):
Regmenu.exits += [Entrance(player, "DLC Quest Basic", Regmenu)]
- if World_Options[Options.Campaign] == Options.Campaign.option_live_freemium_or_die or World_Options[
- Options.Campaign] == Options.Campaign.option_both:
+ if (World_Options.campaign == Options.Campaign.option_live_freemium_or_die or World_Options.campaign
+ == Options.Campaign.option_both):
Regmenu.exits += [Entrance(player, "Live Freemium or Die", Regmenu)]
world.regions.append(Regmenu)
- if World_Options[Options.Campaign] == Options.Campaign.option_basic or World_Options[
- Options.Campaign] == Options.Campaign.option_both:
+ if (World_Options.campaign == Options.Campaign.option_basic or World_Options.campaign
+ == Options.Campaign.option_both):
Regmoveright = Region("Move Right", player, world, "Start of the basic game")
Locmoveright_name = ["Movement Pack", "Animation Pack", "Audio Pack", "Pause Menu Pack"]
@@ -43,13 +44,13 @@ def create_regions(world: MultiWorld, player: int, World_Options: Options.DLCQue
Regmoveright.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regmoveright) for
loc_name in Locmoveright_name]
add_coin_dlcquest(Regmoveright, 4, player)
- if World_Options[Options.CoinSanity] == Options.CoinSanity.option_coin:
- coin_bundle_needed = math.floor(825 / World_Options[Options.CoinSanityRange])
+ if World_Options.coinsanity == Options.CoinSanity.option_coin:
+ coin_bundle_needed = math.floor(825 / World_Options.coinbundlequantity)
for i in range(coin_bundle_needed):
- item_coin = f"DLC Quest: {World_Options[Options.CoinSanityRange] * (i + 1)} Coin"
+ item_coin = f"DLC Quest: {World_Options.coinbundlequantity * (i + 1)} Coin"
Regmoveright.locations += [
DLCQuestLocation(player, item_coin, location_table[item_coin], Regmoveright)]
- if 825 % World_Options[Options.CoinSanityRange] != 0:
+ if 825 % World_Options.coinbundlequantity != 0:
Regmoveright.locations += [
DLCQuestLocation(player, "DLC Quest: 825 Coin", location_table["DLC Quest: 825 Coin"],
Regmoveright)]
@@ -58,7 +59,7 @@ def create_regions(world: MultiWorld, player: int, World_Options: Options.DLCQue
Regmovpack = Region("Movement Pack", player, world)
Locmovpack_name = ["Time is Money Pack", "Psychological Warfare Pack", "Armor for your Horse Pack",
"Shepherd Sheep"]
- if World_Options[Options.ItemShuffle] == Options.ItemShuffle.option_shuffled:
+ if World_Options.item_shuffle == Options.ItemShuffle.option_shuffled:
Locmovpack_name += ["Sword"]
Regmovpack.exits = [Entrance(player, "Tree", Regmovpack), Entrance(player, "Cloud", Regmovpack)]
Regmovpack.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regmovpack) for loc_name
@@ -68,7 +69,7 @@ def create_regions(world: MultiWorld, player: int, World_Options: Options.DLCQue
Regbtree = Region("Behind Tree", player, world)
Locbtree_name = ["Double Jump Pack", "Map Pack", "Between Trees Sheep", "Hole in the Wall Sheep"]
- if World_Options[Options.ItemShuffle] == Options.ItemShuffle.option_shuffled:
+ if World_Options.item_shuffle == Options.ItemShuffle.option_shuffled:
Locbtree_name += ["Gun"]
Regbtree.exits = [Entrance(player, "Behind Tree Double Jump", Regbtree),
Entrance(player, "Forest Entrance", Regbtree)]
@@ -191,27 +192,27 @@ def create_regions(world: MultiWorld, player: int, World_Options: Options.DLCQue
world.get_entrance("True Double Jump", player).connect(world.get_region("True Double Jump Behind Tree", player))
- if World_Options[Options.Campaign] == Options.Campaign.option_live_freemium_or_die or World_Options[
- Options.Campaign] == Options.Campaign.option_both:
+ if (World_Options.campaign == Options.Campaign.option_live_freemium_or_die or World_Options.campaign
+ == Options.Campaign.option_both):
Regfreemiumstart = Region("Freemium Start", player, world)
Locfreemiumstart_name = ["Particles Pack", "Day One Patch Pack", "Checkpoint Pack", "Incredibly Important Pack",
"Nice Try", "Story is Important", "I Get That Reference!"]
- if World_Options[Options.ItemShuffle] == Options.ItemShuffle.option_shuffled:
+ if World_Options.item_shuffle == Options.ItemShuffle.option_shuffled:
Locfreemiumstart_name += ["Wooden Sword"]
Regfreemiumstart.exits = [Entrance(player, "Vines", Regfreemiumstart)]
Regfreemiumstart.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regfreemiumstart)
for loc_name in
Locfreemiumstart_name]
add_coin_freemium(Regfreemiumstart, 50, player)
- if World_Options[Options.CoinSanity] == Options.CoinSanity.option_coin:
- coin_bundle_needed = math.floor(889 / World_Options[Options.CoinSanityRange])
+ if World_Options.coinsanity == Options.CoinSanity.option_coin:
+ coin_bundle_needed = math.floor(889 / World_Options.coinbundlequantity)
for i in range(coin_bundle_needed):
- item_coin_freemium = f"Live Freemium or Die: {World_Options[Options.CoinSanityRange] * (i + 1)} Coin"
+ item_coin_freemium = f"Live Freemium or Die: {World_Options.coinbundlequantity * (i + 1)} Coin"
Regfreemiumstart.locations += [
DLCQuestLocation(player, item_coin_freemium, location_table[item_coin_freemium],
Regfreemiumstart)]
- if 889 % World_Options[Options.CoinSanityRange] != 0:
+ if 889 % World_Options.coinbundlequantity != 0:
Regfreemiumstart.locations += [
DLCQuestLocation(player, "Live Freemium or Die: 889 Coin",
location_table["Live Freemium or Die: 889 Coin"],
@@ -220,7 +221,7 @@ def create_regions(world: MultiWorld, player: int, World_Options: Options.DLCQue
Regbehindvine = Region("Behind the Vines", player, world)
Locbehindvine_name = ["Wall Jump Pack", "Health Bar Pack", "Parallax Pack"]
- if World_Options[Options.ItemShuffle] == Options.ItemShuffle.option_shuffled:
+ if World_Options.item_shuffle == Options.ItemShuffle.option_shuffled:
Locbehindvine_name += ["Pickaxe"]
Regbehindvine.exits = [Entrance(player, "Wall Jump Entrance", Regbehindvine)]
Regbehindvine.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regbehindvine) for
@@ -260,7 +261,7 @@ def create_regions(world: MultiWorld, player: int, World_Options: Options.DLCQue
Regcutcontent = Region("Cut Content", player, world)
Loccutcontent_name = []
- if World_Options[Options.ItemShuffle] == Options.ItemShuffle.option_shuffled:
+ if World_Options.item_shuffle == Options.ItemShuffle.option_shuffled:
Loccutcontent_name += ["Humble Indie Bindle"]
Regcutcontent.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regcutcontent) for
loc_name in Loccutcontent_name]
@@ -269,7 +270,7 @@ def create_regions(world: MultiWorld, player: int, World_Options: Options.DLCQue
Regnamechange = Region("Name Change", player, world)
Locnamechange_name = []
- if World_Options[Options.ItemShuffle] == Options.ItemShuffle.option_shuffled:
+ if World_Options.item_shuffle == Options.ItemShuffle.option_shuffled:
Locnamechange_name += ["Box of Various Supplies"]
Regnamechange.exits = [Entrance(player, "Behind Rocks", Regnamechange)]
Regnamechange.locations += [DLCQuestLocation(player, loc_name, location_table[loc_name], Regnamechange) for
diff --git a/worlds/dlcquest/Rules.py b/worlds/dlcquest/Rules.py
index d2495121f4e8..c5fdfe8282c4 100644
--- a/worlds/dlcquest/Rules.py
+++ b/worlds/dlcquest/Rules.py
@@ -1,10 +1,10 @@
import math
import re
-from .Locations import DLCQuestLocation
-from ..generic.Rules import add_rule, set_rule, item_name_in_locations
-from .Items import DLCQuestItem
+
from BaseClasses import ItemClassification
+from worlds.generic.Rules import add_rule, item_name_in_locations, set_rule
from . import Options
+from .Items import DLCQuestItem
def create_event(player, event: str):
@@ -42,7 +42,7 @@ def has_coin(state, player: int, coins: int):
def set_basic_rules(World_Options, has_enough_coin, player, world):
- if World_Options[Options.Campaign] == Options.Campaign.option_live_freemium_or_die:
+ if World_Options.campaign == Options.Campaign.option_live_freemium_or_die:
return
set_basic_entrance_rules(player, world)
set_basic_self_obtained_items_rules(World_Options, player, world)
@@ -66,12 +66,12 @@ def set_basic_entrance_rules(player, world):
def set_basic_self_obtained_items_rules(World_Options, player, world):
- if World_Options[Options.ItemShuffle] != Options.ItemShuffle.option_disabled:
+ if World_Options.item_shuffle != Options.ItemShuffle.option_disabled:
return
set_rule(world.get_entrance("Behind Ogre", player),
lambda state: state.has("Gun Pack", player))
- if World_Options[Options.TimeIsMoney] == Options.TimeIsMoney.option_required:
+ if World_Options.time_is_money == Options.TimeIsMoney.option_required:
set_rule(world.get_entrance("Tree", player),
lambda state: state.has("Time is Money Pack", player))
set_rule(world.get_entrance("Cave Tree", player),
@@ -87,7 +87,7 @@ def set_basic_self_obtained_items_rules(World_Options, player, world):
def set_basic_shuffled_items_rules(World_Options, player, world):
- if World_Options[Options.ItemShuffle] != Options.ItemShuffle.option_shuffled:
+ if World_Options.item_shuffle != Options.ItemShuffle.option_shuffled:
return
set_rule(world.get_entrance("Behind Ogre", player),
lambda state: state.has("Gun", player))
@@ -108,13 +108,13 @@ def set_basic_shuffled_items_rules(World_Options, player, world):
set_rule(world.get_location("Gun", player),
lambda state: state.has("Gun Pack", player))
- if World_Options[Options.TimeIsMoney] == Options.TimeIsMoney.option_required:
+ if World_Options.time_is_money == Options.TimeIsMoney.option_required:
set_rule(world.get_location("Sword", player),
lambda state: state.has("Time is Money Pack", player))
def set_double_jump_glitchless_rules(World_Options, player, world):
- if World_Options[Options.DoubleJumpGlitch] != Options.DoubleJumpGlitch.option_none:
+ if World_Options.double_jump_glitch != Options.DoubleJumpGlitch.option_none:
return
set_rule(world.get_entrance("Cloud Double Jump", player),
lambda state: state.has("Double Jump Pack", player))
@@ -123,7 +123,7 @@ def set_double_jump_glitchless_rules(World_Options, player, world):
def set_easy_double_jump_glitch_rules(World_Options, player, world):
- if World_Options[Options.DoubleJumpGlitch] == Options.DoubleJumpGlitch.option_all:
+ if World_Options.double_jump_glitch == Options.DoubleJumpGlitch.option_all:
return
set_rule(world.get_entrance("Behind Tree Double Jump", player),
lambda state: state.has("Double Jump Pack", player))
@@ -132,70 +132,70 @@ def set_easy_double_jump_glitch_rules(World_Options, player, world):
def self_basic_coinsanity_funded_purchase_rules(World_Options, has_enough_coin, player, world):
- if World_Options[Options.CoinSanity] != Options.CoinSanity.option_coin:
+ if World_Options.coinsanity != Options.CoinSanity.option_coin:
return
- number_of_bundle = math.floor(825 / World_Options[Options.CoinSanityRange])
+ number_of_bundle = math.floor(825 / World_Options.coinbundlequantity)
for i in range(number_of_bundle):
- item_coin = f"DLC Quest: {World_Options[Options.CoinSanityRange] * (i + 1)} Coin"
+ item_coin = f"DLC Quest: {World_Options.coinbundlequantity * (i + 1)} Coin"
set_rule(world.get_location(item_coin, player),
- has_enough_coin(player, World_Options[Options.CoinSanityRange] * (i + 1)))
- if 825 % World_Options[Options.CoinSanityRange] != 0:
+ has_enough_coin(player, World_Options.coinbundlequantity * (i + 1)))
+ if 825 % World_Options.coinbundlequantity != 0:
set_rule(world.get_location("DLC Quest: 825 Coin", player),
has_enough_coin(player, 825))
set_rule(world.get_location("Movement Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(4 / World_Options[Options.CoinSanityRange])))
+ math.ceil(4 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Animation Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(5 / World_Options[Options.CoinSanityRange])))
+ math.ceil(5 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Audio Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(5 / World_Options[Options.CoinSanityRange])))
+ math.ceil(5 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Pause Menu Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(5 / World_Options[Options.CoinSanityRange])))
+ math.ceil(5 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Time is Money Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(20 / World_Options[Options.CoinSanityRange])))
+ math.ceil(20 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Double Jump Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(100 / World_Options[Options.CoinSanityRange])))
+ math.ceil(100 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Pet Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(5 / World_Options[Options.CoinSanityRange])))
+ math.ceil(5 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Sexy Outfits Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(5 / World_Options[Options.CoinSanityRange])))
+ math.ceil(5 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Top Hat Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(5 / World_Options[Options.CoinSanityRange])))
+ math.ceil(5 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Map Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(140 / World_Options[Options.CoinSanityRange])))
+ math.ceil(140 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Gun Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(75 / World_Options[Options.CoinSanityRange])))
+ math.ceil(75 / World_Options.coinbundlequantity)))
set_rule(world.get_location("The Zombie Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(5 / World_Options[Options.CoinSanityRange])))
+ math.ceil(5 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Night Map Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(75 / World_Options[Options.CoinSanityRange])))
+ math.ceil(75 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Psychological Warfare Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(50 / World_Options[Options.CoinSanityRange])))
+ math.ceil(50 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Armor for your Horse Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(250 / World_Options[Options.CoinSanityRange])))
+ math.ceil(250 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Finish the Fight Pack", player),
lambda state: state.has("DLC Quest: Coin Bundle", player,
- math.ceil(5 / World_Options[Options.CoinSanityRange])))
+ math.ceil(5 / World_Options.coinbundlequantity)))
def set_basic_self_funded_purchase_rules(World_Options, has_enough_coin, player, world):
- if World_Options[Options.CoinSanity] != Options.CoinSanity.option_none:
+ if World_Options.coinsanity != Options.CoinSanity.option_none:
return
set_rule(world.get_location("Movement Pack", player),
has_enough_coin(player, 4))
@@ -232,17 +232,17 @@ def set_basic_self_funded_purchase_rules(World_Options, has_enough_coin, player,
def self_basic_win_condition(World_Options, player, world):
- if World_Options[Options.EndingChoice] == Options.EndingChoice.option_any:
+ if World_Options.ending_choice == Options.EndingChoice.option_any:
set_rule(world.get_location("Winning Basic", player),
lambda state: state.has("Finish the Fight Pack", player))
- if World_Options[Options.EndingChoice] == Options.EndingChoice.option_true:
+ if World_Options.ending_choice == Options.EndingChoice.option_true:
set_rule(world.get_location("Winning Basic", player),
lambda state: state.has("Armor for your Horse Pack", player) and state.has("Finish the Fight Pack",
player))
def set_lfod_rules(World_Options, has_enough_coin_freemium, player, world):
- if World_Options[Options.Campaign] == Options.Campaign.option_basic:
+ if World_Options.campaign == Options.Campaign.option_basic:
return
set_lfod_entrance_rules(player, world)
set_boss_door_requirements_rules(player, world)
@@ -297,7 +297,7 @@ def set_boss_door_requirements_rules(player, world):
def set_lfod_self_obtained_items_rules(World_Options, player, world):
- if World_Options[Options.ItemShuffle] != Options.ItemShuffle.option_disabled:
+ if World_Options.item_shuffle != Options.ItemShuffle.option_disabled:
return
set_rule(world.get_entrance("Vines", player),
lambda state: state.has("Incredibly Important Pack", player))
@@ -309,7 +309,7 @@ def set_lfod_self_obtained_items_rules(World_Options, player, world):
def set_lfod_shuffled_items_rules(World_Options, player, world):
- if World_Options[Options.ItemShuffle] != Options.ItemShuffle.option_shuffled:
+ if World_Options.item_shuffle != Options.ItemShuffle.option_shuffled:
return
set_rule(world.get_entrance("Vines", player),
lambda state: state.has("Wooden Sword", player) or state.has("Pickaxe", player))
@@ -328,79 +328,79 @@ def set_lfod_shuffled_items_rules(World_Options, player, world):
def self_lfod_coinsanity_funded_purchase_rules(World_Options, has_enough_coin_freemium, player, world):
- if World_Options[Options.CoinSanity] != Options.CoinSanity.option_coin:
+ if World_Options.coinsanity != Options.CoinSanity.option_coin:
return
- number_of_bundle = math.floor(889 / World_Options[Options.CoinSanityRange])
+ number_of_bundle = math.floor(889 / World_Options.coinbundlequantity)
for i in range(number_of_bundle):
item_coin_freemium = "Live Freemium or Die: number Coin"
- item_coin_loc_freemium = re.sub("number", str(World_Options[Options.CoinSanityRange] * (i + 1)),
+ item_coin_loc_freemium = re.sub("number", str(World_Options.coinbundlequantity * (i + 1)),
item_coin_freemium)
set_rule(world.get_location(item_coin_loc_freemium, player),
- has_enough_coin_freemium(player, World_Options[Options.CoinSanityRange] * (i + 1)))
- if 889 % World_Options[Options.CoinSanityRange] != 0:
+ has_enough_coin_freemium(player, World_Options.coinbundlequantity * (i + 1)))
+ if 889 % World_Options.coinbundlequantity != 0:
set_rule(world.get_location("Live Freemium or Die: 889 Coin", player),
has_enough_coin_freemium(player, 889))
add_rule(world.get_entrance("Boss Door", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(889 / World_Options[Options.CoinSanityRange])))
+ math.ceil(889 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Particles Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(5 / World_Options[Options.CoinSanityRange])))
+ math.ceil(5 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Day One Patch Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(5 / World_Options[Options.CoinSanityRange])))
+ math.ceil(5 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Checkpoint Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(5 / World_Options[Options.CoinSanityRange])))
+ math.ceil(5 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Incredibly Important Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(15 / World_Options[Options.CoinSanityRange])))
+ math.ceil(15 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Wall Jump Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(35 / World_Options[Options.CoinSanityRange])))
+ math.ceil(35 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Health Bar Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(5 / World_Options[Options.CoinSanityRange])))
+ math.ceil(5 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Parallax Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(5 / World_Options[Options.CoinSanityRange])))
+ math.ceil(5 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Harmless Plants Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(130 / World_Options[Options.CoinSanityRange])))
+ math.ceil(130 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Death of Comedy Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(15 / World_Options[Options.CoinSanityRange])))
+ math.ceil(15 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Canadian Dialog Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(10 / World_Options[Options.CoinSanityRange])))
+ math.ceil(10 / World_Options.coinbundlequantity)))
set_rule(world.get_location("DLC NPC Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(15 / World_Options[Options.CoinSanityRange])))
+ math.ceil(15 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Cut Content Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(40 / World_Options[Options.CoinSanityRange])))
+ math.ceil(40 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Name Change Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(150 / World_Options[Options.CoinSanityRange])))
+ math.ceil(150 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Season Pass", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(199 / World_Options[Options.CoinSanityRange])))
+ math.ceil(199 / World_Options.coinbundlequantity)))
set_rule(world.get_location("High Definition Next Gen Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(20 / World_Options[Options.CoinSanityRange])))
+ math.ceil(20 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Increased HP Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(10 / World_Options[Options.CoinSanityRange])))
+ math.ceil(10 / World_Options.coinbundlequantity)))
set_rule(world.get_location("Remove Ads Pack", player),
lambda state: state.has("Live Freemium or Die: Coin Bundle", player,
- math.ceil(25 / World_Options[Options.CoinSanityRange])))
+ math.ceil(25 / World_Options.coinbundlequantity)))
def set_lfod_self_funded_purchase_rules(World_Options, has_enough_coin_freemium, player, world):
- if World_Options[Options.CoinSanity] != Options.CoinSanity.option_none:
+ if World_Options.coinsanity != Options.CoinSanity.option_none:
return
add_rule(world.get_entrance("Boss Door", player),
has_enough_coin_freemium(player, 889))
@@ -442,10 +442,10 @@ def set_lfod_self_funded_purchase_rules(World_Options, has_enough_coin_freemium,
def set_completion_condition(World_Options, player, world):
- if World_Options[Options.Campaign] == Options.Campaign.option_basic:
+ if World_Options.campaign == Options.Campaign.option_basic:
world.completion_condition[player] = lambda state: state.has("Victory Basic", player)
- if World_Options[Options.Campaign] == Options.Campaign.option_live_freemium_or_die:
+ if World_Options.campaign == Options.Campaign.option_live_freemium_or_die:
world.completion_condition[player] = lambda state: state.has("Victory Freemium", player)
- if World_Options[Options.Campaign] == Options.Campaign.option_both:
+ if World_Options.campaign == Options.Campaign.option_both:
world.completion_condition[player] = lambda state: state.has("Victory Basic", player) and state.has(
"Victory Freemium", player)
diff --git a/worlds/dlcquest/__init__.py b/worlds/dlcquest/__init__.py
index 9569d0efcc1a..392eac7796fb 100644
--- a/worlds/dlcquest/__init__.py
+++ b/worlds/dlcquest/__init__.py
@@ -1,12 +1,13 @@
-from typing import Dict, Any, Iterable, Optional, Union
+from typing import Union
+
from BaseClasses import Tutorial
-from worlds.AutoWorld import World, WebWorld
-from .Items import DLCQuestItem, item_table, ItemData, create_items
-from .Locations import location_table, DLCQuestLocation
-from .Options import DLCQuest_options, DLCQuestOptions, fetch_options
-from .Rules import set_rules
-from .Regions import create_regions
+from worlds.AutoWorld import WebWorld, World
from . import Options
+from .Items import DLCQuestItem, ItemData, create_items, item_table
+from .Locations import DLCQuestLocation, location_table
+from .Options import DLCQuestOptions
+from .Regions import create_regions
+from .Rules import set_rules
client_version = 0
@@ -35,10 +36,8 @@ class DLCqworld(World):
data_version = 1
- option_definitions = DLCQuest_options
-
- def generate_early(self):
- self.options = fetch_options(self.multiworld, self.player)
+ options_dataclass = DLCQuestOptions
+ options: DLCQuestOptions
def create_regions(self):
create_regions(self.multiworld, self.player, self.options)
@@ -68,8 +67,8 @@ def create_items(self):
self.multiworld.itempool.remove(item)
def precollect_coinsanity(self):
- if self.options[Options.Campaign] == Options.Campaign.option_basic:
- if self.options[Options.CoinSanity] == Options.CoinSanity.option_coin and self.options[Options.CoinSanityRange] >= 5:
+ if self.options.campaign == Options.Campaign.option_basic:
+ if self.options.coinsanity == Options.CoinSanity.option_coin and self.options.coinbundlequantity >= 5:
self.multiworld.push_precollected(self.create_item("Movement Pack"))
@@ -80,12 +79,11 @@ def create_item(self, item: Union[str, ItemData]) -> DLCQuestItem:
return DLCQuestItem(item.name, item.classification, item.code, self.player)
def fill_slot_data(self):
- return {
- "death_link": self.multiworld.death_link[self.player].value,
- "ending_choice": self.multiworld.ending_choice[self.player].value,
- "campaign": self.multiworld.campaign[self.player].value,
- "coinsanity": self.multiworld.coinsanity[self.player].value,
- "coinbundlerange": self.multiworld.coinbundlequantity[self.player].value,
- "item_shuffle": self.multiworld.item_shuffle[self.player].value,
- "seed": self.multiworld.per_slot_randoms[self.player].randrange(99999999)
- }
+ options_dict = self.options.as_dict(
+ "death_link", "ending_choice", "campaign", "coinsanity", "item_shuffle"
+ )
+ options_dict.update({
+ "coinbundlerange": self.options.coinbundlequantity.value,
+ "seed": self.random.randrange(99999999)
+ })
+ return options_dict
diff --git a/worlds/factorio/docs/en_Factorio.md b/worlds/factorio/docs/en_Factorio.md
index 61bceb3820a1..dbc33d05dfde 100644
--- a/worlds/factorio/docs/en_Factorio.md
+++ b/worlds/factorio/docs/en_Factorio.md
@@ -42,3 +42,9 @@ depositing excess energy and supplementing energy deficits, much like Accumulato
Each placed EnergyLink Bridge provides 10 MW of throughput. The shared storage has unlimited capacity, but 25% of energy
is lost during depositing. The amount of energy currently in the shared storage is displayed in the Archipelago client.
It can also be queried by typing `/energy-link` in-game.
+
+## Unique Local Commands
+The following commands are only available when using the FactorioClient to play Factorio with Archipelago.
+
+- `/factorio ` Sends the command argument to the Factorio server as a command.
+- `/energy-link` Displays the amount of energy currently in shared storage for EnergyLink
diff --git a/worlds/ff1/__init__.py b/worlds/ff1/__init__.py
index 56b41d62d042..432467399ea6 100644
--- a/worlds/ff1/__init__.py
+++ b/worlds/ff1/__init__.py
@@ -91,7 +91,7 @@ def create_item(self, name: str) -> Item:
def set_rules(self):
self.multiworld.completion_condition[self.player] = lambda state: state.has(CHAOS_TERMINATED_EVENT, self.player)
- def generate_basic(self):
+ def create_items(self):
items = get_options(self.multiworld, 'items', self.player)
if FF1_BRIDGE in items.keys():
self._place_locked_item_in_sphere0(FF1_BRIDGE)
diff --git a/worlds/ff1/docs/en_Final Fantasy.md b/worlds/ff1/docs/en_Final Fantasy.md
index 29d4d29f8094..89629197434f 100644
--- a/worlds/ff1/docs/en_Final Fantasy.md
+++ b/worlds/ff1/docs/en_Final Fantasy.md
@@ -24,3 +24,8 @@ All items can appear in other players worlds, including consumables, shards, wea
All local and remote items appear the same. Final Fantasy will say that you received an item, then BOTH the client log and the
emulator will display what was found external to the in-game text box.
+
+## Unique Local Commands
+The following command is only available when using the FF1Client for the Final Fantasy Randomizer.
+
+- `/nes` Shows the current status of the NES connection.
diff --git a/worlds/generic/docs/commands_en.md b/worlds/generic/docs/commands_en.md
index e52ea20fd24d..3e7c0bd4bd30 100644
--- a/worlds/generic/docs/commands_en.md
+++ b/worlds/generic/docs/commands_en.md
@@ -1,96 +1,108 @@
-### Helpful Commands
+# Helpful Commands
Commands are split into two types: client commands and server commands. Client commands are commands which are executed
by the client and do not affect the Archipelago remote session. Server commands are commands which are executed by the
Archipelago server and affect the Archipelago session or otherwise provide feedback from the server.
-In clients which have their own commands the commands are typically prepended by a forward slash:`/`. Remote commands
-are always submitted to the server prepended with an exclamation point: `!`.
+In clients which have their own commands the commands are typically prepended by a forward slash: `/`.
-#### Local Commands
+Server commands are always submitted to the server prepended with an exclamation point: `!`.
-The following list is a list of client commands which may be available to you through your Archipelago client. You
-execute these commands in your client window.
-
-The following commands are available in these clients: SNIClient, FactorioClient, FF1Client.
-
-- `/connect ` Connect to the multiworld server.
-- `/disconnect` Disconnects you from your current session.
-- `/received` Displays all the items you have found or been sent.
-- `/missing` Displays all the locations along with their current status (checked/missing).
-- `/items` Lists all the item names for the current game.
-- `/locations` Lists all the location names for the current game.
-- `/ready` Sends ready status to the server.
-- `/help` Returns a list of available commands.
-- `/license` Returns the software licensing information.
-- Just typing anything will broadcast a message to all players
-
-##### FF1Client Only
-
-The following command is only available when using the FF1Client for the Final Fantasy Randomizer.
-
-- `/nes` Shows the current status of the NES connection.
-
-##### SNIClient Only
-
-The following command is only available when using the SNIClient for SNES based games.
+# Server Commands
-- `/snes` Attempts to connect to your SNES device via SNI.
-- `/snes_close` Closes the current SNES connection.
-- `/slow_mode` Toggles on or off slow mode, which limits the rate in which you receive items.
-
-##### FactorioClient Only
-
-The following command is only available when using the FactorioClient to play Factorio with Archipelago.
-
-- `/factorio ` Sends the command argument to the Factorio server as a command.
-
-#### Remote Commands
-
-Remote commands may be executed by any client which allows for sending text chat to the Archipelago server. If your
+Server commands may be executed by any client which allows for sending text chat to the Archipelago server. If your
client does not allow for sending chat then you may connect to your game slot with the TextClient which comes with the
Archipelago installation. In order to execute the command you need to merely send a text message with the command,
including the exclamation point.
-- `!help` Returns a listing of available remote commands.
+### General
+- `!help` Returns a listing of available commands.
- `!license` Returns the software licensing information.
-- `!countdown ` Starts a countdown using the given seconds value. Useful for synchronizing starts.
- Defaults to 10 seconds if no argument is provided.
- `!options` Returns the current server options, including password in plaintext.
+- `!players` Returns info about the currently connected and non-connected players.
+- `!status` Returns information about the connection status and check completion numbers for all players in the current room. (Optionally mention a Tag name and get information on who has that Tag. For example: !status DeathLink)
+
+
+### Utilities
+- `!countdown ` Starts a countdown using the given seconds value. Useful for synchronizing starts.
+ Defaults to 10 seconds if no argument is provided.
+- `!alias ` Sets your alias, which allows you to use commands with the alias rather than your provided name.
- `!admin ` Executes a command as if you typed it into the server console. Remote administration must be
enabled.
-- `!players` Returns info about the currently connected and non-connected players.
-- `!status` Returns information about your team. (Currently all players as teams are unimplemented.)
+
+### Information
- `!remaining` Lists the items remaining in your game, but not where they are or who they go to.
- `!missing` Lists the location checks you are missing from the server's perspective.
- `!checked` Lists all the location checks you've done from the server's perspective.
-- `!alias ` Sets your alias.
-- `!getitem ` Cheats an item, if it is enabled in the server.
-- `!hint_location ` Hints for a location specifically. Useful in games where item names may match location
- names such as Factorio.
-- `!hint ` Tells you at which location in whose game your Item is. Note you need to have checked some
- locations to earn a hint. You can check how many you have by just running `!hint`
-- `!release` If you didn't turn on auto-release or if you allowed releasing prior to goal completion. Remember that "
- releasing" actually means sending out your remaining items in your world.
-- `!collect` Grants you all the remaining checks in your world. Typically used after goal completion.
-
-#### Host only (on Archipelago.gg or in your server console)
+### Hints
+- `!hint` Lists all hints relevant to your world, the number of points you have for hints, and how much a hint costs.
+- `!hint ` Tells you the game world and location your item is in, uses points earned from completing locations.
+- `!hint_location ` Tells you what item is in a specific location, uses points earned from completing locations.
+
+### Collect/Release
+- `!collect` Grants you all the remaining items for your world by collecting them from all games. Typically used after
+goal completion.
+- `!release` Releases all items contained in your world to other worlds. Typically, done automatically by the sever, but
+can be configured to allow/require manual usage of this command.
+
+### Cheats
+- `!getitem ` Cheats an item to the currently connected slot, if it is enabled in the server.
+
+
+## Host only (on Archipelago.gg or in your server console)
+
+### General
- `/help` Returns a list of commands available in the console.
- `/license` Returns the software licensing information.
-- `/countdown ` Starts a countdown which is sent to all players via text chat. Defaults to 10 seconds if no
- argument is provided.
- `/options` Lists the server's current options, including password in plaintext.
-- `/save` Saves the state of the current multiworld. Note that the server autosaves on a minute basis.
- `/players` List currently connected players.
+- `/save` Saves the state of the current multiworld. Note that the server auto-saves on a minute basis.
- `/exit` Shutdown the server
-- `/alias ` Assign a player an alias.
+
+### Utilities
+- `/countdown ` Starts a countdown sent to all players via text chat. Defaults to 10 seconds if no
+ argument is provided.
+- `/option