Compare commits

..

4 Commits

Author SHA1 Message Date
NewSoupVi
453d89460f Update Options.py 2025-05-10 04:11:28 +02:00
NewSoupVi
28889e58aa Update Options.py
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-09 13:28:36 +02:00
NewSoupVi
f3c76399e0 Update Options.py 2025-05-08 15:00:21 +02:00
NewSoupVi
8384a23fe2 Institute limit on StartInventory 2025-05-08 14:54:11 +02:00
428 changed files with 11304 additions and 95222 deletions

1
.github/labeler.yml vendored
View File

@@ -21,6 +21,7 @@
- '!data/**' - '!data/**'
- '!.run/**' - '!.run/**'
- '!.github/**' - '!.github/**'
- '!worlds_disabled/**'
- '!worlds/**' - '!worlds/**'
- '!WebHost.py' - '!WebHost.py'
- '!WebHostLib/**' - '!WebHostLib/**'

View File

@@ -98,7 +98,7 @@ jobs:
shell: bash shell: bash
run: | run: |
cd build/exe* cd build/exe*
cp Players/Templates/VVVVVV.yaml Players/ cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate timeout 30 ./ArchipelagoGenerate
- name: Store 7z - name: Store 7z
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -189,7 +189,7 @@ jobs:
shell: bash shell: bash
run: | run: |
cd build/exe* cd build/exe*
cp Players/Templates/VVVVVV.yaml Players/ cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate timeout 30 ./ArchipelagoGenerate
- name: Store AppImage - name: Store AppImage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

View File

@@ -6,8 +6,6 @@ on:
permissions: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
env:
GH_REPO: ${{ github.repository }}
jobs: jobs:
labeler: labeler:

7
.gitignore vendored
View File

@@ -56,6 +56,7 @@ success.txt
output/ output/
Output Logs/ Output Logs/
/factorio/ /factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated /WebHostLib/static/generated
/freeze_requirements.txt /freeze_requirements.txt
/Archipelago.zip /Archipelago.zip
@@ -183,6 +184,12 @@ _speedups.c
_speedups.cpp _speedups.cpp
_speedups.html _speedups.html
# minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv # pyenv
.python-version .python-version

View File

@@ -1,4 +1,3 @@
import sys
from worlds.ahit.Client import launch from worlds.ahit.Client import launch
import Utils import Utils
import ModuleUpdate import ModuleUpdate
@@ -6,4 +5,4 @@ ModuleUpdate.update()
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("AHITClient", exception_logger="Client") Utils.init_logging("AHITClient", exception_logger="Client")
launch(*sys.argv[1:]) launch()

View File

@@ -11,7 +11,6 @@ from typing import List
import Utils import Utils
from settings import get_settings
from NetUtils import ClientStatus from NetUtils import ClientStatus
from Utils import async_start from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
@@ -81,8 +80,8 @@ class AdventureContext(CommonContext):
self.local_item_locations = {} self.local_item_locations = {}
self.dragon_speed_info = {} self.dragon_speed_info = {}
options = get_settings().adventure_options options = Utils.get_settings()
self.display_msgs = options.display_msgs self.display_msgs = options["adventure_options"]["display_msgs"]
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -103,7 +102,7 @@ class AdventureContext(CommonContext):
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == 'Connected': if cmd == 'Connected':
self.locations_array = None self.locations_array = None
if get_settings().adventure_options.as_dict().get("death_link", False): if Utils.get_settings()["adventure_options"].get("death_link", False):
self.set_deathlink = True self.set_deathlink = True
async_start(self.get_freeincarnates_used()) async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo": elif cmd == "RoomInfo":
@@ -416,9 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile): async def run_game(romfile):
options = get_settings().adventure_options auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
auto_start = options.rom_start rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
rom_args = options.rom_args
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)

View File

@@ -9,9 +9,8 @@ from argparse import Namespace
from collections import Counter, deque from collections import Counter, deque
from collections.abc import Collection, MutableSequence from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple, from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING) Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
import dataclasses
from typing_extensions import NotRequired, TypedDict from typing_extensions import NotRequired, TypedDict
@@ -55,21 +54,12 @@ class HasNameAndPlayer(Protocol):
player: int player: int
@dataclasses.dataclass
class PlandoItemBlock:
player: int
from_pool: bool
force: bool | Literal["silent"]
worlds: set[int] = dataclasses.field(default_factory=set)
items: list[str] = dataclasses.field(default_factory=list)
locations: list[str] = dataclasses.field(default_factory=list)
resolved_locations: list[Location] = dataclasses.field(default_factory=list)
count: dict[str, int] = dataclasses.field(default_factory=dict)
class MultiWorld(): class MultiWorld():
debug_types = False debug_types = False
player_name: Dict[int, str] player_name: Dict[int, str]
plando_texts: List[Dict[str, str]]
plando_items: List[List[Dict[str, Any]]]
plando_connections: List
worlds: Dict[int, "AutoWorld.World"] worlds: Dict[int, "AutoWorld.World"]
groups: Dict[int, Group] groups: Dict[int, Group]
regions: RegionManager regions: RegionManager
@@ -93,8 +83,6 @@ class MultiWorld():
start_location_hints: Dict[int, Options.StartLocationHints] start_location_hints: Dict[int, Options.StartLocationHints]
item_links: Dict[int, Options.ItemLinks] item_links: Dict[int, Options.ItemLinks]
plando_item_blocks: Dict[int, List[PlandoItemBlock]]
game: Dict[int, str] game: Dict[int, str]
random: random.Random random: random.Random
@@ -172,12 +160,13 @@ class MultiWorld():
self.local_early_items = {player: {} for player in self.player_ids} self.local_early_items = {player: {} for player in self.player_ids}
self.indirect_connections = {} self.indirect_connections = {}
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {} self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
self.plando_item_blocks = {}
for player in range(1, players + 1): for player in range(1, players + 1):
def set_player_attr(attr: str, val) -> None: def set_player_attr(attr: str, val) -> None:
self.__dict__.setdefault(attr, {})[player] = val self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('plando_item_blocks', []) set_player_attr('plando_items', [])
set_player_attr('plando_texts', {})
set_player_attr('plando_connections', [])
set_player_attr('game', "Archipelago") set_player_attr('game', "Archipelago")
set_player_attr('completion_condition', lambda state: True) set_player_attr('completion_condition', lambda state: True)
self.worlds = {} self.worlds = {}
@@ -438,8 +427,7 @@ class MultiWorld():
def get_location(self, location_name: str, player: int) -> Location: def get_location(self, location_name: str, player: int) -> Location:
return self.regions.location_cache[player][location_name] return self.regions.location_cache[player][location_name]
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False, def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState:
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
cached = getattr(self, "_all_state", None) cached = getattr(self, "_all_state", None)
if use_cache and cached: if use_cache and cached:
return cached.copy() return cached.copy()
@@ -448,13 +436,11 @@ class MultiWorld():
for item in self.itempool: for item in self.itempool:
self.worlds[item.player].collect(ret, item) self.worlds[item.player].collect(ret, item)
if collect_pre_fill_items: for player in self.player_ids:
for player in self.player_ids: subworld = self.worlds[player]
subworld = self.worlds[player] for item in subworld.get_pre_fill_items():
for item in subworld.get_pre_fill_items(): subworld.collect(ret, item)
subworld.collect(ret, item) ret.sweep_for_advancements()
if perform_sweep:
ret.sweep_for_advancements()
if use_cache: if use_cache:
self._all_state = ret self._all_state = ret
@@ -559,9 +545,7 @@ class MultiWorld():
else: else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1))) return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
def can_beat_game(self, def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
starting_state: Optional[CollectionState] = None,
locations: Optional[Iterable[Location]] = None) -> bool:
if starting_state: if starting_state:
if self.has_beaten_game(starting_state): if self.has_beaten_game(starting_state):
return True return True
@@ -570,9 +554,7 @@ class MultiWorld():
state = CollectionState(self) state = CollectionState(self)
if self.has_beaten_game(state): if self.has_beaten_game(state):
return True return True
prog_locations = {location for location in self.get_locations() if location.item
base_locations = self.get_locations() if locations is None else locations
prog_locations = {location for location in base_locations if location.item
and location.item.advancement and location not in state.locations_checked} and location.item.advancement and location not in state.locations_checked}
while prog_locations: while prog_locations:
@@ -741,7 +723,6 @@ class CollectionState():
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = [] additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False): def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
assert parent.worlds, "CollectionState created without worlds initialized in parent"
self.prog_items = {player: Counter() for player in parent.get_all_ids()} self.prog_items = {player: Counter() for player in parent.get_all_ids()}
self.multiworld = parent self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()} self.reachable_regions = {player: set() for player in parent.get_all_ids()}
@@ -1018,17 +999,6 @@ class CollectionState():
return changed return changed
def add_item(self, item: str, player: int, count: int = 1) -> None:
"""
Adds the item to state.
:param item: The item to be added.
:param player: The player the item is for.
:param count: How many of the item to add.
"""
assert count > 0
self.prog_items[player][item] += count
def remove(self, item: Item): def remove(self, item: Item):
changed = self.multiworld.worlds[item.player].remove(self, item) changed = self.multiworld.worlds[item.player].remove(self, item)
if changed: if changed:
@@ -1037,33 +1007,6 @@ class CollectionState():
self.blocked_connections[item.player] = set() self.blocked_connections[item.player] = set()
self.stale[item.player] = True self.stale[item.player] = True
def remove_item(self, item: str, player: int, count: int = 1) -> None:
"""
Removes the item from state.
:param item: The item to be removed.
:param player: The player the item is for.
:param count: How many of the item to remove.
"""
assert count > 0
self.prog_items[player][item] -= count
if self.prog_items[player][item] < 1:
del (self.prog_items[player][item])
def set_item(self, item: str, player: int, count: int) -> None:
"""
Sets the item in state equal to the provided count.
:param item: The item to modify.
:param player: The player the item is for.
:param count: How many of the item to now have.
"""
assert count >= 0
if count == 0:
del (self.prog_items[player][item])
else:
self.prog_items[player][item] = count
class EntranceType(IntEnum): class EntranceType(IntEnum):
ONE_WAY = 1 ONE_WAY = 1
@@ -1337,8 +1280,8 @@ class Region:
Connects current region to regions in exit dictionary. Passed region names must exist first. Connects current region to regions in exit dictionary. Passed region names must exist first.
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided, :param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
created entrances will be named "self.name -> connecting_region" created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region": rule} :param rules: rules for the exits from this region. format is {"connecting_region", rule}
""" """
if not isinstance(exits, Dict): if not isinstance(exits, Dict):
exits = dict.fromkeys(exits) exits = dict.fromkeys(exits)
@@ -1607,19 +1550,21 @@ class Spoiler:
# in the second phase, we cull each sphere such that the game is still beatable, # in the second phase, we cull each sphere such that the game is still beatable,
# reducing each range of influence to the bare minimum required inside it # reducing each range of influence to the bare minimum required inside it
required_locations = {location for sphere in collection_spheres for location in sphere} restore_later: Dict[Location, Item] = {}
for num, sphere in reversed(tuple(enumerate(collection_spheres))): for num, sphere in reversed(tuple(enumerate(collection_spheres))):
to_delete: Set[Location] = set() to_delete: Set[Location] = set()
for location in sphere: for location in sphere:
# we remove the location from required_locations to sweep from, and check if the game is still beatable # we remove the item at location and check if game is still beatable
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name, logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
location.item.player) location.item.player)
required_locations.remove(location) old_item = location.item
if multiworld.can_beat_game(state_cache[num], required_locations): location.item = None
if multiworld.can_beat_game(state_cache[num]):
to_delete.add(location) to_delete.add(location)
restore_later[location] = old_item
else: else:
# still required, got to keep it around # still required, got to keep it around
required_locations.add(location) location.item = old_item
# cull entries in spheres for spoiler walkthrough at end # cull entries in spheres for spoiler walkthrough at end
sphere -= to_delete sphere -= to_delete
@@ -1636,7 +1581,7 @@ class Spoiler:
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player) logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
precollected_items.remove(item) precollected_items.remove(item)
multiworld.state.remove(item) multiworld.state.remove(item)
if not multiworld.can_beat_game(multiworld.state, required_locations): if not multiworld.can_beat_game():
# Add the item back into `precollected_items` and collect it into `multiworld.state`. # Add the item back into `precollected_items` and collect it into `multiworld.state`.
multiworld.push_precollected(item) multiworld.push_precollected(item)
else: else:
@@ -1678,6 +1623,9 @@ class Spoiler:
self.create_paths(state, collection_spheres) self.create_paths(state, collection_spheres)
# repair the multiworld again # repair the multiworld again
for location, item in restore_later.items():
location.item = item
for item in removed_precollected: for item in removed_precollected:
multiworld.push_precollected(item) multiworld.push_precollected(item)

View File

@@ -266,71 +266,38 @@ class CommonContext:
last_death_link: float = time.time() # last send/received death link on AP layer last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info # remaining type info
slot_info: dict[int, NetworkSlot] slot_info: typing.Dict[int, NetworkSlot]
"""Slot Info from the server for the current connection""" server_address: typing.Optional[str]
server_address: str | None password: typing.Optional[str]
"""Autoconnect address provided by the ctx constructor""" hint_cost: typing.Optional[int]
password: str | None hint_points: typing.Optional[int]
"""Password used for Connecting, expected by server_auth""" player_names: typing.Dict[int, str]
hint_cost: int | None
"""Current Hint Cost per Hint from the server"""
hint_points: int | None
"""Current avaliable Hint Points from the server"""
player_names: dict[int, str]
"""Current lookup of slot number to player display name from server (includes aliases)"""
finished_game: bool finished_game: bool
"""
Bool to signal that status should be updated to Goal after reconnecting
to be used to ensure that a StatusUpdate packet does not get lost when disconnected
"""
ready: bool ready: bool
"""Bool to keep track of state for the /ready command""" team: typing.Optional[int]
team: int | None slot: typing.Optional[int]
"""Team number of currently connected slot""" auth: typing.Optional[str]
slot: int | None seed_name: typing.Optional[str]
"""Slot number of currently connected slot"""
auth: str | None
"""Name used in Connect packet"""
seed_name: str | None
"""Seed name that will be validated on opening a socket if present"""
# locations # locations
locations_checked: set[int] locations_checked: typing.Set[int] # local state
""" locations_scouted: typing.Set[int]
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting items_received: typing.List[NetworkItem]
to be used to ensure that a LocationChecks packet does not get lost when disconnected missing_locations: typing.Set[int] # server state
""" checked_locations: typing.Set[int] # server state
locations_scouted: set[int] server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
""" locations_info: typing.Dict[int, NetworkItem]
Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
to be used to ensure that a LocationScouts packet does not get lost when disconnected
"""
items_received: list[NetworkItem]
"""List of NetworkItems recieved from the server"""
missing_locations: set[int]
"""Container of Locations that are unchecked per server state"""
checked_locations: set[int]
"""Container of Locations that are checked per server state"""
server_locations: set[int]
"""Container of Locations that exist per server state; a combination between missing and checked locations"""
locations_info: dict[int, NetworkItem]
"""Dict of location id: NetworkItem info from LocationScouts request"""
# data storage # data storage
stored_data: dict[str, typing.Any] stored_data: typing.Dict[str, typing.Any]
""" stored_data_notification_keys: typing.Set[str]
Data Storage values by key that were retrieved from the server
any keys subscribed to with SetNotify will be kept up to date
"""
stored_data_notification_keys: set[str]
"""Current container of watched Data Storage keys, managed by ctx.set_notify"""
# internals # internals
# current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None _messagebox: typing.Optional["kvui.MessageBox"] = None
"""Current message box through kvui""" # message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None _messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
"""Message box reporting a loss of connection"""
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None: def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
# server state # server state

267
FF1Client.py Normal file
View File

@@ -0,0 +1,267 @@
import asyncio
import copy
import json
import time
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
class FF1CommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_nes(self):
"""Check NES Connection State"""
if isinstance(self.ctx, FF1Context):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in EmuHawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
class FF1Context(CommonContext):
command_processor = FF1CommandProcessor
game = 'Final Fantasy'
items_handling = 0b111 # full remote
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.nes_streams: (StreamReader, StreamWriter) = None
self.nes_sync_task = None
self.messages = {}
self.locations_array = None
self.nes_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(FF1Context, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to NES to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[time.time(), msg_id] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
async_start(parse_locations(self.locations_array, self, True))
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_print_json(self, args: dict):
if self.ui:
self.ui.print_json(copy.deepcopy(args["data"]))
else:
text = self.jsontotextparser(copy.deepcopy(args["data"]))
logger.info(text)
relevant = args.get("type", None) in {"Hint", "ItemSend"}
if relevant:
item = args["item"]
# goes to this world
if self.slot_concerns_self(args["receiving"]):
relevant = True
# found in this world
elif self.slot_concerns_self(item.player):
relevant = True
# not related
else:
relevant = False
if relevant:
item = args["item"]
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
self._set_message(msg, item.item)
def run_gui(self):
from kvui import GameManager
class FF1Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Final Fantasy 1 Client"
self.ui = FF1Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: FF1Context):
current_time = time.time()
return json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10}
}
)
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
if locations_array == ctx.locations_array and not force:
return
else:
# print("New values")
ctx.locations_array = locations_array
locations_checked = []
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
for location in ctx.missing_locations:
# index will be - 0x100 or 0x200
index = location
if location < 0x200:
# Location is a chest
index -= 0x100
flag = 0x04
else:
# Location is an NPC
index -= 0x200
flag = 0x02
# print(f"Location: {ctx.location_names[location]}")
# print(f"Index: {str(hex(index))}")
# print(f"value: {locations_array[index] & flag != 0}")
if locations_array[index] & flag != 0:
locations_checked.append(location)
if locations_checked:
# print([ctx.location_names[location] for location in locations_checked])
await ctx.send_msgs([
{"cmd": "LocationChecks",
"locations": locations_checked}
])
async def nes_sync_task(ctx: FF1Context):
logger.info("Starting nes connector. Use /nes for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.nes_streams:
(reader, writer) = ctx.nes_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
# print(data_decoded)
if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx, False))
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
"the ROM using the same link but adding your slot name")
if ctx.awaiting_rom:
await ctx.server_auth(False)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to NES")
ctx.nes_status = CONNECTION_CONNECTED_STATUS
else:
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.nes_status = error_status
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
else:
try:
logger.debug("Attempting to connect to NES")
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.nes_status = CONNECTION_REFUSED_STATUS
continue
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("FF1Client")
options = Utils.get_options()
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
async def main(args):
ctx = FF1Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.nes_sync_task:
await ctx.nes_sync_task
import colorama
parser = get_base_parser()
args = parser.parse_args()
colorama.just_fix_windows_console()
asyncio.run(main(args))
colorama.deinit()

387
Fill.py
View File

@@ -4,7 +4,7 @@ import logging
import typing import typing
from collections import Counter, deque from collections import Counter, deque
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from Options import Accessibility from Options import Accessibility
from worlds.AutoWorld import call_all from worlds.AutoWorld import call_all
@@ -100,7 +100,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# if minimal accessibility, only check whether location is reachable if game not beatable # if minimal accessibility, only check whether location is reachable if game not beatable
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal: if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state, perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
item_to_place.player) \ item_to_place.player) \
if single_player_placement else not has_beaten_game if single_player_placement else not has_beaten_game
else: else:
perform_access_check = True perform_access_check = True
@@ -138,21 +138,32 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# to clean that up later, so there is a chance generation fails. # to clean that up later, so there is a chance generation fails.
if (not single_player_placement or location.player == item_to_place.player) \ if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check): and location.can_fill(swap_state, item_to_place, perform_access_check):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1 # Verify placing this item won't reduce available locations, which would be a useless swap.
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count prev_state = swap_state.copy()
prev_loc_count = len(
multiworld.get_reachable_locations(prev_state))
reachable_items[placed_item.player].appendleft( swap_state.collect(item_to_place, True)
placed_item) new_loc_count = len(
item_pool.append(placed_item) multiworld.get_reachable_locations(swap_state))
# cleanup at the end to hopefully get better errors if new_loc_count >= prev_loc_count:
cleanup_required = True # Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
break swap_count += 1
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
item_pool.append(placed_item)
# cleanup at the end to hopefully get better errors
cleanup_required = True
break
# Item can't be placed here, restore original item # Item can't be placed here, restore original item
location.item = placed_item location.item = placed_item
@@ -231,7 +242,7 @@ def remaining_fill(multiworld: MultiWorld,
unplaced_items: typing.List[Item] = [] unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = [] placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
total = min(len(itempool), len(locations)) total = min(len(itempool), len(locations))
placed = 0 placed = 0
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule # Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
@@ -332,10 +343,8 @@ def fast_fill(multiworld: MultiWorld,
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]): def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool) maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in multiworld.player_ids if minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
multiworld.worlds[player].options.accessibility == "minimal"} unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
unreachable_locations = [location for location in multiworld.get_locations() if
location.player in minimal_players and
not location.can_reach(maximum_exploration_state)] not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations: for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not if (location.item is not None and location.item.advancement and location.address is not None and not
@@ -356,7 +365,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState,
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)] unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations: if unreachable_locations:
def forbid_important_item_rule(item: Item): def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal") return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
for location in unreachable_locations: for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule) add_item_rule(location, forbid_important_item_rule)
@@ -668,9 +677,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if multiworld.worlds[player].options.progression_balancing > 0 if multiworld.worlds[player].options.progression_balancing > 0
} }
if not balanceable_players: if not balanceable_players:
logging.info("Skipping multiworld progression balancing.") logging.info('Skipping multiworld progression balancing.')
else: else:
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.") logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.debug(balanceable_players) logging.debug(balanceable_players)
state: CollectionState = CollectionState(multiworld) state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set() checked_locations: typing.Set[Location] = set()
@@ -768,7 +777,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if player in threshold_percentages): if player in threshold_percentages):
break break
elif not balancing_sphere: elif not balancing_sphere:
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.") raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
# Gather a set of locations which we can swap items into # Gather a set of locations which we can swap items into
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set) unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
for l in unchecked_locations: for l in unchecked_locations:
@@ -784,8 +793,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
testing = items_to_test.pop() testing = items_to_test.pop()
reducing_state = state.copy() reducing_state = state.copy()
for location in itertools.chain(( for location in itertools.chain((
l for l in items_to_replace l for l in items_to_replace
if l.item.player == player if l.item.player == player
), items_to_test): ), items_to_test):
reducing_state.collect(location.item, True, location) reducing_state.collect(location.item, True, location)
@@ -858,30 +867,52 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
location_2.item.location = location_2 location_2.item.location = location_2
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]: def distribute_planned(multiworld: MultiWorld) -> None:
def warn(warning: str, force: bool | str) -> None: def warn(warning: str, force: typing.Union[bool, str]) -> None:
if isinstance(force, bool): if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
logging.warning(f"{warning}") logging.warning(f'{warning}')
else: else:
logging.debug(f"{warning}") logging.debug(f'{warning}')
def failed(warning: str, force: bool | str) -> None: def failed(warning: str, force: typing.Union[bool, str]) -> None:
if force is True: if force in [True, 'fail', 'failure']:
raise Exception(warning) raise Exception(warning)
else: else:
warn(warning, force) warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
world_name_lookup = multiworld.world_name_lookup world_name_lookup = multiworld.world_name_lookup
plando_blocks: dict[int, list[PlandoItemBlock]] = dict() block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
player_ids: set[int] = set(multiworld.player_ids) plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
player_ids = set(multiworld.player_ids)
for player in player_ids: for player in player_ids:
plando_blocks[player] = [] for block in multiworld.plando_items[player]:
for block in multiworld.worlds[player].options.plando_items: block['player'] = player
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force) if 'force' not in block:
target_world = block.world block['force'] = 'silent'
if 'from_pool' not in block:
block['from_pool'] = True
elif not isinstance(block['from_pool'], bool):
from_pool_type = type(block['from_pool'])
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
if 'world' not in block:
target_world = False
else:
target_world = block['world']
if target_world is False or multiworld.players == 1: # target own world if target_world is False or multiworld.players == 1: # target own world
worlds: set[int] = {player} worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own elif target_world is True: # target any worlds besides own
worlds = set(multiworld.player_ids) - {player} worlds = set(multiworld.player_ids) - {player}
elif target_world is None: # target all worlds elif target_world is None: # target all worlds
@@ -890,201 +921,173 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
worlds = set() worlds = set()
for listed_world in target_world: for listed_world in target_world:
if listed_world not in world_name_lookup: if listed_world not in world_name_lookup:
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.", failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block.force) block['force'])
continue continue
worlds.add(world_name_lookup[listed_world]) worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number elif type(target_world) == int: # target world by slot number
if target_world not in range(1, multiworld.players + 1): if target_world not in range(1, multiworld.players + 1):
failed( failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})", f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
block.force) block['force'])
continue continue
worlds = {target_world} worlds = {target_world}
else: # target world by slot name else: # target world by slot name
if target_world not in world_name_lookup: if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.", failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block.force) block['force'])
continue continue
worlds = {world_name_lookup[target_world]} worlds = {world_name_lookup[target_world]}
new_block.worlds = worlds block['world'] = worlds
items: list[str] | dict[str, typing.Any] = block.items items: block_value = []
if "items" in block:
items = block["items"]
if 'count' not in block:
block['count'] = False
elif "item" in block:
items = block["item"]
if 'count' not in block:
block['count'] = 1
else:
failed("You must specify at least one item to place items with plando.", block['force'])
continue
if isinstance(items, dict): if isinstance(items, dict):
item_list: list[str] = [] item_list: typing.List[str] = []
for key, value in items.items(): for key, value in items.items():
if value is True: if value is True:
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key)) value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
item_list += [key] * value item_list += [key] * value
items = item_list items = item_list
new_block.items = items if isinstance(items, str):
items = [items]
block['items'] = items
locations: list[str] = block.locations locations: block_value = []
if 'location' in block:
locations = block['location'] # just allow 'location' to keep old yamls compatible
elif 'locations' in block:
locations = block['locations']
if isinstance(locations, str): if isinstance(locations, str):
locations = [locations] locations = [locations]
resolved_locations: list[Location] = [] if isinstance(locations, dict):
for target_player in worlds: location_list = []
locations_from_groups: list[str] = [] for key, value in locations.items():
world_locations = multiworld.get_unfilled_locations(target_player) location_list += [key] * value
for group in multiworld.worlds[target_player].location_name_groups: locations = location_list
if group in locations:
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
resolved_locations.extend(location for location in world_locations
if location.name in [*locations, *locations_from_groups])
new_block.locations = sorted(dict.fromkeys(locations))
new_block.resolved_locations = sorted(set(resolved_locations))
count = block.count
if not count:
count = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else len(new_block.items))
if isinstance(count, int):
count = {"min": count, "max": count}
if "min" not in count:
count["min"] = 0
if "max" not in count:
count["max"] = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else len(new_block.items))
new_block.count = count
plando_blocks[player].append(new_block)
return plando_blocks
def resolve_early_locations_for_planned(multiworld: MultiWorld):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc)
for player in multiworld.plando_item_blocks:
removed = []
for block in multiworld.plando_item_blocks[player]:
locations = block.locations
resolved_locations = block.resolved_locations
worlds = block.worlds
if "early_locations" in locations: if "early_locations" in locations:
locations.remove("early_locations")
for target_player in worlds: for target_player in worlds:
resolved_locations += early_locations[target_player] locations += early_locations[target_player]
if "non_early_locations" in locations: if "non_early_locations" in locations:
locations.remove("non_early_locations")
for target_player in worlds: for target_player in worlds:
resolved_locations += non_early_locations[target_player] locations += non_early_locations[target_player]
if block.count["max"] > len(block.items): block['locations'] = list(dict.fromkeys(locations))
count = block.count["max"]
failed(f"Plando count {count} greater than items specified", block.force)
block.count["max"] = len(block.items)
if block.count["min"] > len(block.items):
block.count["min"] = len(block.items)
if block.count["max"] > len(block.resolved_locations) > 0:
count = block.count["max"]
failed(f"Plando count {count} greater than locations specified", block.force)
block.count["max"] = len(block.resolved_locations)
if block.count["min"] > len(block.resolved_locations):
block.count["min"] = len(block.resolved_locations)
block.count["target"] = multiworld.random.randint(block.count["min"],
block.count["max"])
if not block.count["target"]: if not block['count']:
removed.append(block) block['count'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if isinstance(block['count'], int):
block['count'] = {'min': block['count'], 'max': block['count']}
if 'min' not in block['count']:
block['count']['min'] = 0
if 'max' not in block['count']:
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if block['count']['max'] > len(block['items']):
count = block['count']
failed(f"Plando count {count} greater than items specified", block['force'])
block['count'] = len(block['items'])
if block['count']['max'] > len(block['locations']) > 0:
count = block['count']
failed(f"Plando count {count} greater than locations specified", block['force'])
block['count'] = len(block['locations'])
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
for block in removed: if block['count']['target'] > 0:
multiworld.plando_item_blocks[player].remove(block) plando_blocks.append(block)
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
# shuffle, but then sort blocks by number of locations minus number of items, # shuffle, but then sort blocks by number of locations minus number of items,
# so less-flexible blocks get priority # so less-flexible blocks get priority
multiworld.random.shuffle(plando_blocks) multiworld.random.shuffle(plando_blocks)
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"] plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
if len(block.resolved_locations) > 0 if len(block['locations']) > 0
else len(multiworld.get_unfilled_locations(block.player)) - else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
block.count["target"]))
for placement in plando_blocks: for placement in plando_blocks:
player = placement.player player = placement['player']
try: try:
worlds = placement.worlds worlds = placement['world']
locations = placement.resolved_locations locations = placement['locations']
items = placement.items items = placement['items']
maxcount = placement.count["target"] maxcount = placement['count']['target']
from_pool = placement.from_pool from_pool = placement['from_pool']
item_candidates = [] candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
if from_pool:
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
for item in multiworld.random.sample(items, maxcount):
candidate = next((i for i in instances if i.name == item), None)
if candidate is None:
warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
f"it's already missing from it", placement.force)
candidate = multiworld.worlds[player].create_item(item)
else:
multiworld.itempool.remove(candidate)
instances.remove(candidate)
item_candidates.append(candidate)
else:
item_candidates = [multiworld.worlds[player].create_item(item)
for item in multiworld.random.sample(items, maxcount)]
if any(item.code is None for item in item_candidates) \
and not all(item.code is None for item in item_candidates):
failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
f"event items and non-event items. "
f"Event items: {[item for item in item_candidates if item.code is None]}, "
f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
placement.force)
continue
else:
is_real = item_candidates[0].code is not None
candidates = [candidate for candidate in locations if candidate.item is None
and bool(candidate.address) == is_real]
multiworld.random.shuffle(candidates) multiworld.random.shuffle(candidates)
allstate = multiworld.get_all_state(False) multiworld.random.shuffle(items)
mincount = placement.count["min"] count = 0
allowed_margin = len(item_candidates) - mincount err: typing.List[str] = []
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True, successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
allow_partial=True, name="Plando Main Fill") claimed_indices: typing.Set[typing.Optional[int]] = set()
for item_name in items:
index_to_delete: typing.Optional[int] = None
if from_pool:
try:
# If from_pool, try to find an existing item with this name & player in the itempool and use it
index_to_delete, item = next(
(i, item) for i, item in enumerate(multiworld.itempool)
if item.player == player and item.name == item_name and i not in claimed_indices
)
except StopIteration:
warn(
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
placement['force'])
item = multiworld.worlds[player].create_item(item_name)
else:
item = multiworld.worlds[player].create_item(item_name)
for location in reversed(candidates):
if (location.address is None) == (item.code is None): # either both None or both not None
if not location.item:
if location.item_rule(item):
if location.can_fill(multiworld.state, item, False):
successful_pairs.append((index_to_delete, item, location))
claimed_indices.add(index_to_delete)
candidates.remove(location)
count = count + 1
break
else:
err.append(f"Can't place item at {location} due to fill condition not met.")
else:
err.append(f"{item_name} not allowed at {location}.")
else:
err.append(f"Cannot place {item_name} into already filled location {location}.")
else:
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
if count == maxcount:
break
if count < placement['count']['min']:
m = placement['count']['min']
failed(
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
# Sort indices in reverse so we can remove them one by one
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
for (index, item, location) in successful_pairs:
multiworld.push_item(location, item, collect=False)
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
if index is not None: # If this item is from_pool and was found in the pool, remove it.
multiworld.itempool.pop(index)
if len(item_candidates) > allowed_margin:
failed(f"Could not place {len(item_candidates)} "
f"of {mincount + allowed_margin} item(s) "
f"for {multiworld.player_name[player]}, "
f"remaining items: {item_candidates}",
placement.force)
if from_pool:
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
except Exception as e: except Exception as e:
raise Exception( raise Exception(
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e f"Error running plando for player {player} ({multiworld.player_name[player]})") from e

View File

@@ -10,8 +10,8 @@ import sys
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from collections import Counter from collections import Counter
from typing import Any, Dict, Tuple, Union
from itertools import chain from itertools import chain
from typing import Any
import ModuleUpdate import ModuleUpdate
@@ -77,7 +77,7 @@ def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits) return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None) -> tuple[argparse.Namespace, int]: def main(args=None) -> Tuple[argparse.Namespace, int]:
# __name__ == "__main__" check so unittests that already imported worlds don't trip this. # __name__ == "__main__" check so unittests that already imported worlds don't trip this.
if __name__ == "__main__" and "worlds" in sys.modules: if __name__ == "__main__" and "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded before logging init.") raise Exception("Worlds system should not be loaded before logging init.")
@@ -95,7 +95,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
logging.info("Race mode enabled. Using non-deterministic random source.") logging.info("Race mode enabled. Using non-deterministic random source.")
random.seed() # reset to time-based random source random.seed() # reset to time-based random source
weights_cache: dict[str, tuple[Any, ...]] = {} weights_cache: Dict[str, Tuple[Any, ...]] = {}
if args.weights_file_path and os.path.exists(args.weights_file_path): if args.weights_file_path and os.path.exists(args.weights_file_path):
try: try:
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path) weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
@@ -180,7 +180,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
erargs.name = {} erargs.name = {}
erargs.csv_output = args.csv_output erargs.csv_output = args.csv_output
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \ settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None) {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
for fname, yamls in weights_cache.items()} for fname, yamls in weights_cache.items()}
@@ -212,7 +212,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
path = player_path_cache[player] path = player_path_cache[player]
if path: if path:
try: try:
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \ settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path]) tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
for settingsObject in settings: for settingsObject in settings:
for k, v in vars(settingsObject).items(): for k, v in vars(settingsObject).items():
@@ -224,14 +224,10 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
except Exception as e: except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e raise Exception(f"Error setting {k} to {v} for player {player}") from e
# name was not specified if path == args.weights_file_path: # if name came from the weights file, just use base player name
if player not in erargs.name: erargs.name[player] = f"Player{player}"
if path == args.weights_file_path: elif player not in erargs.name: # if name was not specified, generate it from filename
# weights file, so we need to make the name unique erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = f"Player{player}"
else:
# use the filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter) erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
player += 1 player += 1
@@ -246,7 +242,7 @@ def main(args=None) -> tuple[argparse.Namespace, int]:
return erargs, seed return erargs, seed
def read_weights_yamls(path) -> tuple[Any, ...]: def read_weights_yamls(path) -> Tuple[Any, ...]:
try: try:
if urllib.parse.urlparse(path).scheme in ('https', 'file'): if urllib.parse.urlparse(path).scheme in ('https', 'file'):
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig") yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
@@ -338,6 +334,12 @@ def handle_name(name: str, player: int, name_counter: Counter):
return new_name return new_name
def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict: def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}') logging.debug(f'Applying {new_weights}')
cleaned_weights = {} cleaned_weights = {}
@@ -382,7 +384,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
return weights return weights
def roll_meta_option(option_key, game: str, category_dict: dict) -> Any: def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
from worlds import AutoWorldRegister from worlds import AutoWorldRegister
if not game: if not game:
@@ -403,7 +405,7 @@ def roll_linked_options(weights: dict) -> dict:
if "name" not in option_set: if "name" not in option_set:
raise ValueError("One of your linked options does not have a name.") raise ValueError("One of your linked options does not have a name.")
try: try:
if Options.roll_percentage(option_set["percentage"]): if roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.") logging.debug(f"Linked option {option_set['name']} triggered.")
new_options = option_set["options"] new_options = option_set["options"]
for category_name, category_options in new_options.items(): for category_name, category_options in new_options.items():
@@ -436,7 +438,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
trigger_result = get_choice("option_result", option_set) trigger_result = get_choice("option_result", option_set)
result = get_choice(key, currently_targeted_weights) result = get_choice(key, currently_targeted_weights)
currently_targeted_weights[key] = result currently_targeted_weights[key] = result
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)): if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
for category_name, category_options in option_set["options"].items(): for category_name, category_options in option_set["options"].items():
currently_targeted_weights = weights currently_targeted_weights = weights
if category_name: if category_name:
@@ -540,6 +542,10 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
handle_option(ret, game_weights, option_key, option, plando_options) handle_option(ret, game_weights, option_key, option, plando_options)
valid_keys.add(option_key) valid_keys.add(option_key)
# TODO remove plando_items after moving it to the options system
valid_keys.add("plando_items")
if PlandoOptions.items in plando_options:
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
if ret.game == "A Link to the Past": if ret.game == "A Link to the Past":
# TODO there are still more LTTP options not on the options system # TODO there are still more LTTP options not on the options system
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"} valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}

View File

@@ -11,16 +11,14 @@ Additional components can be added to worlds.LauncherComponents.components.
import argparse import argparse
import logging import logging
import multiprocessing import multiprocessing
import os
import shlex import shlex
import subprocess import subprocess
import sys import sys
import urllib.parse import urllib.parse
import webbrowser import webbrowser
from collections.abc import Callable, Sequence
from os.path import isfile from os.path import isfile
from shutil import which from shutil import which
from typing import Any from typing import Callable, Optional, Sequence, Tuple, Union, Any
if __name__ == "__main__": if __name__ == "__main__":
import ModuleUpdate import ModuleUpdate
@@ -42,17 +40,13 @@ def open_host_yaml():
if is_linux: if is_linux:
exe = which('sensible-editor') or which('gedit') or \ exe = which('sensible-editor') or which('gedit') or \
which('xdg-open') or which('gnome-open') or which('kde-open') which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, file])
elif is_macos: elif is_macos:
exe = which("open") exe = which("open")
subprocess.Popen([exe, file])
else: else:
webbrowser.open(file) webbrowser.open(file)
return
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, file], env=env)
def open_patch(): def open_patch():
suffixes = [] suffixes = []
@@ -97,11 +91,7 @@ def open_folder(folder_path):
return return
if exe: if exe:
env = os.environ subprocess.Popen([exe, folder_path])
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, folder_path], env=env)
else: else:
logging.warning(f"No file browser available to open {folder_path}") logging.warning(f"No file browser available to open {folder_path}")
@@ -113,51 +103,66 @@ def update_settings():
components.extend([ components.extend([
# Functions # Functions
Component("Open host.yaml", func=open_host_yaml, Component("Open host.yaml", func=open_host_yaml),
description="Open the host.yaml file to change settings for generation, games, and more."), Component("Open Patch", func=open_patch),
Component("Open Patch", func=open_patch, Component("Generate Template Options", func=generate_yamls),
description="Open a patch file, downloaded from the room page or provided by the host."), Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
Component("Generate Template Options", func=generate_yamls, Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
description="Generate template YAMLs for currently installed games."),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
description="Open archipelago.gg in your browser."),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
Component("Unrated/18+ Discord Server", icon="discord", Component("Unrated/18+ Discord Server", icon="discord",
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"), func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
description="Find unrated and 18+ games in the After Dark Discord server."), Component("Browse Files", func=browse_files),
Component("Browse Files", func=browse_files,
description="Open the Archipelago installation folder in your file browser."),
]) ])
def handle_uri(path: str) -> tuple[list[Component], Component]: def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
url = urllib.parse.urlparse(path) url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query) queries = urllib.parse.parse_qs(url.query)
client_components = [] launch_args = (path, *launch_args)
client_component = []
text_client_component = None text_client_component = None
game = queries["game"][0] if "game" in queries:
game = queries["game"][0]
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
game = "Archipelago"
for component in components: for component in components:
if component.supports_uri and component.game_name == game: if component.supports_uri and component.game_name == game:
client_components.append(component) client_component.append(component)
elif component.display_name == "Text Client": elif component.display_name == "Text Client":
text_client_component = component text_client_component = component
return client_components, text_client_component
from kvui import MDButton, MDButtonText
from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText
from kivymd.uix.divider import MDDivider
if not client_component:
run_component(text_client_component, *launch_args)
return
else:
popup_text = MDDialogSupportingText(text="Select client to open and connect with.")
component_buttons = [MDDivider()]
for component in [text_client_component, *client_component]:
component_buttons.append(MDButton(
MDButtonText(text=component.display_name),
on_release=lambda *args, comp=component: run_component(comp, *launch_args),
style="text"
))
component_buttons.append(MDDivider())
MDDialog(
# Headline
MDDialogHeadlineText(text="Connect to Multiworld"),
# Text
popup_text,
# Content
MDDialogContentContainer(
*component_buttons,
orientation="vertical"
),
).open()
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None: def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
from kvui import ButtonsPrompt
component_options = {
component.display_name: component for component in component_list
}
popup = ButtonsPrompt("Connect to Multiworld",
"Select client to open and connect with.",
lambda component_name: run_component(component_options[component_name], *launch_args),
*component_options.keys())
popup.open()
def identify(path: None | str) -> tuple[None | str, None | Component]:
if path is None: if path is None:
return None, None return None, None
for component in components: for component in components:
@@ -168,7 +173,7 @@ def identify(path: None | str) -> tuple[None | str, None | Component]:
return None, None return None, None
def get_exe(component: str | Component) -> Sequence[str] | None: def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
if isinstance(component, str): if isinstance(component, str):
name = component name = component
component = None component = None
@@ -196,8 +201,7 @@ def get_exe(component: str | Component) -> Sequence[str] | None:
def launch(exe, in_terminal=False): def launch(exe, in_terminal=False):
if in_terminal: if in_terminal:
if is_windows: if is_windows:
# intentionally using a window title with a space so it gets quoted and treated as a title subprocess.Popen(['start', *exe], shell=True)
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
return return
elif is_linux: elif is_linux:
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm') terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
@@ -222,10 +226,10 @@ def create_shortcut(button: Any, component: Component) -> None:
button.menu.dismiss() button.menu.dismiss()
refresh_components: Callable[[], None] | None = None refresh_components: Optional[Callable[[], None]] = None
def run_gui(launch_components: list[Component], args: Any) -> None: def run_gui(path: str, args: Any) -> None:
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox) from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
from kivy.properties import ObjectProperty from kivy.properties import ObjectProperty
from kivy.core.window import Window from kivy.core.window import Window
@@ -258,12 +262,12 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
cards: list[LauncherCard] cards: list[LauncherCard]
current_filter: Sequence[str | Type] | None current_filter: Sequence[str | Type] | None
def __init__(self, ctx=None, components=None, args=None): def __init__(self, ctx=None, path=None, args=None):
self.title = self.base_title + " " + Utils.__version__ self.title = self.base_title + " " + Utils.__version__
self.ctx = ctx self.ctx = ctx
self.icon = r"data/icon.png" self.icon = r"data/icon.png"
self.favorites = [] self.favorites = []
self.launch_components = components self.launch_uri = path
self.launch_args = args self.launch_args = args
self.cards = [] self.cards = []
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC) self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
@@ -385,9 +389,9 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
return self.top_screen return self.top_screen
def on_start(self): def on_start(self):
if self.launch_components: if self.launch_uri:
build_uri_popup(self.launch_components, self.launch_args) handle_uri(self.launch_uri, self.launch_args)
self.launch_components = None self.launch_uri = None
self.launch_args = None self.launch_args = None
@staticmethod @staticmethod
@@ -405,7 +409,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
if file and component: if file and component:
run_component(component, file) run_component(component, file)
else: else:
logging.warning(f"unable to identify component for {filename}") logging.warning(f"unable to identify component for {file}")
def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]): def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
# Activate search as soon as we start typing, no matter if we are focused on the search box or not. # Activate search as soon as we start typing, no matter if we are focused on the search box or not.
@@ -428,7 +432,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
for filter in self.current_filter)) for filter in self.current_filter))
super().on_stop() super().on_stop()
Launcher(components=launch_components, args=args).run() Launcher(path=path, args=args).run()
# avoiding Launcher reference leak # avoiding Launcher reference leak
# and don't try to do something with widgets after window closed # and don't try to do something with widgets after window closed
@@ -447,7 +451,7 @@ def run_component(component: Component, *args):
logging.warning(f"Component {component} does not appear to be executable.") logging.warning(f"Component {component} does not appear to be executable.")
def main(args: argparse.Namespace | dict | None = None): def main(args: Optional[Union[argparse.Namespace, dict]] = None):
if isinstance(args, argparse.Namespace): if isinstance(args, argparse.Namespace):
args = {k: v for k, v in args._get_kwargs()} args = {k: v for k, v in args._get_kwargs()}
elif not args: elif not args:
@@ -455,15 +459,7 @@ def main(args: argparse.Namespace | dict | None = None):
path = args.get("Patch|Game|Component|url", None) path = args.get("Patch|Game|Component|url", None)
if path is not None: if path is not None:
if path.startswith("archipelago://"): if not path.startswith("archipelago://"):
args["args"] = (path, *args.get("args", ()))
# add the url arg to the passthrough args
components, text_client_component = handle_uri(path)
if not components:
args["component"] = text_client_component
else:
args['launch_components'] = [text_client_component, *components]
else:
file, component = identify(path) file, component = identify(path)
if file: if file:
args['file'] = file args['file'] = file
@@ -479,7 +475,7 @@ def main(args: argparse.Namespace | dict | None = None):
elif "component" in args: elif "component" in args:
run_component(args["component"], *args["args"]) run_component(args["component"], *args["args"])
elif not args["update_settings"]: elif not args["update_settings"]:
run_gui(args.get("launch_components", None), args.get("args", ())) run_gui(path, args.get("args", ()))
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -290,9 +290,12 @@ async def gba_sync_task(ctx: MMBN3Context):
async def run_game(romfile): async def run_game(romfile):
from worlds.mmbn3 import MMBN3World options = Utils.get_options().get("mmbn3_options", None)
auto_start = MMBN3World.settings.rom_start if options is None:
if auto_start is True: auto_start = True
else:
auto_start = options.get("rom_start", True)
if auto_start:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)
elif os.path.isfile(auto_start): elif os.path.isfile(auto_start):

39
Main.py
View File

@@ -7,14 +7,14 @@ import tempfile
import time import time
import zipfile import zipfile
import zlib import zlib
from typing import Dict, List, Optional, Set, Tuple, Union
import worlds import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \ from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned flood_items
from NetUtils import convert_to_base_types
from Options import StartInventoryPool from Options import StartInventoryPool
from Utils import __version__, output_path, version_tuple from Utils import __version__, output_path, version_tuple, get_settings
from settings import get_settings from settings import get_settings
from worlds import AutoWorld from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -22,7 +22,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
__all__ = ["main"] __all__ = ["main"]
def main(args, seed=None, baked_server_options: dict[str, object] | None = None): def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
if not baked_server_options: if not baked_server_options:
baked_server_options = get_settings().server_options.as_dict() baked_server_options = get_settings().server_options.as_dict()
assert isinstance(baked_server_options, dict) assert isinstance(baked_server_options, dict)
@@ -37,6 +37,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger = logging.getLogger() logger = logging.getLogger()
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
multiworld.plando_options = args.plando_options multiworld.plando_options = args.plando_options
multiworld.plando_items = args.plando_items.copy()
multiworld.plando_texts = args.plando_texts.copy()
multiworld.plando_connections = args.plando_connections.copy()
multiworld.game = args.game.copy() multiworld.game = args.game.copy()
multiworld.player_name = args.name.copy() multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy() multiworld.sprite = args.sprite.copy()
@@ -132,15 +135,13 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld.worlds[1].options.non_local_items.value = set() multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set() multiworld.worlds[1].options.local_items.value = set()
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
AutoWorld.call_all(multiworld, "connect_entrances") AutoWorld.call_all(multiworld, "connect_entrances")
AutoWorld.call_all(multiworld, "generate_basic") AutoWorld.call_all(multiworld, "generate_basic")
# remove starting inventory from pool items. # remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible. # Because some worlds don't actually create items during create_items this has to be as late as possible.
fallback_inventory = StartInventoryPool({}) fallback_inventory = StartInventoryPool({})
depletion_pool: dict[int, dict[str, int]] = { depletion_pool: Dict[int, Dict[str, int]] = {
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy() player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
for player in multiworld.player_ids for player in multiworld.player_ids
} }
@@ -149,7 +150,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
} }
if target_per_player: if target_per_player:
new_itempool: list[Item] = [] new_itempool: List[Item] = []
# Make new itempool with start_inventory_from_pool items removed # Make new itempool with start_inventory_from_pool items removed
for item in multiworld.itempool: for item in multiworld.itempool:
@@ -178,9 +179,8 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
multiworld._all_state = None multiworld._all_state = None
logger.info("Running Item Plando.") logger.info("Running Item Plando.")
resolve_early_locations_for_planned(multiworld)
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks distribute_planned(multiworld)
for x in multiworld.plando_item_blocks[player]])
logger.info('Running Pre Main Fill.') logger.info('Running Pre Main Fill.')
@@ -233,7 +233,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir)) pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
# collect ER hint info # collect ER hint info
er_hint_data: dict[int, dict[int, str]] = {} er_hint_data: Dict[int, Dict[int, str]] = {}
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data) AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
def write_multidata(): def write_multidata():
@@ -274,7 +274,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
for player in multiworld.groups[location.item.player]["players"]: for player in multiworld.groups[location.item.player]["players"]:
precollected_hints[player].add(hint) precollected_hints[player].add(hint)
locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids} locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
for location in multiworld.get_filled_locations(): for location in multiworld.get_filled_locations():
if type(location.address) == int: if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \ assert location.item.code is not None, "item code None should be event, " \
@@ -303,12 +303,12 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
} }
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"] data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
checks_in_area: dict[int, dict[str, int | list[int]]] = {} checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
# get spheres -> filter address==None -> skip empty # get spheres -> filter address==None -> skip empty
spheres: list[dict[int, set[int]]] = [] spheres: List[Dict[int, Set[int]]] = []
for sphere in multiworld.get_sendable_spheres(): for sphere in multiworld.get_sendable_spheres():
current_sphere: dict[int, set[int]] = collections.defaultdict(set) current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
for sphere_location in sphere: for sphere_location in sphere:
current_sphere[sphere_location.player].add(sphere_location.address) current_sphere[sphere_location.player].add(sphere_location.address)
@@ -335,9 +335,6 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
} }
AutoWorld.call_all(multiworld, "modify_multidata", multidata) AutoWorld.call_all(multiworld, "modify_multidata", multidata)
for key in ("slot_data", "er_hint_data"):
multidata[key] = convert_to_base_types(multidata[key])
multidata = zlib.compress(pickle.dumps(multidata), 9) multidata = zlib.compress(pickle.dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f: with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:

344
MinecraftClient.py Normal file
View File

@@ -0,0 +1,344 @@
import argparse
import json
import os
import sys
import re
import atexit
import shutil
from subprocess import Popen
from shutil import copyfile
from time import strftime
import logging
import requests
import Utils
from Utils import is_windows
atexit.register(input, "Press enter to exit.")
# 1 or more digits followed by m or g, then optional b
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
def prompt_yes_no(prompt):
yes_inputs = {'yes', 'ye', 'y'}
no_inputs = {'no', 'n'}
while True:
choice = input(prompt + " [y/n] ").lower()
if choice in yes_inputs:
return True
elif choice in no_inputs:
return False
else:
print('Please respond with "y" or "n".')
def find_ap_randomizer_jar(forge_dir):
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
mods_dir = os.path.join(forge_dir, 'mods')
if os.path.isdir(mods_dir):
for entry in os.scandir(mods_dir):
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
logging.info(f"Found AP randomizer mod: {entry.name}")
return entry.name
return None
else:
os.mkdir(mods_dir)
logging.info(f"Created mods folder in {forge_dir}")
return None
def replace_apmc_files(forge_dir, apmc_file):
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
if apmc_file is None:
return
apdata_dir = os.path.join(forge_dir, 'APData')
copy_apmc = True
if not os.path.isdir(apdata_dir):
os.mkdir(apdata_dir)
logging.info(f"Created APData folder in {forge_dir}")
for entry in os.scandir(apdata_dir):
if entry.name.endswith(".apmc") and entry.is_file():
if not os.path.samefile(apmc_file, entry.path):
os.remove(entry.path)
logging.info(f"Removed {entry.name} in {apdata_dir}")
else: # apmc already in apdata
copy_apmc = False
if copy_apmc:
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
def read_apmc_file(apmc_file):
from base64 import b64decode
with open(apmc_file, 'r') as f:
return json.loads(b64decode(f.read()))
def update_mod(forge_dir, url: str):
"""Check mod version, download new mod from GitHub releases page if needed. """
ap_randomizer = find_ap_randomizer_jar(forge_dir)
os.path.basename(url)
if ap_randomizer is not None:
logging.info(f"Your current mod is {ap_randomizer}.")
else:
logging.info(f"You do not have the AP randomizer mod installed.")
if ap_randomizer != os.path.basename(url):
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
f"{os.path.basename(url)}")
if prompt_yes_no("Would you like to update?"):
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
logging.info("Downloading AP randomizer mod. This may take a moment...")
apmod_resp = requests.get(url)
if apmod_resp.status_code == 200:
with open(new_ap_mod, 'wb') as f:
f.write(apmod_resp.content)
logging.info(f"Wrote new mod file to {new_ap_mod}")
if old_ap_mod is not None:
os.remove(old_ap_mod)
logging.info(f"Removed old mod file from {old_ap_mod}")
else:
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
logging.error(f"Please report this issue on the Archipelago Discord server.")
sys.exit(1)
def check_eula(forge_dir):
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
eula_path = os.path.join(forge_dir, "eula.txt")
if not os.path.isfile(eula_path):
# Create eula.txt
with open(eula_path, 'w') as f:
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
f.write("eula=false\n")
with open(eula_path, 'r+') as f:
text = f.read()
if 'false' in text:
# Prompt user to agree to the EULA
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
if prompt_yes_no("Do you agree to the EULA?"):
f.seek(0)
f.write(text.replace('false', 'true'))
f.truncate()
logging.info(f"Set {eula_path} to true")
else:
sys.exit(0)
def find_jdk_dir(version: str) -> str:
"""get the specified versions jdk directory"""
for entry in os.listdir():
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
return os.path.abspath(entry)
def find_jdk(version: str) -> str:
"""get the java exe location"""
if is_windows:
jdk = find_jdk_dir(version)
jdk_exe = os.path.join(jdk, "bin", "java.exe")
if os.path.isfile(jdk_exe):
return jdk_exe
else:
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
if not jdk_exe:
raise Exception("Could not find Java. Is Java installed on the system?")
return jdk_exe
def download_java(java: str):
"""Download Corretto (Amazon JDK)"""
jdk = find_jdk_dir(java)
if jdk is not None:
print(f"Removing old JDK...")
from shutil import rmtree
rmtree(jdk)
print(f"Downloading Java...")
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
resp = requests.get(jdk_url)
if resp.status_code == 200: # OK
print(f"Extracting...")
import zipfile
from io import BytesIO
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
zf.extractall()
else:
print(f"Error downloading Java (status code {resp.status_code}).")
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
if not prompt_yes_no("Continue anyways?"):
sys.exit(0)
def install_forge(directory: str, forge_version: str, java_version: str):
"""download and install forge"""
java_exe = find_jdk(java_version)
if java_exe is not None:
print(f"Downloading Forge {forge_version}...")
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
resp = requests.get(forge_url)
if resp.status_code == 200: # OK
forge_install_jar = os.path.join(directory, "forge_install.jar")
if not os.path.exists(directory):
os.mkdir(directory)
with open(forge_install_jar, 'wb') as f:
f.write(resp.content)
print(f"Installing Forge...")
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
install_process.wait()
os.remove(forge_install_jar)
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
"""Run the Forge server."""
java_exe = find_jdk(java_version)
if not os.path.isfile(java_exe):
java_exe = "java" # try to fall back on java in the PATH
heap_arg = max_heap_re.match(heap_arg).group()
if heap_arg[-1] in ['b', 'B']:
heap_arg = heap_arg[:-1]
heap_arg = "-Xmx" + heap_arg
os_args = "win_args.txt" if is_windows else "unix_args.txt"
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
forge_args = []
with open(args_file) as argfile:
for line in argfile:
forge_args.extend(line.strip().split(" "))
args = [java_exe, heap_arg, *forge_args, "-nogui"]
logging.info(f"Running Forge server: {args}")
os.chdir(forge_dir)
return Popen(args)
def get_minecraft_versions(version, release_channel="release"):
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
resp = requests.get(version_file_endpoint)
local = False
if resp.status_code == 200: # OK
try:
data = resp.json()
except requests.exceptions.JSONDecodeError:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
else:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
if local:
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
data = json.load(f)
else:
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
json.dump(data, f)
try:
if version:
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
else:
return resp.json()[release_channel][0]
except (StopIteration, KeyError):
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
if release_channel != "release":
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
else:
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
sys.exit(0)
def is_correct_forge(forge_dir) -> bool:
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
return True
return False
if __name__ == '__main__':
Utils.init_logging("MinecraftClient")
parser = argparse.ArgumentParser()
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
help="Specify release channel to use.")
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
help="specify java version.")
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
help="specify forge version. (Minecraft Version-Forge Version)")
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
help="specify Mod data version to download.")
args = parser.parse_args()
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
# Change to executable's working directory
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
options = Utils.get_options()
channel = args.channel or options["minecraft_options"]["release_channel"]
apmc_data = None
data_version = args.data_version or None
if apmc_file is None and not args.install:
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
if apmc_file is not None and data_version is None:
apmc_data = read_apmc_file(apmc_file)
data_version = apmc_data.get('client_version', '')
versions = get_minecraft_versions(data_version, channel)
forge_dir = options["minecraft_options"]["forge_directory"]
max_heap = options["minecraft_options"]["max_heap_size"]
forge_version = args.forge or versions["forge"]
java_version = args.java or versions["java"]
mod_url = versions["url"]
java_dir = find_jdk_dir(java_version)
if args.install:
if is_windows:
print("Installing Java")
download_java(java_version)
if not is_correct_forge(forge_dir):
print("Installing Minecraft Forge")
install_forge(forge_dir, forge_version, java_version)
else:
print("Correct Forge version already found, skipping install.")
sys.exit(0)
if apmc_data is None:
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
if is_windows:
if java_dir is None or not os.path.isdir(java_dir):
if prompt_yes_no("Did not find java directory. Download and install java now?"):
download_java(java_version)
java_dir = find_jdk_dir(java_version)
if java_dir is None or not os.path.isdir(java_dir):
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
if not is_correct_forge(forge_dir):
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
install_forge(forge_dir, forge_version, java_version)
if not os.path.isdir(forge_dir):
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
if not max_heap_re.match(max_heap):
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
update_mod(forge_dir, mod_url)
replace_apmc_files(forge_dir, apmc_file)
check_eula(forge_dir)
server_process = run_forge_server(forge_dir, java_version, max_heap)
server_process.wait()

View File

@@ -458,12 +458,8 @@ class Context:
self.generator_version = Version(*decoded_obj["version"]) self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {}) clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {} self.minimum_client_versions = {}
if self.generator_version < Version(0, 6, 2):
min_version = Version(0, 1, 6)
else:
min_version = min_client_version
for player, version in clients_ver.items(): for player, version in clients_ver.items():
self.minimum_client_versions[player] = max(Version(*version), min_version) self.minimum_client_versions[player] = max(Version(*version), min_client_version)
self.slot_info = decoded_obj["slot_info"] self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()} self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
@@ -1830,7 +1826,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.clients[team][slot].append(client) ctx.clients[team][slot].append(client)
client.version = args['version'] client.version = args['version']
client.tags = args['tags'] client.tags = args['tags']
client.no_locations = bool(client.tags & _non_game_messages.keys()) client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
# set NoText for old PopTracker clients that predate the tag to save traffic # set NoText for old PopTracker clients that predate the tag to save traffic
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1)) client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
connected_packet = { connected_packet = {
@@ -1904,7 +1900,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
old_tags = client.tags old_tags = client.tags
client.tags = args["tags"] client.tags = args["tags"]
if set(old_tags) != set(client.tags): if set(old_tags) != set(client.tags):
client.no_locations = bool(client.tags & _non_game_messages.keys()) client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
client.no_text = "NoText" in client.tags or ( client.no_text = "NoText" in client.tags or (
"PopTracker" in client.tags and client.version < (0, 5, 1) "PopTracker" in client.tags and client.version < (0, 5, 1)
) )
@@ -1994,14 +1990,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.save() ctx.save()
for slot in concerning_slots: for slot in concerning_slots:
ctx.on_changed_hints(client.team, slot) ctx.on_changed_hints(client.team, slot)
elif cmd == 'StatusUpdate': elif cmd == 'StatusUpdate':
if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL: update_client_status(ctx, client, args["status"])
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
"text": "Trackers can't register Goal Complete",
"original_cmd": cmd}])
else:
update_client_status(ctx, client, args["status"])
elif cmd == 'Say': elif cmd == 'Say':
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable(): if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():

View File

@@ -106,27 +106,6 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
return obj return obj
_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"]
def convert_to_base_types(obj: typing.Any) -> _base_types:
if isinstance(obj, (tuple, list, set, frozenset)):
return tuple(convert_to_base_types(o) for o in obj)
elif isinstance(obj, dict):
return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()}
elif obj is None or type(obj) in (str, int, float, bool):
return obj
# unwrap simple types to their base, such as StrEnum
elif isinstance(obj, str):
return str(obj)
elif isinstance(obj, int):
return int(obj)
elif isinstance(obj, float):
return float(obj)
else:
raise Exception(f"Cannot handle {type(obj)}")
_encode = JSONEncoder( _encode = JSONEncoder(
ensure_ascii=False, ensure_ascii=False,
check_circular=False, check_circular=False,

View File

@@ -12,7 +12,6 @@ from CommonClient import CommonContext, server_loop, gui_enabled, \
import Utils import Utils
from Utils import async_start from Utils import async_start
from worlds import network_data_package from worlds import network_data_package
from worlds.oot import OOTWorld
from worlds.oot.Rom import Rom, compress_rom_file from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file from worlds.oot.N64Patch import apply_patch_file
from worlds.oot.Utils import data_path from worlds.oot.Utils import data_path
@@ -281,7 +280,7 @@ async def n64_sync_task(ctx: OoTContext):
async def run_game(romfile): async def run_game(romfile):
auto_start = OOTWorld.settings.rom_start auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)
@@ -296,7 +295,7 @@ async def patch_and_run_game(apz5_file):
decomp_path = base_name + '-decomp.z64' decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64' comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM # Load vanilla ROM, patch file, compress ROM
rom_file_name = OOTWorld.settings.rom_file rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
rom = Rom(rom_file_name) rom = Rom(rom_file_name)
sub_file = None sub_file = None

View File

@@ -24,12 +24,6 @@ if typing.TYPE_CHECKING:
import pathlib import pathlib
def roll_percentage(percentage: int | float) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
class OptionError(ValueError): class OptionError(ValueError):
pass pass
@@ -1025,7 +1019,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
if isinstance(data, typing.Iterable): if isinstance(data, typing.Iterable):
for text in data: for text in data:
if isinstance(text, typing.Mapping): if isinstance(text, typing.Mapping):
if roll_percentage(text.get("percentage", 100)): if random.random() < float(text.get("percentage", 100)/100):
at = text.get("at", None) at = text.get("at", None)
if at is not None: if at is not None:
if isinstance(at, dict): if isinstance(at, dict):
@@ -1051,7 +1045,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
else: else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!") raise OptionError("\"at\" must be a valid string or weighted list of strings!")
elif isinstance(text, PlandoText): elif isinstance(text, PlandoText):
if roll_percentage(text.percentage): if random.random() < float(text.percentage/100):
texts.append(text) texts.append(text)
else: else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}") raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
@@ -1175,7 +1169,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
for connection in data: for connection in data:
if isinstance(connection, typing.Mapping): if isinstance(connection, typing.Mapping):
percentage = connection.get("percentage", 100) percentage = connection.get("percentage", 100)
if roll_percentage(percentage): if random.random() < float(percentage / 100):
entrance = connection.get("entrance", None) entrance = connection.get("entrance", None)
if is_iterable_except_str(entrance): if is_iterable_except_str(entrance):
entrance = random.choice(sorted(entrance)) entrance = random.choice(sorted(entrance))
@@ -1193,7 +1187,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
percentage percentage
)) ))
elif isinstance(connection, PlandoConnection): elif isinstance(connection, PlandoConnection):
if roll_percentage(connection.percentage): if random.random() < float(connection.percentage / 100):
value.append(connection) value.append(connection)
else: else:
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.") raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
@@ -1475,133 +1469,6 @@ class ItemLinks(OptionList):
link["item_pool"] = list(pool) link["item_pool"] = list(pool)
@dataclass(frozen=True)
class PlandoItem:
items: list[str] | dict[str, typing.Any]
locations: list[str]
world: int | str | bool | None | typing.Iterable[str] | set[int] = False
from_pool: bool = True
force: bool | typing.Literal["silent"] = "silent"
count: int | bool | dict[str, int] = False
percentage: int = 100
class PlandoItems(Option[typing.List[PlandoItem]]):
"""Generic items plando."""
default = ()
supports_weighting = False
display_name = "Plando Items"
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
self.value = list(deepcopy(value))
super().__init__()
@classmethod
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
if not isinstance(data, typing.Iterable):
raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
value: typing.List[PlandoItem] = []
for item in data:
if isinstance(item, typing.Mapping):
percentage = item.get("percentage", 100)
if not isinstance(percentage, int):
raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
if not (0 <= percentage <= 100):
raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
if roll_percentage(percentage):
count = item.get("count", False)
items = item.get("items", [])
if not items:
items = item.get("item", None) # explicitly throw an error here if not present
if not items:
raise OptionError("You must specify at least one item to place items with plando.")
count = 1
if isinstance(items, str):
items = [items]
elif not isinstance(items, (dict, list)):
raise OptionError(f"Plando 'items' has to be string, list, or "
f"dictionary, not {type(items)}")
locations = item.get("locations", [])
if not locations:
locations = item.get("location", [])
if locations:
count = 1
else:
locations = ["Everywhere"]
if isinstance(locations, str):
locations = [locations]
if not isinstance(locations, list):
raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
world = item.get("world", False)
from_pool = item.get("from_pool", True)
force = item.get("force", "silent")
if not isinstance(from_pool, bool):
raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
if not (isinstance(force, bool) or force == "silent"):
raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
elif isinstance(item, PlandoItem):
if roll_percentage(item.percentage):
value.append(item)
else:
raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
return cls(value)
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if not self.value:
return
from BaseClasses import PlandoOptions
if not (PlandoOptions.items & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando items module is turned off, "
f"so items for {player_name} will be ignored.")
else:
# filter down item groups
for plando in self.value:
# confirm a valid count
if isinstance(plando.count, dict):
if "min" in plando.count and "max" in plando.count:
if plando.count["min"] > plando.count["max"]:
raise OptionError("Plando cannot have count `min` greater than `max`.")
items_copy = plando.items.copy()
if isinstance(plando.items, dict):
for item in items_copy:
if item in world.item_name_groups:
value = plando.items.pop(item)
group = world.item_name_groups[item]
filtered_items = sorted(group.difference(list(plando.items.keys())))
if not filtered_items:
raise OptionError(f"Plando `items` contains the group \"{item}\" "
f"and every item in it. This is not allowed.")
if value is True:
for key in filtered_items:
plando.items[key] = True
else:
for key in random.choices(filtered_items, k=value):
plando.items[key] = plando.items.get(key, 0) + 1
else:
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
for item in items_copy:
if item in world.item_name_groups:
plando.items.remove(item)
plando.items.extend(sorted(world.item_name_groups[item]))
@classmethod
def get_option_name(cls, value: list[PlandoItem]) -> str:
return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoItem]:
yield from self.value
def __len__(self) -> int:
return len(self.value)
class Removed(FreeText): class Removed(FreeText):
"""This Option has been Removed.""" """This Option has been Removed."""
rich_text_doc = True rich_text_doc = True
@@ -1624,7 +1491,6 @@ class PerGameCommonOptions(CommonOptions):
exclude_locations: ExcludeLocations exclude_locations: ExcludeLocations
priority_locations: PriorityLocations priority_locations: PriorityLocations
item_links: ItemLinks item_links: ItemLinks
plando_items: PlandoItems
@dataclass @dataclass
@@ -1678,7 +1544,6 @@ def get_option_groups(world: typing.Type[World], visibility_level: Visibility =
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None: def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
import os import os
from inspect import cleandoc
import yaml import yaml
from jinja2 import Template from jinja2 import Template
@@ -1717,21 +1582,19 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
# yaml dump may add end of document marker and newlines. # yaml dump may add end of document marker and newlines.
return yaml.dump(scalar).replace("...\n", "").strip() return yaml.dump(scalar).replace("...\n", "").strip()
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
template = Template(file_data)
for game_name, world in AutoWorldRegister.world_types.items(): for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden: if not world.hidden or generate_hidden:
option_groups = get_option_groups(world) option_groups = get_option_groups(world)
with open(local_path("data", "options.yaml")) as f:
res = template.render( file_data = f.read()
res = Template(file_data).render(
option_groups=option_groups, option_groups=option_groups,
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar, __version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range, dictify_range=dictify_range,
cleandoc=cleandoc,
) )
del file_data
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f: with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res) f.write(res)

View File

@@ -7,6 +7,7 @@ Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past * The Legend of Zelda: A Link to the Past
* Factorio * Factorio
* Minecraft
* Subnautica * Subnautica
* Risk of Rain 2 * Risk of Rain 2
* The Legend of Zelda: Ocarina of Time * The Legend of Zelda: Ocarina of Time
@@ -14,6 +15,7 @@ Currently, the following games are supported:
* Super Metroid * Super Metroid
* Secret of Evermore * Secret of Evermore
* Final Fantasy * Final Fantasy
* Rogue Legacy
* VVVVVV * VVVVVV
* Raft * Raft
* Super Mario 64 * Super Mario 64
@@ -40,6 +42,7 @@ Currently, the following games are supported:
* The Messenger * The Messenger
* Kingdom Hearts 2 * Kingdom Hearts 2
* The Legend of Zelda: Link's Awakening DX * The Legend of Zelda: Link's Awakening DX
* Clique
* Adventure * Adventure
* DLC Quest * DLC Quest
* Noita * Noita
@@ -77,9 +80,6 @@ Currently, the following games are supported:
* Inscryption * Inscryption
* Civilization VI * Civilization VI
* The Legend of Zelda: The Wind Waker * The Legend of Zelda: The Wind Waker
* Jak and Daxter: The Precursor Legacy
* Super Mario Land 2: 6 Golden Coins
* shapez
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -139,11 +139,8 @@ def local_path(*path: str) -> str:
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0])) local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
else: else:
import __main__ import __main__
if globals().get("__file__") and os.path.isfile(__file__): if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
# we are running in a normal Python environment # we are running in a normal Python environment
local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
# we are running in a normal Python environment, but AP was imported weirdly
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__)) local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
else: else:
# pray # pray
@@ -166,10 +163,6 @@ def home_path(*path: str) -> str:
os.symlink(home_path.cached_path, legacy_home_path) os.symlink(home_path.cached_path, legacy_home_path)
else: else:
os.makedirs(home_path.cached_path, 0o700, exist_ok=True) os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
elif sys.platform == 'darwin':
import platformdirs
home_path.cached_path = platformdirs.user_data_dir("Archipelago", False)
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
else: else:
# not implemented # not implemented
home_path.cached_path = local_path() # this will generate the same exceptions we got previously home_path.cached_path = local_path() # this will generate the same exceptions we got previously
@@ -181,7 +174,7 @@ def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions.""" """Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, "cached_path"): if hasattr(user_path, "cached_path"):
pass pass
elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()): elif os.access(local_path(), os.W_OK):
user_path.cached_path = local_path() user_path.cached_path = local_path()
else: else:
user_path.cached_path = home_path() user_path.cached_path = home_path()
@@ -230,12 +223,7 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
from shutil import which from shutil import which
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open")) open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
assert open_command, "Didn't find program for open_file! Please report this together with system details." assert open_command, "Didn't find program for open_file! Please report this together with system details."
subprocess.call([open_command, filename])
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.call([open_command, filename], env=env)
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes # from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
@@ -442,7 +430,6 @@ class RestrictedUnpickler(pickle.Unpickler):
if module == "builtins" and name in safe_builtins: if module == "builtins" and name in safe_builtins:
return getattr(builtins, name) return getattr(builtins, name)
# used by OptionCounter # used by OptionCounter
# necessary because the actual Options class instances are pickled when transfered to WebHost generation pool
if module == "collections" and name == "Counter": if module == "collections" and name == "Counter":
return collections.Counter return collections.Counter
# used by MultiServer -> savegame/multidata # used by MultiServer -> savegame/multidata
@@ -550,8 +537,6 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
if add_timestamp: if add_timestamp:
stream_handler.setFormatter(formatter) stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler) root_logger.addHandler(stream_handler)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# Relay unhandled exceptions to logger. # Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
@@ -718,30 +703,25 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(open_filename(*args)) res.put(open_filename(*args))
def _run_for_stdout(*args: str):
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \ def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]: -> typing.Optional[str]:
logging.info(f"Opening file input dialog for {title}.") logging.info(f"Opening file input dialog for {title}.")
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux: if is_linux:
# prefer native dialog # prefer native dialog
from shutil import which from shutil import which
kdialog = which("kdialog") kdialog = which("kdialog")
if kdialog: if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters) return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
zenity = which("zenity") zenity = which("zenity")
if zenity: if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else () selection = (f"--filename={suggest}",) if suggest else ()
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk # fall back to tk
try: try:
@@ -775,18 +755,21 @@ def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux: if is_linux:
# prefer native dialog # prefer native dialog
from shutil import which from shutil import which
kdialog = which("kdialog") kdialog = which("kdialog")
if kdialog: if kdialog:
return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory", return run(kdialog, f"--title={title}", "--getexistingdirectory",
os.path.abspath(suggest) if suggest else ".") os.path.abspath(suggest) if suggest else ".")
zenity = which("zenity") zenity = which("zenity")
if zenity: if zenity:
z_filters = ("--directory",) z_filters = ("--directory",)
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else () selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk # fall back to tk
try: try:
@@ -813,6 +796,9 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def messagebox(title: str, text: str, error: bool = False) -> None: def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_kivy_running(): if is_kivy_running():
from kvui import MessageBox from kvui import MessageBox
MessageBox(title, text, error).open() MessageBox(title, text, error).open()
@@ -823,10 +809,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
from shutil import which from shutil import which
kdialog = which("kdialog") kdialog = which("kdialog")
if kdialog: if kdialog:
return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text) return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
zenity = which("zenity") zenity = which("zenity")
if zenity: if zenity:
return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info") return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
elif is_windows: elif is_windows:
import ctypes import ctypes

View File

@@ -2,15 +2,14 @@ from __future__ import annotations
import atexit import atexit
import os import os
import pkgutil
import sys import sys
import asyncio import asyncio
import random import random
import typing import shutil
from typing import Tuple, List, Iterable, Dict from typing import Tuple, List, Iterable, Dict
from . import WargrooveWorld from worlds.wargroove import WargrooveWorld
from .Items import item_table, faction_table, CommanderData, ItemData from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -22,7 +21,7 @@ import logging
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("WargrooveClient", exception_logger="Client") Utils.init_logging("WargrooveClient", exception_logger="Client")
from NetUtils import ClientStatus from NetUtils import NetworkItem, ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \ from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop CommonContext, server_loop
@@ -30,34 +29,6 @@ wg_logger = logging.getLogger("WG")
class WargrooveClientCommandProcessor(ClientCommandProcessor): class WargrooveClientCommandProcessor(ClientCommandProcessor):
def _cmd_sacrifice_summon(self):
"""Toggles sacrifices and summons On/Off"""
if isinstance(self.ctx, WargrooveContext):
self.ctx.has_sacrifice_summon = not self.ctx.has_sacrifice_summon
if self.ctx.has_sacrifice_summon:
self.output(f"Sacrifices and summons are enabled.")
else:
unit_summon_response_file = os.path.join(self.ctx.game_communication_path, "unitSummonResponse")
if os.path.exists(unit_summon_response_file):
os.remove(unit_summon_response_file)
self.output(f"Sacrifices and summons are disabled.")
def _cmd_deathlink(self):
"""Toggles deathlink On/Off"""
if isinstance(self.ctx, WargrooveContext):
self.ctx.has_death_link = not self.ctx.has_death_link
Utils.async_start(self.ctx.update_death_link(self.ctx.has_death_link), name="Update Deathlink")
if self.ctx.has_death_link:
death_link_send_file = os.path.join(self.ctx.game_communication_path, "deathLinkSend")
if os.path.exists(death_link_send_file):
os.remove(death_link_send_file)
self.output(f"Deathlink enabled.")
else:
death_link_receive_file = os.path.join(self.ctx.game_communication_path, "deathLinkReceive")
if os.path.exists(death_link_receive_file):
os.remove(death_link_receive_file)
self.output(f"Deathlink disabled.")
def _cmd_resync(self): def _cmd_resync(self):
"""Manually trigger a resync.""" """Manually trigger a resync."""
self.output(f"Syncing items.") self.output(f"Syncing items.")
@@ -87,11 +58,6 @@ class WargrooveContext(CommonContext):
commander_defense_boost_multiplier: int = 0 commander_defense_boost_multiplier: int = 0
income_boost_multiplier: int = 0 income_boost_multiplier: int = 0
starting_groove_multiplier: float starting_groove_multiplier: float
has_death_link: bool = False
has_sacrifice_summon: bool = True
player_stored_units_key: str = ""
ai_stored_units_key: str = ""
max_stored_units: int = 1000
faction_item_ids = { faction_item_ids = {
'Starter': 0, 'Starter': 0,
'Cherrystone': 52025, 'Cherrystone': 52025,
@@ -105,31 +71,6 @@ class WargrooveContext(CommonContext):
'Income Boost': 52023, 'Income Boost': 52023,
'Commander Defense Boost': 52024, 'Commander Defense Boost': 52024,
} }
unit_classes = {
"archer",
"ballista",
"balloon",
"dog",
"dragon",
"giant",
"harpoonship",
"harpy",
"knight",
"mage",
"merman",
"rifleman",
"soldier",
"spearman",
"thief",
"thief_with_gold",
"travelboat",
"trebuchet",
"turtle",
"villager",
"wagon",
"warship",
"witch",
}
def __init__(self, server_address, password): def __init__(self, server_address, password):
super(WargrooveContext, self).__init__(server_address, password) super(WargrooveContext, self).__init__(server_address, password)
@@ -137,80 +78,31 @@ class WargrooveContext(CommonContext):
self.syncing = False self.syncing = False
self.awaiting_bridge = False self.awaiting_bridge = False
# self.game_communication_path: files go in this path to pass data between us and the actual game # self.game_communication_path: files go in this path to pass data between us and the actual game
game_options = WargrooveWorld.settings
# Validate the AppData directory with Wargroove save data.
# By default, Windows sets an environment variable we can leverage.
# However, other OSes don't usually have this value set, so we need to rely on a settings value instead.
appdata_wargroove = None
if "appdata" in os.environ: if "appdata" in os.environ:
appdata_wargroove = os.environ['appdata'] options = Utils.get_options()
else: root_directory = os.path.join(options["wargroove_options"]["root_directory"])
try: data_directory = os.path.join("lib", "worlds", "wargroove", "data")
appdata_wargroove = game_options.save_directory dev_data_directory = os.path.join("worlds", "wargroove", "data")
except FileNotFoundError: appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
print_error_and_close("WargrooveClient couldn't detect a path to the AppData folder.\n" if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
"Unable to infer required game_communication_path.\n" print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
"Try setting the \"save_directory\" value in your local options file " "Unable to infer required game_communication_path")
"to the AppData folder containing your Wargroove saves.") self.game_communication_path = os.path.join(root_directory, "AP")
appdata_wargroove = os.path.expandvars(os.path.join(appdata_wargroove, "Chucklefish", "Wargroove")) if not os.path.exists(self.game_communication_path):
if not os.path.isdir(appdata_wargroove): os.makedirs(self.game_communication_path)
print_error_and_close(f"WargrooveClient couldn't find Wargroove data in your AppData folder.\n" self.remove_communication_files()
f"Looked in \"{appdata_wargroove}\".\n" atexit.register(self.remove_communication_files)
f"If you haven't yet booted the game at least once, boot Wargroove " if not os.path.isdir(appdata_wargroove):
f"and then close it to attempt to fix this error.\n" print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
f"If the AppData folder above seems wrong, try setting the " "Boot Wargroove and then close it to attempt to fix this error")
f"\"save_directory\" value in your local options file " if not os.path.isdir(data_directory):
f"to the AppData folder containing your Wargroove saves.") data_directory = dev_data_directory
if not os.path.isdir(data_directory):
# Check for the Wargroove game executable path.
# This should always be set regardless of the OS.
root_directory = game_options["root_directory"]
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
print_error_and_close(f"WargrooveClient couldn't find wargroove64.exe in "
f"\"{root_directory}/win64_bin/\".\n"
f"Unable to infer required game_communication_path.\n"
f"Please verify the \"root_directory\" value in your local "
f"options file is set correctly.")
self.game_communication_path = os.path.join(root_directory, "AP")
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
self.remove_communication_files()
atexit.register(self.remove_communication_files)
if not os.path.isdir(appdata_wargroove):
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
"Boot Wargroove and then close it to attempt to fix this error")
mods_directory = os.path.join(appdata_wargroove, "mods", "ArchipelagoMod")
save_directory = os.path.join(appdata_wargroove, "save")
# Wargroove doesn't always create the mods directory, so we have to do it
if not os.path.isdir(mods_directory):
os.makedirs(mods_directory)
resources = ["data/mods/ArchipelagoMod/maps.dat",
"data/mods/ArchipelagoMod/mod.dat",
"data/mods/ArchipelagoMod/modAssets.dat",
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp",
"data/save/campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak"]
file_paths = [os.path.join(mods_directory, "maps.dat"),
os.path.join(mods_directory, "mod.dat"),
os.path.join(mods_directory, "modAssets.dat"),
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp"),
os.path.join(save_directory, "campaign-c40a6e5b0cdf86ddac03b276691c483d.cmp.bak")]
for resource, destination in zip(resources, file_paths):
file_data = pkgutil.get_data("worlds.wargroove", resource)
if file_data is None:
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!") print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
with open(destination, 'wb') as f: shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
f.write(file_data) else:
print_error_and_close("WargrooveClient couldn't detect system type. "
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: "Unable to infer required game_communication_path")
with open(os.path.join(self.game_communication_path, "deathLinkReceive"), 'w+') as f:
text = data.get("cause", "")
if text:
f.write(f"DeathLink: {text}")
else:
f.write(f"DeathLink: Received from {data['source']}")
super(WargrooveContext, self).on_deathlink(data)
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -246,25 +138,20 @@ class WargrooveContext(CommonContext):
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd in {"Connected"}: if cmd in {"Connected"}:
slot_data = args["slot_data"]
self.has_death_link = slot_data.get("death_link", False)
filename = f"AP_settings.json" filename = f"AP_settings.json"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
json.dump(slot_data, f) slot_data = args["slot_data"]
json.dump(args["slot_data"], f)
self.can_choose_commander = slot_data["can_choose_commander"] self.can_choose_commander = slot_data["can_choose_commander"]
print('can choose commander:', self.can_choose_commander) print('can choose commander:', self.can_choose_commander)
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"] self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
self.income_boost_multiplier = slot_data["income_boost"] self.income_boost_multiplier = slot_data["income_boost"]
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"] self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
f.close()
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
pass f.close()
self.player_stored_units_key = f"wargroove_player_units_{self.team}"
self.ai_stored_units_key = f"wargroove_ai_units_{self.team}"
self.set_notify(self.player_stored_units_key, self.ai_stored_units_key)
self.update_commander_data() self.update_commander_data()
self.ui.update_tracker() self.ui.update_tracker()
@@ -274,6 +161,7 @@ class WargrooveContext(CommonContext):
filename = f"seed{i}" filename = f"seed{i}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.write(str(random.randint(0, 4294967295))) f.write(str(random.randint(0, 4294967295)))
f.close()
if cmd in {"RoomInfo"}: if cmd in {"RoomInfo"}:
self.seed_name = args["seed_name"] self.seed_name = args["seed_name"]
@@ -301,6 +189,7 @@ class WargrooveContext(CommonContext):
f.write(f"{item_count * self.commander_defense_boost_multiplier}") f.write(f"{item_count * self.commander_defense_boost_multiplier}")
else: else:
f.write(f"{item_count}") f.write(f"{item_count}")
f.close()
print_filename = f"AP_{str(network_item.item)}.item.print" print_filename = f"AP_{str(network_item.item)}.item.print"
print_path = os.path.join(self.game_communication_path, print_filename) print_path = os.path.join(self.game_communication_path, print_filename)
@@ -311,6 +200,7 @@ class WargrooveContext(CommonContext):
self.item_names.lookup_in_game(network_item.item) + self.item_names.lookup_in_game(network_item.item) +
" from " + " from " +
self.player_names[network_item.player]) self.player_names[network_item.player])
f.close()
self.update_commander_data() self.update_commander_data()
self.ui.update_tracker() self.ui.update_tracker()
@@ -319,7 +209,7 @@ class WargrooveContext(CommonContext):
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w') as f:
pass f.close()
def run_gui(self): def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task.""" """Import kivy UI system and start running it as self.ui_task."""
@@ -495,75 +385,32 @@ class WargrooveContext(CommonContext):
async def game_watcher(ctx: WargrooveContext): async def game_watcher(ctx: WargrooveContext):
from worlds.wargroove.Locations import location_table
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
try: if ctx.syncing == True:
if ctx.syncing == True: sync_msg = [{'cmd': 'Sync'}]
sync_msg = [{'cmd': 'Sync'}] if ctx.locations_checked:
if ctx.locations_checked: sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) await ctx.send_msgs(sync_msg)
await ctx.send_msgs(sync_msg) ctx.syncing = False
ctx.syncing = False sending = []
sending = [] victory = False
victory = False for root, dirs, files in os.walk(ctx.game_communication_path):
for root, dirs, files in os.walk(ctx.game_communication_path): for file in files:
for file in files: if file.find("send") > -1:
if file == "deathLinkSend" and ctx.has_death_link: st = file.split("send", -1)[1]
with open(os.path.join(ctx.game_communication_path, file), 'r') as f: sending = sending+[(int(st))]
failed_mission = f.read() os.remove(os.path.join(ctx.game_communication_path, file))
if ctx.slot is not None: if file.find("victory") > -1:
await ctx.send_death(f"{ctx.player_names[ctx.slot]} failed {failed_mission}") victory = True
os.remove(os.path.join(ctx.game_communication_path, file)) os.remove(os.path.join(ctx.game_communication_path, file))
if file.find("send") > -1: ctx.locations_checked = sending
st = file.split("send", -1)[1] message = [{"cmd": 'LocationChecks', "locations": sending}]
sending = sending+[(int(st))] await ctx.send_msgs(message)
os.remove(os.path.join(ctx.game_communication_path, file)) if not ctx.finished_game and victory:
if file.find("victory") > -1: await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
victory = True ctx.finished_game = True
os.remove(os.path.join(ctx.game_communication_path, file)) await asyncio.sleep(0.1)
if file == "unitSacrifice" or file == "unitSacrificeAI":
if ctx.has_sacrifice_summon:
stored_units_key = ctx.player_stored_units_key
if file == "unitSacrificeAI":
stored_units_key = ctx.ai_stored_units_key
with open(os.path.join(ctx.game_communication_path, file), 'r') as f:
unit_class = f.read()
message = [{"cmd": 'Set', "key": stored_units_key,
"default": [],
"want_reply": True,
"operations": [{"operation": "add", "value": [unit_class[:64]]}]}]
await ctx.send_msgs(message)
os.remove(os.path.join(ctx.game_communication_path, file))
if file == "unitSummonRequestAI" or file == "unitSummonRequest":
if ctx.has_sacrifice_summon:
stored_units_key = ctx.player_stored_units_key
if file == "unitSummonRequestAI":
stored_units_key = ctx.ai_stored_units_key
with open(os.path.join(ctx.game_communication_path, "unitSummonResponse"), 'w') as f:
if stored_units_key in ctx.stored_data:
stored_units = ctx.stored_data[stored_units_key]
if stored_units is None:
stored_units = []
wg1_stored_units = [unit for unit in stored_units if unit in ctx.unit_classes]
if len(wg1_stored_units) != 0:
summoned_unit = random.choice(wg1_stored_units)
message = [{"cmd": 'Set', "key": stored_units_key,
"default": [],
"want_reply": True,
"operations": [{"operation": "remove", "value": summoned_unit[:64]}]}]
await ctx.send_msgs(message)
f.write(summoned_unit)
os.remove(os.path.join(ctx.game_communication_path, file))
ctx.locations_checked = sending
message = [{"cmd": 'LocationChecks', "locations": sending}]
await ctx.send_msgs(message)
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
await asyncio.sleep(0.1)
except Exception as err:
logger.warn("Exception in communication thread, a check may not have been sent: " + str(err))
def print_error_and_close(msg): def print_error_and_close(msg):
@@ -571,9 +418,8 @@ def print_error_and_close(msg):
Utils.messagebox("Error", msg, error=True) Utils.messagebox("Error", msg, error=True)
sys.exit(1) sys.exit(1)
def launch(*launch_args: str): if __name__ == '__main__':
async def main(): async def main(args):
args = parser.parse_args(launch_args)
ctx = WargrooveContext(args.connect, args.password) ctx = WargrooveContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled: if gui_enabled:
@@ -593,6 +439,7 @@ def launch(*launch_args: str):
parser = get_base_parser(description="Wargroove Client, for text interfacing.") parser = get_base_parser(description="Wargroove Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.just_fix_windows_console() colorama.just_fix_windows_console()
asyncio.run(main()) asyncio.run(main(args))
colorama.deinit() colorama.deinit()

View File

@@ -80,8 +80,10 @@ def register():
"""Import submodules, triggering their registering on flask routing. """Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem.""" Note: initializes worlds subsystem."""
# has automatic patch integration # has automatic patch integration
import worlds.AutoWorld
import worlds.Files import worlds.Files
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
game_name in worlds.Files.AutoPatchRegister.patch_types
from WebHostLib.customserver import run_server_process from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it # to trigger app routing picking up on it

View File

@@ -61,7 +61,12 @@ def download_slot_file(room_id, player_id: int):
else: else:
import io import io
if slot_data.game == "Factorio": if slot_data.game == "Minecraft":
from worlds.minecraft import mc_update_output
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf: with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist(): for name in zf.namelist():
if name.endswith("info.json"): if name.endswith("info.json"):

View File

@@ -1,4 +1,4 @@
flask>=3.1.1 flask>=3.1.0
werkzeug>=3.1.3 werkzeug>=3.1.3
pony>=0.7.19 pony>=0.7.19
waitress>=3.0.2 waitress>=3.0.2

View File

@@ -0,0 +1,49 @@
window.addEventListener('load', () => {
// Reload tracker every 15 seconds
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item tracker
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
// Update only counters in the location-table
let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
for (let i = 0; i < counters.length; i++) {
counters[i].innerHTML = fakeCounters[i].innerHTML;
}
};
ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let i = 0; i < categories.length; i++) {
let hide_id = categories[i].id.split('-')[0];
if (hide_id == 'Total') {
continue;
}
categories[i].addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'-header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
});

View File

@@ -0,0 +1,102 @@
#player-tracker-wrapper{
margin: 0;
}
#inventory-table{
border-top: 2px solid #000000;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
width: 384px;
background-color: #42b149;
}
#inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
#inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
filter: grayscale(100%) contrast(75%) brightness(30%);
}
#inventory-table img.acquired{
filter: none;
}
#inventory-table div.counted-item {
position: relative;
}
#inventory-table div.item-count {
position: absolute;
color: white;
font-family: "Minecraftia", monospace;
font-weight: bold;
bottom: 0;
right: 0;
}
#location-table{
width: 384px;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: #42b149;
padding: 0 3px 3px;
font-family: "Minecraftia", monospace;
font-size: 14px;
cursor: default;
}
#location-table th{
vertical-align: middle;
text-align: left;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
line-height: 20px;
}
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header {
font-weight: bold;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}
#location-table tbody.locations {
font-size: 12px;
}
#location-table td.location-name {
padding-left: 16px;
}
.hide {
display: none;
}

View File

@@ -26,15 +26,30 @@
<td>{{ patch.game }}</td> <td>{{ patch.game }}</td>
<td> <td>
{% if patch.data %} {% if patch.data %}
{% if patch.game == "VVVVVV" and room.seed.slots|length == 1 %} {% if patch.game == "Minecraft" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMC File...</a>
{% elif patch.game == "Factorio" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Factorio Mod...</a>
{% elif patch.game == "Kingdom Hearts 2" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Kingdom Hearts 2 Mod...</a>
{% elif patch.game == "Ocarina of Time" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APZ5 File...</a>
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download> <a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APV6 File...</a> Download APV6 File...</a>
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %} {% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download> <a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APSM64EX File...</a> Download APSM64EX File...</a>
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %} {% elif patch.game | supports_apdeltapatch %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download> <a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a> Download Patch File...</a>
{% elif patch.game == "Final Fantasy Mystic Quest" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMQ File...</a>
{% else %} {% else %}
No file to download for this game. No file to download for this game.
{% endif %} {% endif %}

View File

@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
<td><img src="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></td>
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
title="Progressive Resource Crafting" /></td>
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Ender Pearl'] }}" class="{{ 'acquired' if '3 Ender Pearls' in acquired_items }}" title="Ender Pearls" />
<div class="item-count">{{ pearls_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Bucket'] }}" class="{{ 'acquired' if 'Bucket' in acquired_items }}" title="Bucket" /></td>
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Archery' in acquired_items }}" title="Archery" /></td>
<td><img src="{{ icons['Shield'] }}" class="{{ 'acquired' if 'Shield' in acquired_items }}" title="Shield" /></td>
<td><img src="{{ icons['Red Bed'] }}" class="{{ 'acquired' if 'Bed' in acquired_items }}" title="Bed" /></td>
<td><img src="{{ icons['Water Bottle'] }}" class="{{ 'acquired' if 'Bottles' in acquired_items }}" title="Bottles" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Netherite Scrap'] }}" class="{{ 'acquired' if '8 Netherite Scrap' in acquired_items }}" title="Netherite Scrap" />
<div class="item-count">{{ scrap_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Flint and Steel'] }}" class="{{ 'acquired' if 'Flint and Steel' in acquired_items }}" title="Flint and Steel" /></td>
<td><img src="{{ icons['Enchanting Table'] }}" class="{{ 'acquired' if 'Enchanting' in acquired_items }}" title="Enchanting" /></td>
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
<div class="item-count">{{ shard_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
</tr>
</table>
<table id="location-table">
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
</tr>
<tbody class="locations hide" id="{{area}}">
{% for location in location_info[area] %}
<tr>
<td class="location-name">{{ location }}</td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -706,6 +706,127 @@ if "A Link to the Past" in network_data_package["games"]:
_multiworld_trackers["A Link to the Past"] = render_ALinkToThePast_multiworld_tracker _multiworld_trackers["A Link to the Past"] = render_ALinkToThePast_multiworld_tracker
_player_trackers["A Link to the Past"] = render_ALinkToThePast_tracker _player_trackers["A Link to the Past"] = render_ALinkToThePast_tracker
if "Minecraft" in network_data_package["games"]:
def render_Minecraft_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
icons = {
"Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png",
"Stone Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c4/Stone_Pickaxe_JE2_BE2.png",
"Iron Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d1/Iron_Pickaxe_JE3_BE2.png",
"Diamond Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e7/Diamond_Pickaxe_JE3_BE3.png",
"Wooden Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d5/Wooden_Sword_JE2_BE2.png",
"Stone Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b1/Stone_Sword_JE2_BE2.png",
"Iron Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/8/8e/Iron_Sword_JE2_BE2.png",
"Diamond Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/4/44/Diamond_Sword_JE3_BE3.png",
"Leather Tunic": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b7/Leather_Tunic_JE4_BE2.png",
"Iron Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Iron_Chestplate_JE2_BE2.png",
"Diamond Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e0/Diamond_Chestplate_JE3_BE2.png",
"Iron Ingot": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Iron_Ingot_JE3_BE2.png",
"Block of Iron": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7e/Block_of_Iron_JE4_BE3.png",
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b3/Brewing_Stand_%28empty%29_JE10.png",
"Ender Pearl": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/f6/Ender_Pearl_JE3_BE2.png",
"Bucket": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Bucket_JE2_BE2.png",
"Bow": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/a/ab/Bow_%28Pull_2%29_JE1_BE1.png",
"Shield": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c6/Shield_JE2_BE1.png",
"Red Bed": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/6/6a/Red_Bed_%28N%29.png",
"Netherite Scrap": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/33/Netherite_Scrap_JE2_BE1.png",
"Flint and Steel": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/94/Flint_and_Steel_JE4_BE2.png",
"Enchanting Table": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Enchanting_Table.gif",
"Fishing Rod": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7f/Fishing_Rod_JE2_BE2.png",
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
"Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png",
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
"Saddle": "https://i.imgur.com/2QtDyR0.png",
"Channeling Book": "https://i.imgur.com/J3WsYZw.png",
"Silk Touch Book": "https://i.imgur.com/iqERxHQ.png",
"Piercing IV Book": "https://i.imgur.com/OzJptGz.png",
}
minecraft_location_ids = {
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014],
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020,
42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105,
42099, 42103, 42110, 42100],
"Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111,
42112,
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
}
display_data = {}
# Determine display for progressive items
progressive_items = {
"Progressive Tools": 45013,
"Progressive Weapons": 45012,
"Progressive Armor": 45014,
"Progressive Resource Crafting": 45001
}
progressive_names = {
"Progressive Tools": ["Wooden Pickaxe", "Stone Pickaxe", "Iron Pickaxe", "Diamond Pickaxe"],
"Progressive Weapons": ["Wooden Sword", "Stone Sword", "Iron Sword", "Diamond Sword"],
"Progressive Armor": ["Leather Tunic", "Iron Chestplate", "Diamond Chestplate"],
"Progressive Resource Crafting": ["Iron Ingot", "Iron Ingot", "Block of Iron"]
}
inventory = tracker_data.get_player_inventory_counts(team, player)
for item_name, item_id in progressive_items.items():
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
display_name = progressive_names[item_name][level]
base_name = item_name.split(maxsplit=1)[1].lower().replace(" ", "_")
display_data[base_name + "_url"] = icons[display_name]
# Multi-items
multi_items = {
"3 Ender Pearls": 45029,
"8 Netherite Scrap": 45015,
"Dragon Egg Shard": 45043
}
for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower()
count = inventory[item_id]
if count >= 0:
display_data[base_name + "_count"] = count
# Victory condition
game_state = tracker_data.get_player_client_status(team, player)
display_data["game_finished"] = game_state == 30
# Turn location IDs into advancement tab counts
checked_locations = tracker_data.get_player_checked_locations(team, player)
lookup_name = lambda id: tracker_data.location_id_to_name["Minecraft"][id]
location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations}
for tab_name, tab_locations in minecraft_location_ids.items()}
checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations])
for tab_name, tab_locations in minecraft_location_ids.items()}
checks_done["Total"] = len(checked_locations)
checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in minecraft_location_ids.items()}
checks_in_area["Total"] = sum(checks_in_area.values())
lookup_any_item_id_to_name = tracker_data.item_id_to_name["Minecraft"]
return render_template(
"tracker__Minecraft.html",
inventory=inventory,
icons=icons,
acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0},
player=player,
team=team,
room=tracker_data.room,
player_name=tracker_data.get_player_name(team, player),
saving_second=tracker_data.get_room_saving_second(),
checks_done=checks_done,
checks_in_area=checks_in_area,
location_info=location_info,
**display_data,
)
_player_trackers["Minecraft"] = render_Minecraft_tracker
if "Ocarina of Time" in network_data_package["games"]: if "Ocarina of Time" in network_data_package["games"]:
def render_OcarinaOfTime_tracker(tracker_data: TrackerData, team: int, player: int) -> str: def render_OcarinaOfTime_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
icons = { icons = {

View File

@@ -119,9 +119,9 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
# AP Container # AP Container
elif handler: elif handler:
data = zfile.open(file, "r").read() data = zfile.open(file, "r").read()
with zipfile.ZipFile(BytesIO(data)) as container: patch = handler(BytesIO(data))
player = json.loads(container.open("archipelago.json").read())["player"] patch.read()
files[player] = data files[patch.player] = data
# Spoiler # Spoiler
elif file.filename.endswith(".txt"): elif file.filename.endswith(".txt"):
@@ -135,6 +135,11 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
flash("Could not load multidata. File may be corrupted or incompatible.") flash("Could not load multidata. File may be corrupted or incompatible.")
multidata = None multidata = None
# Minecraft
elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read()
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
files[metadata["player_id"]] = data
# Factorio # Factorio
elif file.filename.endswith(".zip"): elif file.filename.endswith(".zip"):

View File

@@ -24,20 +24,9 @@
<BaseButton>: <BaseButton>:
ripple_color: app.theme_cls.primaryColor ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2 ripple_duration_in_fast: 0.2
<MDNavigationItemBase>: <MDTabsItemBase>:
on_release: app.screens.switch_screens(self) ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2
MDNavigationItemLabel:
text: root.text
theme_text_color: "Custom"
text_color_active: self.theme_cls.primaryColor
text_color_normal: 1, 1, 1, 1
# indicator is on icon only for some reason
canvas.before:
Color:
rgba: self.theme_cls.secondaryContainerColor if root.active else self.theme_cls.transparentColor
Rectangle:
size: root.size
<TooltipLabel>: <TooltipLabel>:
adaptive_height: True adaptive_height: True
theme_font_size: "Custom" theme_font_size: "Custom"
@@ -233,8 +222,3 @@
spacing: 10 spacing: 10
size_hint_y: None size_hint_y: None
height: self.minimum_height height: self.minimum_height
<MessageBoxLabel>:
valign: "middle"
halign: "center"
text_size: self.width, None
height: self.texture_size[1]

View File

@@ -365,14 +365,18 @@ request_handlers = {
["PREFERRED_CORES"] = function (req) ["PREFERRED_CORES"] = function (req)
local res = {} local res = {}
local preferred_cores = client.getconfig().PreferredCores local preferred_cores = client.getconfig().PreferredCores
local systems_enumerator = preferred_cores.Keys:GetEnumerator()
res["type"] = "PREFERRED_CORES_RESPONSE" res["type"] = "PREFERRED_CORES_RESPONSE"
res["value"] = {} res["value"] = {}
res["value"]["NES"] = preferred_cores.NES
while systems_enumerator:MoveNext() do res["value"]["SNES"] = preferred_cores.SNES
res["value"][systems_enumerator.Current] = preferred_cores[systems_enumerator.Current] res["value"]["GB"] = preferred_cores.GB
end 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
return res return res
end, end,

462
data/lua/connector_ff1.lua Normal file
View File

@@ -0,0 +1,462 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
require("common")
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local ITEM_INDEX = 0x03
local WEAPON_INDEX = 0x07
local ARMOR_INDEX = 0x0B
local goldLookup = {
[0x16C] = 10,
[0x16D] = 20,
[0x16E] = 25,
[0x16F] = 30,
[0x170] = 55,
[0x171] = 70,
[0x172] = 85,
[0x173] = 110,
[0x174] = 135,
[0x175] = 155,
[0x176] = 160,
[0x177] = 180,
[0x178] = 240,
[0x179] = 255,
[0x17A] = 260,
[0x17B] = 295,
[0x17C] = 300,
[0x17D] = 315,
[0x17E] = 330,
[0x17F] = 350,
[0x180] = 385,
[0x181] = 400,
[0x182] = 450,
[0x183] = 500,
[0x184] = 530,
[0x185] = 575,
[0x186] = 620,
[0x187] = 680,
[0x188] = 750,
[0x189] = 795,
[0x18A] = 880,
[0x18B] = 1020,
[0x18C] = 1250,
[0x18D] = 1455,
[0x18E] = 1520,
[0x18F] = 1760,
[0x190] = 1975,
[0x191] = 2000,
[0x192] = 2750,
[0x193] = 3400,
[0x194] = 4150,
[0x195] = 5000,
[0x196] = 5450,
[0x197] = 6400,
[0x198] = 6720,
[0x199] = 7340,
[0x19A] = 7690,
[0x19B] = 7900,
[0x19C] = 8135,
[0x19D] = 9000,
[0x19E] = 9300,
[0x19F] = 9500,
[0x1A0] = 9900,
[0x1A1] = 10000,
[0x1A2] = 12350,
[0x1A3] = 13000,
[0x1A4] = 13450,
[0x1A5] = 14050,
[0x1A6] = 14720,
[0x1A7] = 15000,
[0x1A8] = 17490,
[0x1A9] = 18010,
[0x1AA] = 19990,
[0x1AB] = 20000,
[0x1AC] = 20010,
[0x1AD] = 26000,
[0x1AE] = 45000,
[0x1AF] = 65000
}
local extensionConsumableLookup = {
[432] = 0x3C,
[436] = 0x3C,
[440] = 0x3C,
[433] = 0x3D,
[437] = 0x3D,
[441] = 0x3D,
[434] = 0x3E,
[438] = 0x3E,
[442] = 0x3E,
[435] = 0x3F,
[439] = 0x3F,
[443] = 0x3F
}
local noOverworldItemsLookup = {
[499] = 0x2B,
[500] = 0x12,
}
local consumableStacks = nil
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local ff1Socket = nil
local frame = 0
local isNesHawk = false
--Sets correct memory access functions based on whether NesHawk or QuickNES is loaded
local function defineMemoryFunctions()
local memDomain = {}
local domains = memory.getmemorydomainlist()
if domains[1] == "System Bus" then
--NesHawk
isNesHawk = true
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
memDomain["saveram"] = function() memory.usememorydomain("Battery RAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
elseif domains[1] == "WRAM" then
--QuickNES
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
memDomain["saveram"] = function() memory.usememorydomain("WRAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
end
return memDomain
end
local memDomain = defineMemoryFunctions()
local function StateOKForMainLoop()
memDomain.saveram()
local A = u8(0x102) -- Party Made
local B = u8(0x0FC)
local C = u8(0x0A3)
return A ~= 0x00 and not (A== 0xF2 and B == 0xF2 and C == 0xF2)
end
function generateLocationChecked()
memDomain.saveram()
data = uRange(0x01FF, 0x101)
data[0] = nil
return data
end
function setConsumableStacks()
memDomain.rom()
consumableStacks = {}
-- In order shards, tent, cabin, house, heal, pure, soft, ext1, ext2, ext3, ex4
consumableStacks[0x35] = 1
consumableStacks[0x36] = u8(0x47400) + 1
consumableStacks[0x37] = u8(0x47401) + 1
consumableStacks[0x38] = u8(0x47402) + 1
consumableStacks[0x39] = u8(0x47403) + 1
consumableStacks[0x3A] = u8(0x47404) + 1
consumableStacks[0x3B] = u8(0x47405) + 1
consumableStacks[0x3C] = u8(0x47406) + 1
consumableStacks[0x3D] = u8(0x47407) + 1
consumableStacks[0x3E] = u8(0x47408) + 1
consumableStacks[0x3F] = u8(0x47409) + 1
end
function getEmptyWeaponSlots()
memDomain.saveram()
ret = {}
count = 1
slot1 = uRange(0x118, 0x4)
slot2 = uRange(0x158, 0x4)
slot3 = uRange(0x198, 0x4)
slot4 = uRange(0x1D8, 0x4)
for i,v in pairs(slot1) do
if v == 0 then
ret[count] = 0x118 + i
count = count + 1
end
end
for i,v in pairs(slot2) do
if v == 0 then
ret[count] = 0x158 + i
count = count + 1
end
end
for i,v in pairs(slot3) do
if v == 0 then
ret[count] = 0x198 + i
count = count + 1
end
end
for i,v in pairs(slot4) do
if v == 0 then
ret[count] = 0x1D8 + i
count = count + 1
end
end
return ret
end
function getEmptyArmorSlots()
memDomain.saveram()
ret = {}
count = 1
slot1 = uRange(0x11C, 0x4)
slot2 = uRange(0x15C, 0x4)
slot3 = uRange(0x19C, 0x4)
slot4 = uRange(0x1DC, 0x4)
for i,v in pairs(slot1) do
if v == 0 then
ret[count] = 0x11C + i
count = count + 1
end
end
for i,v in pairs(slot2) do
if v == 0 then
ret[count] = 0x15C + i
count = count + 1
end
end
for i,v in pairs(slot3) do
if v == 0 then
ret[count] = 0x19C + i
count = count + 1
end
end
for i,v in pairs(slot4) do
if v == 0 then
ret[count] = 0x1DC + i
count = count + 1
end
end
return ret
end
local function slice (tbl, s, e)
local pos, new = 1, {}
for i = s + 1, e do
new[pos] = tbl[i]
pos = pos + 1
end
return new
end
function processBlock(block)
local msgBlock = block['messages']
if msgBlock ~= nil then
for i, v in pairs(msgBlock) do
if itemMessages[i] == nil then
local msg = {TTL=450, message=v, color=0xFFFF0000}
itemMessages[i] = msg
end
end
end
local itemsBlock = block["items"]
memDomain.saveram()
isInGame = u8(0x102)
if itemsBlock ~= nil and isInGame ~= 0x00 then
if consumableStacks == nil then
setConsumableStacks()
end
memDomain.saveram()
-- print('ITEMBLOCK: ')
-- print(itemsBlock)
itemIndex = u8(ITEM_INDEX)
-- print('ITEMINDEX: '..itemIndex)
for i, v in pairs(slice(itemsBlock, itemIndex, #itemsBlock)) do
-- Minus the offset and add to the correct domain
local memoryLocation = v
if v >= 0x100 and v <= 0x114 then
-- This is a key item
memoryLocation = memoryLocation - 0x0E0
wU8(memoryLocation, 0x01)
elseif v >= 0x1E0 and v <= 0x1F2 then
-- This is a movement item
-- Minus Offset (0x100) - movement offset (0xE0)
memoryLocation = memoryLocation - 0x1E0
-- Canal is a flipped bit
if memoryLocation == 0x0C then
wU8(memoryLocation, 0x00)
else
wU8(memoryLocation, 0x01)
end
elseif v >= 0x1F3 and v <= 0x1F4 then
-- NoOverworld special items
memoryLocation = noOverworldItemsLookup[v]
wU8(memoryLocation, 0x01)
elseif v >= 0x16C and v <= 0x1AF then
-- This is a gold item
amountToAdd = goldLookup[v]
biggest = u8(0x01E)
medium = u8(0x01D)
smallest = u8(0x01C)
currentValue = 0x10000 * biggest + 0x100 * medium + smallest
newValue = currentValue + amountToAdd
newBiggest = math.floor(newValue / 0x10000)
newMedium = math.floor(math.fmod(newValue, 0x10000) / 0x100)
newSmallest = math.floor(math.fmod(newValue, 0x100))
wU8(0x01E, newBiggest)
wU8(0x01D, newMedium)
wU8(0x01C, newSmallest)
elseif v >= 0x115 and v <= 0x11B then
-- This is a regular consumable OR a shard
-- Minus Offset (0x100) + item offset (0x20)
memoryLocation = memoryLocation - 0x0E0
currentValue = u8(memoryLocation)
amountToAdd = consumableStacks[memoryLocation]
if currentValue < 99 then
wU8(memoryLocation, currentValue + amountToAdd)
end
elseif v >= 0x1B0 and v <= 0x1BB then
-- This is an extension consumable
memoryLocation = extensionConsumableLookup[v]
currentValue = u8(memoryLocation)
amountToAdd = consumableStacks[memoryLocation]
if currentValue < 99 then
value = currentValue + amountToAdd
if value > 99 then
value = 99
end
wU8(memoryLocation, value)
end
end
end
if #itemsBlock > itemIndex then
wU8(ITEM_INDEX, #itemsBlock)
end
memDomain.saveram()
weaponIndex = u8(WEAPON_INDEX)
emptyWeaponSlots = getEmptyWeaponSlots()
lastUsedWeaponIndex = weaponIndex
-- print('WEAPON_INDEX: '.. weaponIndex)
memDomain.saveram()
for i, v in pairs(slice(itemsBlock, weaponIndex, #itemsBlock)) do
if v >= 0x11C and v <= 0x143 then
-- Minus the offset and add to the correct domain
local itemValue = v - 0x11B
if #emptyWeaponSlots > 0 then
slot = table.remove(emptyWeaponSlots, 1)
wU8(slot, itemValue)
lastUsedWeaponIndex = weaponIndex + i
else
break
end
end
end
if lastUsedWeaponIndex ~= weaponIndex then
wU8(WEAPON_INDEX, lastUsedWeaponIndex)
end
memDomain.saveram()
armorIndex = u8(ARMOR_INDEX)
emptyArmorSlots = getEmptyArmorSlots()
lastUsedArmorIndex = armorIndex
-- print('ARMOR_INDEX: '.. armorIndex)
memDomain.saveram()
for i, v in pairs(slice(itemsBlock, armorIndex, #itemsBlock)) do
if v >= 0x144 and v <= 0x16B then
-- Minus the offset and add to the correct domain
local itemValue = v - 0x143
if #emptyArmorSlots > 0 then
slot = table.remove(emptyArmorSlots, 1)
wU8(slot, itemValue)
lastUsedArmorIndex = armorIndex + i
else
break
end
end
end
if lastUsedArmorIndex ~= armorIndex then
wU8(ARMOR_INDEX, lastUsedArmorIndex)
end
end
end
function receive()
l, e = ff1Socket:receive()
if e == 'closed' then
if curstate == STATE_OK then
print("Connection closed")
end
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
print("timeout")
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
processBlock(json.decode(l))
-- Determine Message to send back
memDomain.rom()
local playerName = uRange(0x7BCBF, 0x41)
playerName[0] = nil
local retTable = {}
retTable["playerName"] = playerName
if StateOKForMainLoop() then
retTable["locations"] = generateLocationChecked()
end
msg = json.encode(retTable).."\n"
local ret, error = ff1Socket:send(msg)
if ret == nil then
print(error)
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
curstate = STATE_TENTATIVELY_CONNECTED
elseif curstate == STATE_TENTATIVELY_CONNECTED then
print("Connected!")
itemMessages["(0,0)"] = {TTL=240, message="Connected", color="green"}
curstate = STATE_OK
end
end
function main()
if not checkBizHawkVersion() then
return
end
server, error = socket.bind('localhost', 52980)
while true do
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
frame = frame + 1
drawMessages()
if not (curstate == prevstate) then
-- console.log("Current state: "..curstate)
prevstate = curstate
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Blue")
receive()
else
gui.drawEllipse(248, 9, 6, 6, "Black", "Green")
end
elseif (curstate == STATE_UNINITIALIZED) then
gui.drawEllipse(248, 9, 6, 6, "Black", "White")
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
drawText(5, 8, "Waiting for client", 0xFFFF0000)
drawText(5, 32, "Please start FF1Client.exe", 0xFFFF0000)
-- Advance so the messages are drawn
emu.frameadvance()
server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
-- print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE
ff1Socket = client
ff1Socket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

View File

@@ -477,7 +477,7 @@ function main()
elseif (curstate == STATE_UNINITIALIZED) then elseif (curstate == STATE_UNINITIALIZED) then
-- If we're uninitialized, attempt to make the connection. -- If we're uninitialized, attempt to make the connection.
if (frame % 120 == 0) then if (frame % 120 == 0) then
server:settimeout(120) server:settimeout(2)
local client, timeout = server:accept() local client, timeout = server:accept()
if timeout == nil then if timeout == nil then
print('Initial Connection Made') print('Initial Connection Made')

BIN
data/mcicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -51,9 +51,10 @@ requires:
{%- for option_key, option in group_options.items() %} {%- for option_key, option in group_options.items() %}
{{ option_key }}: {{ option_key }}:
{%- if option.__doc__ %} {%- if option.__doc__ %}
# {{ cleandoc(option.__doc__) # {{ option.__doc__
| trim | trim
| replace('\n', '\n# ') | replace('\n\n', '\n \n')
| replace('\n ', '\n# ')
| indent(4, first=False) | indent(4, first=False)
}} }}
{%- endif -%} {%- endif -%}

View File

@@ -48,6 +48,9 @@
# Civilization VI # Civilization VI
/worlds/civ6/ @hesto2 /worlds/civ6/ @hesto2
# Clique
/worlds/clique/ @ThePhar
# Dark Souls III # Dark Souls III
/worlds/dark_souls_3/ @Marechal-L @nex3 /worlds/dark_souls_3/ @Marechal-L @nex3
@@ -84,9 +87,6 @@
# Inscryption # Inscryption
/worlds/inscryption/ @DrBibop @Glowbuzz /worlds/inscryption/ @DrBibop @Glowbuzz
# Jak and Daxter: The Precursor Legacy
/worlds/jakanddaxter/ @massimilianodelliubaldini
# Kirby's Dream Land 3 # Kirby's Dream Land 3
/worlds/kdl3/ @Silvris /worlds/kdl3/ @Silvris
@@ -118,6 +118,9 @@
# The Messenger # The Messenger
/worlds/messenger/ @alwaysintreble /worlds/messenger/ @alwaysintreble
# Minecraft
/worlds/minecraft/ @KonoTyran @espeon65536
# Mega Man 2 # Mega Man 2
/worlds/mm2/ @Silvris /worlds/mm2/ @Silvris
@@ -145,15 +148,15 @@
# Raft # Raft
/worlds/raft/ @SunnyBat /worlds/raft/ @SunnyBat
# Rogue Legacy
/worlds/rogue_legacy/ @ThePhar
# Risk of Rain 2 # Risk of Rain 2
/worlds/ror2/ @kindasneaki /worlds/ror2/ @kindasneaki
# Saving Princess # Saving Princess
/worlds/saving_princess/ @LeonarthCG /worlds/saving_princess/ @LeonarthCG
# shapez
/worlds/shapez/ @BlastSlimey
# Shivers # Shivers
/worlds/shivers/ @GodlFire @korydondzila /worlds/shivers/ @GodlFire @korydondzila
@@ -172,9 +175,6 @@
# Super Mario 64 # Super Mario 64
/worlds/sm64ex/ @N00byKing /worlds/sm64ex/ @N00byKing
# Super Mario Land 2: 6 Golden Coins
/worlds/marioland2/ @Alchav
# Super Mario World # Super Mario World
/worlds/smw/ @PoryGone /worlds/smw/ @PoryGone
@@ -232,7 +232,7 @@
## Active Unmaintained Worlds ## Active Unmaintained Worlds
# The following worlds in this repo are currently unmaintained, but currently still work in core. If any update breaks # The following worlds in this repo are currently unmaintained, but currently still work in core. If any update breaks
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for # compatibility, these worlds may be moved to `worlds_disabled`. If you are interested in stepping up as maintainer for
# any of these worlds, please review `/docs/world maintainer.md` documentation. # any of these worlds, please review `/docs/world maintainer.md` documentation.
# Final Fantasy (1) # Final Fantasy (1)
@@ -241,6 +241,15 @@
# Ocarina of Time # Ocarina of Time
# /worlds/oot/ # /worlds/oot/
## Disabled Unmaintained Worlds
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are
# interested in stepping up as maintainer for any of these worlds, please review `/docs/world maintainer.md`
# documentation.
# Ori and the Blind Forest
# /worlds_disabled/oribf/
################### ###################
## Documentation ## ## Documentation ##
################### ###################

View File

@@ -122,21 +122,3 @@ Concrete examples of soft logic include:
- Buying expensive shop items might logically require access to a place where you can quickly farm money, or logically require access to enough parts of the game that checking other locations should naturally generate enough money without grinding. - Buying expensive shop items might logically require access to a place where you can quickly farm money, or logically require access to enough parts of the game that checking other locations should naturally generate enough money without grinding.
Remember that all items referenced by logic (however hard or soft) must be `progression`. Since you typically don't want to turn a ton of `filler` items into `progression` just for this, it's common to e.g. write money logic using only the rare "$100" item, so the dozens of "$1" and "$10" items in your world can remain `filler`. Remember that all items referenced by logic (however hard or soft) must be `progression`. Since you typically don't want to turn a ton of `filler` items into `progression` just for this, it's common to e.g. write money logic using only the rare "$100" item, so the dozens of "$1" and "$10" items in your world can remain `filler`.
---
### What if my game has "missable" or "one-time-only" locations or region connections?
Archipelago logic assumes that once a region or location becomes reachable, it stays reachable forever, no matter what
the player does in-game. Slightly more formally: Receiving an AP item must never cause a region connection or location
to "go out of logic" (become unreachable when it was previously reachable), and receiving AP items is the only kind of
state change that AP logic acknowledges. No other actions or events can change reachability.
So when the game itself does not follow this assumption, the options are:
- Modify the game to make that location/connection repeatable
- If there are both missable and repeatable ways to check the location/traverse the connection, then write logic for
only the repeatable ways
- Don't generate the missable location/connection at all
- For connections, any logical regions will still need to be reachable through other, *repeatable* connections
- For locations, this may require game changes to remove the vanilla item if it affects logic
- Decide that resetting the save file is part of the game's logic, and warn players about that

View File

@@ -117,6 +117,12 @@ flowchart LR
%% Java Based Games %% Java Based Games
subgraph Java subgraph Java
JM[Mod with Archipelago.MultiClient.Java] JM[Mod with Archipelago.MultiClient.Java]
subgraph Minecraft
MCS[Minecraft Forge Server]
JMC[Any Java Minecraft Clients]
MCS <-- TCP --> JMC
end
JM <-- Forge Mod Loader --> MCS
end end
AS <-- WebSockets --> JM AS <-- WebSockets --> JM
@@ -125,8 +131,10 @@ flowchart LR
NM[Mod with Archipelago.MultiClient.Net] NM[Mod with Archipelago.MultiClient.Net]
subgraph FNA/XNA subgraph FNA/XNA
TS[Timespinner] TS[Timespinner]
RL[Rogue Legacy]
end end
NM <-- TsRandomizer --> TS NM <-- TsRandomizer --> TS
NM <-- RogueLegacyRandomizer --> RL
subgraph Unity subgraph Unity
ROR[Risk of Rain 2] ROR[Risk of Rain 2]
SN[Subnautica] SN[Subnautica]
@@ -175,4 +183,4 @@ flowchart LR
FMOD <--> FMAPI FMOD <--> FMAPI
end end
CC <-- Integrated --> FC CC <-- Integrated --> FC
``` ```

View File

@@ -231,11 +231,11 @@ Sent to clients after a client requested this message be sent to them, more info
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for. Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
#### Arguments #### Arguments
| Name | Type | Notes | | Name | Type | Notes |
| ---- |-------------| ----- | | ---- | ---- | ----- |
| type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. | | type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. |
| original_cmd | str \| None | The `cmd` argument of the faulty packet, will be `None` if the `cmd` failed to be parsed. | | original_cmd | Optional[str] | The `cmd` argument of the faulty packet, will be `None` if the `cmd` failed to be parsed. |
| text | str | A descriptive message of the problem at hand. | | text | str | A descriptive message of the problem at hand. |
##### PacketProblemType ##### PacketProblemType
`PacketProblemType` indicates the type of problem that was detected in the faulty packet, the known problem types are below but others may be added in the future. `PacketProblemType` indicates the type of problem that was detected in the faulty packet, the known problem types are below but others may be added in the future.
@@ -551,14 +551,14 @@ In JSON this may look like:
Message nodes sent along with [PrintJSON](#PrintJSON) packet to be reconstructed into a legible message. The nodes are intended to be read in the order they are listed in the packet. Message nodes sent along with [PrintJSON](#PrintJSON) packet to be reconstructed into a legible message. The nodes are intended to be read in the order they are listed in the packet.
```python ```python
from typing import TypedDict from typing import TypedDict, Optional
class JSONMessagePart(TypedDict): class JSONMessagePart(TypedDict):
type: str | None type: Optional[str]
text: str | None text: Optional[str]
color: str | None # only available if type is a color color: Optional[str] # only available if type is a color
flags: int | None # only available if type is an item_id or item_name flags: Optional[int] # only available if type is an item_id or item_name
player: int | None # only available if type is either item or location player: Optional[int] # only available if type is either item or location
hint_status: HintStatus | None # only available if type is hint_status hint_status: Optional[HintStatus] # only available if type is hint_status
``` ```
`type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently. `type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently.

View File

@@ -333,7 +333,7 @@ within the world.
### TextChoice ### TextChoice
Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any 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 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 `str | int` so if you need the value at a specified 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.options.my_option.current_key` will always return a string. point, `self.options.my_option.current_key` will always return a string.
### PlandoBosses ### PlandoBosses

View File

@@ -102,16 +102,17 @@ In worlds, this should only be used for the top level to avoid issues when upgra
### Bool ### Bool
Since `bool` can not be subclassed, use the `settings.Bool` helper in a union to get a comment in host.yaml. Since `bool` can not be subclassed, use the `settings.Bool` helper in a `typing.Union` to get a comment in host.yaml.
```python ```python
import settings import settings
import typing
class MySettings(settings.Group): class MySettings(settings.Group):
class MyBool(settings.Bool): class MyBool(settings.Bool):
"""Doc string""" """Doc string"""
my_value: MyBool | bool = True my_value: typing.Union[MyBool, bool] = True
``` ```
### UserFilePath ### UserFilePath
@@ -133,15 +134,15 @@ Checks the file against [md5s](#md5s) by default.
Resolves to an executable (varying file extension based on platform) Resolves to an executable (varying file extension based on platform)
#### description: str | None #### description: Optional\[str\]
Human-readable name to use in file browser Human-readable name to use in file browser
#### copy_to: str | None #### copy_to: Optional\[str\]
Instead of storing the path, copy the file. Instead of storing the path, copy the file.
#### md5s: list[str | bytes] #### md5s: List[Union[str, bytes]]
Provide md5 hashes as hex digests or raw bytes for automatic validation. Provide md5 hashes as hex digests or raw bytes for automatic validation.

View File

@@ -11,13 +11,8 @@ found in the [general test directory](/test/general).
## Defining World Tests ## Defining World Tests
In order to run tests from your world, you will need to create a `test` package within your world package. This can be In order to run tests from your world, you will need to create a `test` package within your world package. This can be
done by creating a `test` directory inside your world with an (empty) `__init__.py` inside it. By convention, a base done by creating a `test` directory with a file named `__init__.py` inside it inside your world. By convention, a base
for your world tests can be created in `bases.py` or any file that does not start with `test`, that you can then import for your world tests can be created in this file that you can then import into other modules.
into other modules. All tests should be defined in files named `test_*.py` (all lower case) and be member functions
(named `test_*`) of classes (named `Test*` or `*Test`) that inherit from `unittest.TestCase` or a test base.
Defining anything inside `test/__init__.py` is deprecated. Defining TestBase there was previously the norm; however,
it complicates test discovery because some worlds also put actual tests into `__init__.py`.
### WorldTestBase ### WorldTestBase
@@ -26,7 +21,7 @@ interactions in the world interact as expected, you will want to use the [WorldT
comes with the basics for test setup as well as a few preloaded tests that most worlds might want to check on varying comes with the basics for test setup as well as a few preloaded tests that most worlds might want to check on varying
options combinations. options combinations.
Example `/worlds/<my_game>/test/bases.py`: Example `/worlds/<my_game>/test/__init__.py`:
```python ```python
from test.bases import WorldTestBase from test.bases import WorldTestBase
@@ -54,7 +49,7 @@ with `test_`.
Example `/worlds/<my_game>/test/test_chest_access.py`: Example `/worlds/<my_game>/test/test_chest_access.py`:
```python ```python
from .bases import MyGameTestBase from . import MyGameTestBase
class TestChestAccess(MyGameTestBase): class TestChestAccess(MyGameTestBase):
@@ -124,12 +119,8 @@ variable to keep all the benefits of the test framework while not running the ma
#### Using Pycharm #### Using Pycharm
In PyCharm, running all tests can be done by right-clicking the root test directory and selecting Run 'Archipelago Unittests'. In PyCharm, running all tests can be done by right-clicking the root test directory and selecting Run 'Archipelago Unittests'.
If you have never previously run ModuleUpdate.py, then you will need to do this once before the tests will run. Unless you configured PyCharm to use pytest as a test runner, you may get import failures. To solve this, edit the run configuration,
You can run ModuleUpdate.py by right-clicking ModuleUpdate.py and selecting `Run 'ModuleUpdate'`. and set the working directory to the Archipelago directory which contains all the project files.
After running ModuleUpdate.py you may still get a `ModuleNotFoundError: No module named 'flask'` for the webhost tests.
If this happens, run WebHost.py by right-clicking it and selecting `Run 'WebHost'`. Make sure to press enter when prompted.
Unless you configured PyCharm to use pytest as a test runner, you may get import failures. To solve this,
edit the run configuration, and set the working directory to the Archipelago directory which contains all the project files.
If you only want to run your world's defined tests, repeat the steps for the test directory within your world. If you only want to run your world's defined tests, repeat the steps for the test directory within your world.
Your working directory should be the directory of your world in the worlds directory and the script should be the Your working directory should be the directory of your world in the worlds directory and the script should be the

View File

@@ -258,6 +258,31 @@ another flag like "progression", it means "an especially useful progression item
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that * `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres
### Events
An Event is a special combination of a Location and an Item, with both having an `id` of `None`. These can be used to
track certain logic interactions, with the Event Item being required for access in other locations or regions, but not
being "real". Since the item and location have no ID, they get dropped at the end of generation and so the server is
never made aware of them and these locations can never be checked, nor can the items be received during play.
They may also be used for making the spoiler log look nicer, i.e. by having a `"Victory"` Event Item, that
is required to finish the game. This makes it very clear when the player finishes, rather than only seeing their last
relevant Item. Events function just like any other Location, and can still have their own access rules, etc.
By convention, the Event "pair" of Location and Item typically have the same name, though this is not a requirement.
They must not exist in the `name_to_id` lookups, as they have no ID.
The most common way to create an Event pair is to create and place the Item on the Location as soon as it's created:
```python
from worlds.AutoWorld import World
from BaseClasses import ItemClassification
from .subclasses import MyGameLocation, MyGameItem
class MyGameWorld(World):
victory_loc = MyGameLocation(self.player, "Victory", None)
victory_loc.place_locked_item(MyGameItem("Victory", ItemClassification.progression, None, self.player))
```
### Regions ### Regions
Regions are logical containers that typically hold locations that share some common access rules. If location logic is Regions are logical containers that typically hold locations that share some common access rules. If location logic is
@@ -266,7 +291,7 @@ like entrance randomization in logic.
Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions. Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions.
There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L310-L311)), There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)),
from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit"). from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit").
### Entrances ### Entrances
@@ -314,63 +339,6 @@ avoiding the need for indirect conditions at the expense of performance.
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
reject the placement of an item there. reject the placement of an item there.
### Events (or "generation-only items/locations")
An event item or location is one that only exists during multiworld generation; the server is never made aware of them.
Event locations can never be checked by the player, and event items cannot be received during play.
Events are used to represent in-game actions (that aren't regular Archipelago locations) when either:
* We want to show in the spoiler log when the player is expected to perform the in-game action.
* It's the cleanest way to represent how that in-game action impacts logic.
Typical examples include completing the goal, defeating a boss, or flipping a switch that affects multiple areas.
To be precise: the term "event" on its own refers to the special combination of an "event item" placed on an "event
location". Event items and locations are created the same way as normal items and locations, except that they have an
`id` of `None`, and an event item must be placed on an event location
(and vice versa). Finally, although events are often described as "fake" items and locations, it's important to
understand that they are perfectly real during generation.
The most common way to create an event is to create the event item and the event location, then immediately call
`Location.place_locked_item()`:
```python
victory_loc = MyGameLocation(self.player, "Defeat the Final Boss", None, final_boss_arena_region)
victory_loc.place_locked_item(MyGameItem("Victory", ItemClassification.progression, None, self.player))
self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
set_rule(victory_loc, lambda state: state.has("Boss Defeating Sword", self.player))
```
Requiring an event to finish the game will make the spoiler log display an additional
`Defeat the Final Boss: Victory` line when the player is expected to finish, rather than only showing their last
relevant item. But events aren't just about the spoiler log; a more substantial example of using events to structure
your logic might be:
```python
water_loc = MyGameLocation(self.player, "Water Level Switch", None, pump_station_region)
water_loc.place_locked_item(MyGameItem("Lowered Water Level", ItemClassification.progression, None, self.player))
pump_station_region.locations.append(water_loc)
set_rule(water_loc, lambda state: state.has("Double Jump", self.player)) # the switch is really high up
...
basement_loc = MyGameLocation(self.player, "Flooded House - Basement Chest", None, flooded_house_region)
flooded_house_region.locations += [upstairs_loc, ground_floor_loc, basement_loc]
...
set_rule(basement_loc, lambda state: state.has("Lowered Water Level", self.player))
```
This creates a "Lowered Water Level" event and a regular location whose access rule depends on that
event being reachable. If you made several more locations the same way, this would ensure all of those locations can
only become reachable when the event location is reachable (i.e. when the water level can be lowered), without
copy-pasting the event location's access rule and then repeatedly re-evaluating it. Also, the spoiler log will show
`Water Level Switch: Lowered Water Level` when the player is expected to do this.
To be clear, this example could also be modeled with a second Region (perhaps "Un-Flooded House"). Or you could modify
the game so flipping that switch checks a regular AP location in addition to lowering the water level.
Events are never required, but it may be cleaner to use an event if e.g. flipping that switch affects the logic in
dozens of half-flooded areas that would all otherwise need additional Regions, and you don't want it to be a regular
location. It depends on the game.
## Implementation ## Implementation
### Your World ### Your World
@@ -520,8 +488,8 @@ In addition, the following methods can be implemented and are called in this ord
If it's hard to separate, this can be done during `generate_early` or `create_items` as well. If it's hard to separate, this can be done during `generate_early` or `create_items` as well.
* `create_items(self)` * `create_items(self)`
called to place player's items into the MultiWorld's itempool. By the end of this step all regions, locations and called to place player's items into the MultiWorld's itempool. By the end of this step all regions, locations and
items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions after items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions
this step. Locations cannot be moved to different regions after this step. This includes event items and locations. after this step. Locations cannot be moved to different regions after this step.
* `set_rules(self)` * `set_rules(self)`
called to set access and item rules on locations and entrances. called to set access and item rules on locations and entrances.
* `connect_entrances(self)` * `connect_entrances(self)`
@@ -533,7 +501,7 @@ In addition, the following methods can be implemented and are called in this ord
called to modify item placement before, during, and after the regular fill process; all finishing before called to modify item placement before, during, and after the regular fill process; all finishing before
`generate_output`. Any items that need to be placed during `pre_fill` should not exist in the itempool, and if there `generate_output`. Any items that need to be placed during `pre_fill` should not exist in the itempool, and if there
are any items that need to be filled this way, but need to be in state while you fill other items, they can be are any items that need to be filled this way, but need to be in state while you fill other items, they can be
returned from `get_pre_fill_items`. returned from `get_prefill_items`.
* `generate_output(self, output_directory: str)` * `generate_output(self, output_directory: str)`
creates the output files if there is output to be generated. When this is called, creates the output files if there is output to be generated. When this is called,
`self.multiworld.get_locations(self.player)` has all locations for the player, with attribute `item` pointing to the `self.multiworld.get_locations(self.player)` has all locations for the player, with attribute `item` pointing to the

View File

@@ -65,5 +65,5 @@ date, voting members and final result in the commit message.
## Handling of Unmaintained Worlds ## Handling of Unmaintained Worlds
As long as worlds are known to work for the most part, they can stay included. Once the world becomes broken, it shall As long as worlds are known to work for the most part, they can stay included. Once a world becomes broken it shall be
be deleted. moved from `worlds/` to `worlds_disabled/`.

View File

@@ -86,7 +86,6 @@ Type: dirifempty; Name: "{app}"
[InstallDelete] [InstallDelete]
Type: files; Name: "{app}\*.exe" Type: files; Name: "{app}\*.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua" Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
Type: files; Name: "{app}\data\lua\connector_ff1.lua"
Type: filesandordirs; Name: "{app}\SNI\lua*" Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*" Type: filesandordirs; Name: "{app}\EnemizerCLI*"
#include "installdelete.iss" #include "installdelete.iss"
@@ -138,6 +137,11 @@ Root: HKCR; Subkey: "{#MyAppName}kdl3patch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}kdl3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}kdl3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata\shell\open\command"; ValueData: """{app}\ArchipelagoMinecraftClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apz5"; ValueData: "{#MyAppName}n64zpf"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: ".apz5"; ValueData: "{#MyAppName}n64zpf"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archipelago Ocarina of Time Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archipelago Ocarina of Time Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: "";

222
kvui.py
View File

@@ -6,6 +6,7 @@ import re
import io import io
import pkgutil import pkgutil
from collections import deque from collections import deque
assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility" assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility"
if sys.platform == "win32": if sys.platform == "win32":
@@ -56,14 +57,10 @@ from kivy.animation import Animation
from kivy.uix.popup import Popup from kivy.uix.popup import Popup
from kivy.uix.image import AsyncImage from kivy.uix.image import AsyncImage
from kivymd.app import MDApp from kivymd.app import MDApp
from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogSupportingText, MDDialogButtonContainer
from kivymd.uix.gridlayout import MDGridLayout from kivymd.uix.gridlayout import MDGridLayout
from kivymd.uix.floatlayout import MDFloatLayout from kivymd.uix.floatlayout import MDFloatLayout
from kivymd.uix.boxlayout import MDBoxLayout from kivymd.uix.boxlayout import MDBoxLayout
from kivymd.uix.navigationbar import MDNavigationBar, MDNavigationItem from kivymd.uix.tab.tab import MDTabsSecondary, MDTabsItem, MDTabsItemText, MDTabsCarousel
from kivymd.uix.screen import MDScreen
from kivymd.uix.screenmanager import MDScreenManager
from kivymd.uix.menu import MDDropdownMenu from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.menu.menu import MDDropdownTextItem from kivymd.uix.menu.menu import MDDropdownTextItem
from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText
@@ -713,94 +710,72 @@ class CommandPromptTextInput(ResizableTextField):
self.text = self._command_history[self._command_history_index] self.text = self._command_history[self._command_history_index]
class MessageBoxLabel(MDLabel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._label.refresh()
class MessageBox(Popup): class MessageBox(Popup):
class MessageBoxLabel(MDLabel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._label.refresh()
def __init__(self, title, text, error=False, **kwargs): def __init__(self, title, text, error=False, **kwargs):
label = MessageBoxLabel(text=text) label = MessageBox.MessageBoxLabel(text=text)
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.] separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40), super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40),
separator_color=separator_color, **kwargs) separator_color=separator_color, **kwargs)
self.height += max(0, label.height - 18) self.height += max(0, label.height - 18)
class MDNavigationItemBase(MDNavigationItem): class ClientTabs(MDTabsSecondary):
text = StringProperty(None) carousel: MDTabsCarousel
lock_swiping = True
def __init__(self, *args, **kwargs):
self.carousel = MDTabsCarousel(lock_swiping=True, anim_move_duration=0.2)
super().__init__(*args, MDDivider(size_hint_y=None, height=dp(1)), self.carousel, **kwargs)
self.size_hint_y = 1
class ButtonsPrompt(MDDialog): def _check_panel_height(self, *args):
def __init__(self, title: str, text: str, response: typing.Callable[[str], None], self.ids.tab_scroll.height = dp(38)
*prompts: str, **kwargs) -> None:
"""
Customizable popup box that lets you create any number of buttons. The text of the pressed button is returned to
the callback.
:param title: The title of the popup. def update_indicator(
:param text: The message prompt in the popup. self, x: float = 0.0, w: float = 0.0, instance: MDTabsItem = None
:param response: A callable that will get called when the user presses a button. The prompt will not close ) -> None:
itself so should be done here if you want to close it when certain buttons are pressed. def update_indicator(*args):
:param prompts: Any number of strings to be used for the buttons. indicator_pos = (0, 0)
""" indicator_size = (0, 0)
layout = MDBoxLayout(orientation="vertical")
label = MessageBoxLabel(text=text)
layout.add_widget(label)
def on_release(button: MDButton, *args) -> None: item_text_object = self._get_tab_item_text_icon_object()
response(button.text)
buttons = [MDDivider()] if item_text_object:
for prompt in prompts: indicator_pos = (
button = MDButton( instance.x + dp(12),
MDButtonText(text=prompt, pos_hint={"center_x": 0.5, "center_y": 0.5}), self.indicator.pos[1]
on_release=on_release, if not self._tabs_carousel
style="text", else self._tabs_carousel.height,
theme_width="Custom", )
size_hint_x=1, indicator_size = (
) instance.width - dp(24),
button.text = prompt self.indicator_height,
buttons.extend([button, MDDivider()]) )
super().__init__( Animation(
MDDialogHeadlineText(text=title), pos=indicator_pos,
MDDialogSupportingText(text=text), size=indicator_size,
MDDialogButtonContainer(*buttons, orientation="vertical"), d=0 if not self.indicator_anim else self.indicator_duration,
**kwargs, t=self.indicator_transition,
) ).start(self.indicator)
if not instance:
class MDScreenManagerBase(MDScreenManager): self.indicator.pos = (x, self.indicator.pos[1])
current_tab: MDNavigationItemBase self.indicator.size = (w, self.indicator_height)
local_screen_names: list[str]
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.local_screen_names = []
def add_widget(self, widget: Widget, *args, **kwargs) -> None:
super().add_widget(widget, *args, **kwargs)
if "index" in kwargs:
self.local_screen_names.insert(kwargs["index"], widget.name)
else: else:
self.local_screen_names.append(widget.name) Clock.schedule_once(update_indicator)
def switch_screens(self, new_tab: MDNavigationItemBase) -> None: def remove_tab(self, tab, content=None):
""" if content is None:
Called whenever the user clicks a tab to switch to a different screen. content = tab.content
self.ids.container.remove_widget(tab)
:param new_tab: The new screen to switch to's tab. self.carousel.remove_widget(content)
""" self.on_size(self, self.size)
name = new_tab.text
if self.local_screen_names.index(name) > self.local_screen_names.index(self.current_screen.name):
self.transition.direction = "left"
else:
self.transition.direction = "right"
self.current = name
self.current_tab = new_tab
class CommandButton(MDButton, MDTooltip): class CommandButton(MDButton, MDTooltip):
@@ -828,9 +803,6 @@ class GameManager(ThemedApp):
main_area_container: MDGridLayout main_area_container: MDGridLayout
""" subclasses can add more columns beside the tabs """ """ subclasses can add more columns beside the tabs """
tabs: MDNavigationBar
screens: MDScreenManagerBase
def __init__(self, ctx: context_type): def __init__(self, ctx: context_type):
self.title = self.base_title self.title = self.base_title
self.ctx = ctx self.ctx = ctx
@@ -860,7 +832,7 @@ class GameManager(ThemedApp):
@property @property
def tab_count(self): def tab_count(self):
if hasattr(self, "tabs"): if hasattr(self, "tabs"):
return max(1, len(self.tabs.children)) return max(1, len(self.tabs.tab_list))
return 1 return 1
def on_start(self): def on_start(self):
@@ -900,32 +872,30 @@ class GameManager(ThemedApp):
self.grid.add_widget(self.progressbar) self.grid.add_widget(self.progressbar)
# middle part # middle part
self.screens = MDScreenManagerBase(pos_hint={"center_x": 0.5}) self.tabs = ClientTabs(pos_hint={"center_x": 0.5, "center_y": 0.5})
self.tabs = MDNavigationBar(orientation="horizontal", size_hint_y=None, height=dp(40), set_bars_color=True) self.tabs.add_widget(MDTabsItem(MDTabsItemText(text="All" if len(self.logging_pairs) > 1 else "Archipelago")))
# bind the method to the bar for back compatibility self.log_panels["All"] = self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name)
self.tabs.remove_tab = self.remove_client_tab for logger_name, name in
self.screens.current_tab = self.add_client_tab( self.logging_pairs))
"All" if len(self.logging_pairs) > 1 else "Archipelago", self.tabs.carousel.add_widget(self.tabs.default_tab_content)
UILog(*(logging.getLogger(logger_name) for logger_name, name in self.logging_pairs)),
)
self.log_panels["All"] = self.screens.current_tab.content
self.screens.current_tab.active = True
for logger_name, display_name in self.logging_pairs: for logger_name, display_name in self.logging_pairs:
bridge_logger = logging.getLogger(logger_name) bridge_logger = logging.getLogger(logger_name)
self.log_panels[display_name] = UILog(bridge_logger) self.log_panels[display_name] = UILog(bridge_logger)
if len(self.logging_pairs) > 1: if len(self.logging_pairs) > 1:
self.add_client_tab(display_name, self.log_panels[display_name]) panel = MDTabsItem(MDTabsItemText(text=display_name))
panel.content = self.log_panels[display_name]
# show Archipelago tab if other logging is present
self.tabs.carousel.add_widget(panel.content)
self.tabs.add_widget(panel)
hint_panel = self.add_client_tab("Hints", HintLayout())
self.hint_log = HintLog(self.json_to_kivy_parser) self.hint_log = HintLog(self.json_to_kivy_parser)
hint_panel = self.add_client_tab("Hints", HintLayout(self.hint_log))
self.log_panels["Hints"] = hint_panel.content self.log_panels["Hints"] = hint_panel.content
hint_panel.content.add_widget(self.hint_log)
self.main_area_container = MDGridLayout(size_hint_y=1, rows=1) self.main_area_container = MDGridLayout(size_hint_y=1, rows=1)
tab_container = MDGridLayout(size_hint_y=1, cols=1) self.main_area_container.add_widget(self.tabs)
tab_container.add_widget(self.tabs)
tab_container.add_widget(self.screens)
self.main_area_container.add_widget(tab_container)
self.grid.add_widget(self.main_area_container) self.grid.add_widget(self.main_area_container)
@@ -962,61 +932,25 @@ class GameManager(ThemedApp):
return self.container return self.container
def add_client_tab(self, title: str, content: Widget, index: int = -1) -> MDNavigationItemBase: def add_client_tab(self, title: str, content: Widget, index: int = -1) -> Widget:
""" """Adds a new tab to the client window with a given title, and provides a given Widget as its content.
Adds a new tab to the client window with a given title, and provides a given Widget as its content. Returns the new tab widget, with the provided content being placed on the tab as content."""
Returns the new tab widget, with the provided content being placed on the tab as content. new_tab = MDTabsItem(MDTabsItemText(text=title))
:param title: The title of the tab.
:param content: The Widget to be added as content for this tab's new MDScreen. Will also be added to the
returned tab as tab.content.
:param index: The index to insert the tab at. Defaults to -1, meaning the tab will be appended to the end.
:return: The new tab.
"""
if self.tabs.children:
self.tabs.add_widget(MDDivider(orientation="vertical"))
new_tab = MDNavigationItemBase(text=title)
new_tab.content = content new_tab.content = content
new_screen = MDScreen(name=title) if -1 < index <= len(self.tabs.carousel.slides):
new_screen.add_widget(content) new_tab.bind(on_release=self.tabs.set_active_item)
if -1 < index <= len(self.tabs.children): new_tab._tabs = self.tabs
remapped_index = len(self.tabs.children) - index self.tabs.ids.container.add_widget(new_tab, index=index)
self.tabs.add_widget(new_tab, index=remapped_index) self.tabs.carousel.add_widget(new_tab.content, index=len(self.tabs.carousel.slides) - index)
self.screens.add_widget(new_screen, index=index)
else: else:
self.tabs.add_widget(new_tab) self.tabs.add_widget(new_tab)
self.screens.add_widget(new_screen) self.tabs.carousel.add_widget(new_tab.content)
return new_tab return new_tab
def remove_client_tab(self, tab: MDNavigationItemBase) -> None:
"""
Called to remove a tab and its screen.
:param tab: The tab to remove.
"""
tab_index = self.tabs.children.index(tab)
# if the tab is currently active we need to swap before removing it
if tab == self.screens.current_tab:
if not tab_index:
# account for the divider
swap_index = tab_index + 2
else:
swap_index = tab_index - 2
self.tabs.children[swap_index].on_release()
# self.screens.switch_screens(self.tabs.children[swap_index])
# get the divider to the left if we can
if not tab_index:
divider_index = tab_index + 1
else:
divider_index = tab_index - 1
self.tabs.remove_widget(self.tabs.children[divider_index])
self.tabs.remove_widget(tab)
self.screens.remove_widget(self.screens.get_screen(tab.text))
def update_texts(self, dt): def update_texts(self, dt):
if hasattr(self.screens.current_tab.content, "fix_heights"): for slide in self.tabs.carousel.slides:
getattr(self.screens.current_tab.content, "fix_heights")() if hasattr(slide, "fix_heights"):
slide.fix_heights() # TODO: remove this when Kivy fixes this upstream
if self.ctx.server: if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \ self.title = self.base_title + " " + Utils.__version__ + \
f" | Connected to: {self.ctx.server_address} " \ f" | Connected to: {self.ctx.server_address} " \

View File

@@ -1,5 +1,5 @@
[pytest] [pytest]
python_files = test_*.py Test*.py __init__.py # TODO: remove Test* once all worlds have been ported python_files = test_*.py Test*.py # TODO: remove Test* once all worlds have been ported
python_classes = Test python_classes = Test
python_functions = test python_functions = test
testpaths = testpaths =

View File

@@ -10,10 +10,9 @@ import sys
import types import types
import typing import typing
import warnings import warnings
from collections.abc import Iterator, Sequence
from enum import IntEnum from enum import IntEnum
from threading import Lock from threading import Lock
from typing import cast, Any, BinaryIO, ClassVar, TextIO, TypeVar, Union from typing import cast, Any, BinaryIO, ClassVar, Dict, Iterator, List, Optional, TextIO, Tuple, Union, TypeVar
__all__ = [ __all__ = [
"get_settings", "fmt_doc", "no_gui", "get_settings", "fmt_doc", "no_gui",
@@ -24,7 +23,7 @@ __all__ = [
no_gui = False no_gui = False
skip_autosave = False skip_autosave = False
_world_settings_name_cache: dict[str, str] = {} # TODO: cache on disk and update when worlds change _world_settings_name_cache: Dict[str, str] = {} # TODO: cache on disk and update when worlds change
_world_settings_name_cache_updated = False _world_settings_name_cache_updated = False
_lock = Lock() _lock = Lock()
@@ -54,7 +53,7 @@ def fmt_doc(cls: type, level: int) -> str:
class Group: class Group:
_type_cache: ClassVar[dict[str, Any] | None] = None _type_cache: ClassVar[Optional[Dict[str, Any]]] = None
_dumping: bool = False _dumping: bool = False
_has_attr: bool = False _has_attr: bool = False
_changed: bool = False _changed: bool = False
@@ -107,7 +106,7 @@ class Group:
self.__dict__.values())) self.__dict__.values()))
@classmethod @classmethod
def get_type_hints(cls) -> dict[str, Any]: def get_type_hints(cls) -> Dict[str, Any]:
"""Returns resolved type hints for the class""" """Returns resolved type hints for the class"""
if cls._type_cache is None: if cls._type_cache is None:
if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str): if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str):
@@ -125,10 +124,10 @@ class Group:
return self[key] return self[key]
return default return default
def items(self) -> list[tuple[str, Any]]: def items(self) -> List[Tuple[str, Any]]:
return [(key, getattr(self, key)) for key in self] return [(key, getattr(self, key)) for key in self]
def update(self, dct: dict[str, Any]) -> None: def update(self, dct: Dict[str, Any]) -> None:
assert isinstance(dct, dict), f"{self.__class__.__name__}.update called with " \ assert isinstance(dct, dict), f"{self.__class__.__name__}.update called with " \
f"{dct.__class__.__name__} instead of dict." f"{dct.__class__.__name__} instead of dict."
@@ -197,7 +196,7 @@ class Group:
warnings.warn(f"{self.__class__.__name__}.{k} " warnings.warn(f"{self.__class__.__name__}.{k} "
f"assigned from incompatible type {type(v).__name__}") f"assigned from incompatible type {type(v).__name__}")
def as_dict(self, *args: str, downcast: bool = True) -> dict[str, Any]: def as_dict(self, *args: str, downcast: bool = True) -> Dict[str, Any]:
return { return {
name: _to_builtin(cast(object, getattr(self, name))) if downcast else getattr(self, name) name: _to_builtin(cast(object, getattr(self, name))) if downcast else getattr(self, name)
for name in self if not args or name in args for name in self if not args or name in args
@@ -212,7 +211,7 @@ class Group:
f.write(f"{indent}{yaml_line}") f.write(f"{indent}{yaml_line}")
@classmethod @classmethod
def _dump_item(cls, name: str | None, attr: object, f: TextIO, level: int) -> None: def _dump_item(cls, name: Optional[str], attr: object, f: TextIO, level: int) -> None:
"""Write a group, dict or sequence item to f, where attr can be a scalar or a collection""" """Write a group, dict or sequence item to f, where attr can be a scalar or a collection"""
# lazy construction of yaml Dumper to avoid loading Utils early # lazy construction of yaml Dumper to avoid loading Utils early
@@ -224,7 +223,7 @@ class Group:
def represent_mapping(self, tag: str, mapping: Any, flow_style: Any = None) -> MappingNode: def represent_mapping(self, tag: str, mapping: Any, flow_style: Any = None) -> MappingNode:
from yaml import ScalarNode from yaml import ScalarNode
res: MappingNode = super().represent_mapping(tag, mapping, flow_style) res: MappingNode = super().represent_mapping(tag, mapping, flow_style)
pairs = cast(list[tuple[ScalarNode, Any]], res.value) pairs = cast(List[Tuple[ScalarNode, Any]], res.value)
for k, v in pairs: for k, v in pairs:
k.style = None # remove quotes from keys k.style = None # remove quotes from keys
return res return res
@@ -330,9 +329,9 @@ class Path(str):
"""Marks the file as required and opens a file browser when missing""" """Marks the file as required and opens a file browser when missing"""
is_exe: bool = False is_exe: bool = False
"""Special cross-platform handling for executables""" """Special cross-platform handling for executables"""
description: str | None = None description: Optional[str] = None
"""Title to display when browsing for the file""" """Title to display when browsing for the file"""
copy_to: str | None = None copy_to: Optional[str] = None
"""If not None, copy to AP folder instead of linking it""" """If not None, copy to AP folder instead of linking it"""
@classmethod @classmethod
@@ -340,7 +339,7 @@ class Path(str):
"""Overload and raise to validate input files from browse""" """Overload and raise to validate input files from browse"""
pass pass
def browse(self: T, **kwargs: Any) -> T | None: def browse(self: T, **kwargs: Any) -> Optional[T]:
"""Opens a file browser to search for the file""" """Opens a file browser to search for the file"""
raise NotImplementedError(f"Please use a subclass of Path for {self.__class__.__name__}") raise NotImplementedError(f"Please use a subclass of Path for {self.__class__.__name__}")
@@ -370,12 +369,12 @@ class _LocalPath(str):
class FilePath(Path): class FilePath(Path):
# path to a file # path to a file
md5s: ClassVar[list[str | bytes]] = [] md5s: ClassVar[List[Union[str, bytes]]] = []
"""MD5 hashes for default validator.""" """MD5 hashes for default validator."""
def browse(self: T, def browse(self: T,
filetypes: Sequence[tuple[str, Sequence[str]]] | None = None, **kwargs: Any)\ filetypes: Optional[typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]] = None, **kwargs: Any)\
-> T | None: -> Optional[T]:
from Utils import open_filename, is_windows from Utils import open_filename, is_windows
if not filetypes: if not filetypes:
if self.is_exe: if self.is_exe:
@@ -440,7 +439,7 @@ class FilePath(Path):
class FolderPath(Path): class FolderPath(Path):
# path to a folder # path to a folder
def browse(self: T, **kwargs: Any) -> T | None: def browse(self: T, **kwargs: Any) -> Optional[T]:
from Utils import open_directory from Utils import open_directory
res = open_directory(f"Select {self.description or self.__class__.__name__}", self) res = open_directory(f"Select {self.description or self.__class__.__name__}", self)
if res: if res:
@@ -598,16 +597,16 @@ class ServerOptions(Group):
OFF = 0 OFF = 0
ON = 1 ON = 1
host: str | None = None host: Optional[str] = None
port: int = 38281 port: int = 38281
password: str | None = None password: Optional[str] = None
multidata: str | None = None multidata: Optional[str] = None
savefile: str | None = None savefile: Optional[str] = None
disable_save: bool = False disable_save: bool = False
loglevel: str = "info" loglevel: str = "info"
logtime: bool = False logtime: bool = False
server_password: ServerPassword | None = None server_password: Optional[ServerPassword] = None
disable_item_cheat: DisableItemCheat | bool = False disable_item_cheat: Union[DisableItemCheat, bool] = False
location_check_points: LocationCheckPoints = LocationCheckPoints(1) location_check_points: LocationCheckPoints = LocationCheckPoints(1)
hint_cost: HintCost = HintCost(10) hint_cost: HintCost = HintCost(10)
release_mode: ReleaseMode = ReleaseMode("auto") release_mode: ReleaseMode = ReleaseMode("auto")
@@ -703,7 +702,7 @@ does nothing if not found
""" """
sni_path: SNIPath = SNIPath("SNI") sni_path: SNIPath = SNIPath("SNI")
snes_rom_start: SnesRomStart | bool = True snes_rom_start: Union[SnesRomStart, bool] = True
class BizHawkClientOptions(Group): class BizHawkClientOptions(Group):
@@ -722,7 +721,7 @@ class BizHawkClientOptions(Group):
""" """
emuhawk_path: EmuHawkPath = EmuHawkPath(None) emuhawk_path: EmuHawkPath = EmuHawkPath(None)
rom_start: RomStart | bool = True rom_start: Union[RomStart, bool] = True
# Top-level group with lazy loading of worlds # Top-level group with lazy loading of worlds
@@ -734,7 +733,7 @@ class Settings(Group):
sni_options: SNIOptions = SNIOptions() sni_options: SNIOptions = SNIOptions()
bizhawkclient_options: BizHawkClientOptions = BizHawkClientOptions() bizhawkclient_options: BizHawkClientOptions = BizHawkClientOptions()
_filename: str | None = None _filename: Optional[str] = None
def __getattribute__(self, key: str) -> Any: def __getattribute__(self, key: str) -> Any:
if key.startswith("_") or key in self.__class__.__dict__: if key.startswith("_") or key in self.__class__.__dict__:
@@ -788,7 +787,7 @@ class Settings(Group):
return super().__getattribute__(key) return super().__getattribute__(key)
def __init__(self, location: str | None): # change to PathLike[str] once we drop 3.8? def __init__(self, location: Optional[str]): # change to PathLike[str] once we drop 3.8?
super().__init__() super().__init__()
if location: if location:
from Utils import parse_yaml from Utils import parse_yaml
@@ -822,7 +821,7 @@ class Settings(Group):
import atexit import atexit
atexit.register(autosave) atexit.register(autosave)
def save(self, location: str | None = None) -> None: # as above def save(self, location: Optional[str] = None) -> None: # as above
from Utils import parse_yaml from Utils import parse_yaml
location = location or self._filename location = location or self._filename
assert location, "No file specified" assert location, "No file specified"
@@ -855,7 +854,7 @@ class Settings(Group):
super().dump(f, level) super().dump(f, level)
@property @property
def filename(self) -> str | None: def filename(self) -> Optional[str]:
return self._filename return self._filename
@@ -868,7 +867,7 @@ def get_settings() -> Settings:
if not res: if not res:
from Utils import user_path, local_path from Utils import user_path, local_path
filenames = ("options.yaml", "host.yaml") filenames = ("options.yaml", "host.yaml")
locations: list[str] = [] locations: List[str] = []
if os.path.join(os.getcwd()) != local_path(): if os.path.join(os.getcwd()) != local_path():
locations += filenames # use files from cwd only if it's not the local_path locations += filenames # use files from cwd only if it's not the local_path
locations += [user_path(filename) for filename in filenames] locations += [user_path(filename) for filename in filenames]

View File

@@ -1,20 +1,22 @@
import base64 import base64
import datetime import datetime
import io
import json
import os import os
import platform import platform
import shutil import shutil
import subprocess
import sys import sys
import sysconfig import sysconfig
import threading
import urllib.request
import warnings import warnings
import zipfile import zipfile
from collections.abc import Iterable, Sequence import urllib.request
import io
import json
import threading
import subprocess
from hashlib import sha3_512 from hashlib import sha3_512
from pathlib import Path from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple, Union
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it # This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
requirement = 'cx-Freeze==8.0.0' requirement = 'cx-Freeze==8.0.0'
@@ -58,11 +60,13 @@ from Cython.Build import cythonize
# On Python < 3.10 LogicMixin is not currently supported. # On Python < 3.10 LogicMixin is not currently supported.
non_apworlds: set[str] = { non_apworlds: Set[str] = {
"A Link to the Past", "A Link to the Past",
"Adventure", "Adventure",
"ArchipIDLE", "ArchipIDLE",
"Archipelago", "Archipelago",
"Clique",
"Final Fantasy",
"Lufia II Ancient Cave", "Lufia II Ancient Cave",
"Meritous", "Meritous",
"Ocarina of Time", "Ocarina of Time",
@@ -143,7 +147,7 @@ def download_SNI() -> None:
print(f"No SNI found for system spec {platform_name} {machine_name}") print(f"No SNI found for system spec {platform_name} {machine_name}")
signtool: str | None signtool: Optional[str]
if os.path.exists("X:/pw.txt"): if os.path.exists("X:/pw.txt"):
print("Using signtool") print("Using signtool")
with open("X:/pw.txt", encoding="utf-8-sig") as f: with open("X:/pw.txt", encoding="utf-8-sig") as f:
@@ -201,7 +205,7 @@ def remove_sprites_from_folder(folder: Path) -> None:
os.remove(folder / file) os.remove(folder / file)
def _threaded_hash(filepath: str | Path) -> str: def _threaded_hash(filepath: Union[str, Path]) -> str:
hasher = sha3_512() hasher = sha3_512()
hasher.update(open(filepath, "rb").read()) hasher.update(open(filepath, "rb").read())
return base64.b85encode(hasher.digest()).decode() return base64.b85encode(hasher.digest()).decode()
@@ -251,7 +255,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
self.libfolder = Path(self.buildfolder, "lib") self.libfolder = Path(self.buildfolder, "lib")
self.library = Path(self.libfolder, "library.zip") self.library = Path(self.libfolder, "library.zip")
def installfile(self, path: Path, subpath: str | Path | None = None, keep_content: bool = False) -> None: def installfile(self, path: Path, subpath: Optional[Union[str, Path]] = None, keep_content: bool = False) -> None:
folder = self.buildfolder folder = self.buildfolder
if subpath: if subpath:
folder /= subpath folder /= subpath
@@ -370,7 +374,11 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
assert not non_apworlds - set(AutoWorldRegister.world_types), \ assert not non_apworlds - set(AutoWorldRegister.world_types), \
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld" f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
folders_to_remove: list[str] = [] folders_to_remove: 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) generate_yaml_templates(self.buildfolder / "Players" / "Templates", False)
for worldname, worldtype in AutoWorldRegister.world_types.items(): for worldname, worldtype in AutoWorldRegister.world_types.items():
if worldname not in non_apworlds: if worldname not in non_apworlds:
@@ -438,12 +446,12 @@ class AppImageCommand(setuptools.Command):
("app-exec=", None, "The application to run inside the image."), ("app-exec=", None, "The application to run inside the image."),
("yes", "y", 'Answer "yes" to all questions.'), ("yes", "y", 'Answer "yes" to all questions.'),
] ]
build_folder: Path | None build_folder: Optional[Path]
dist_file: Path | None dist_file: Optional[Path]
app_dir: Path | None app_dir: Optional[Path]
app_name: str app_name: str
app_exec: Path | None app_exec: Optional[Path]
app_icon: Path | None # source file app_icon: Optional[Path] # source file
app_id: str # lower case name, used for icon and .desktop app_id: str # lower case name, used for icon and .desktop
yes: bool yes: bool
@@ -480,12 +488,12 @@ tmp="${{exe#*/}}"
if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then
exe="{default_exe.parent}/$exe" exe="{default_exe.parent}/$exe"
fi fi
export LD_LIBRARY_PATH="${{LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}}$APPDIR/{default_exe.parent}/lib" export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$APPDIR/{default_exe.parent}/lib"
$APPDIR/$exe "$@" $APPDIR/$exe "$@"
""") """)
launcher_filename.chmod(0o755) launcher_filename.chmod(0o755)
def install_icon(self, src: Path, name: str | None = None, symlink: Path | None = None) -> None: def install_icon(self, src: Path, name: Optional[str] = None, symlink: Optional[Path] = None) -> None:
assert self.app_dir, "Invalid app_dir" assert self.app_dir, "Invalid app_dir"
try: try:
from PIL import Image from PIL import Image
@@ -548,7 +556,7 @@ $APPDIR/$exe "$@"
subprocess.call(f'ARCH={build_arch} ./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True) subprocess.call(f'ARCH={build_arch} ./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True)
def find_libs(*args: str) -> Sequence[tuple[str, str]]: def find_libs(*args: str) -> Sequence[Tuple[str, str]]:
"""Try to find system libraries to be included.""" """Try to find system libraries to be included."""
if not args: if not args:
return [] return []
@@ -556,7 +564,7 @@ def find_libs(*args: str) -> Sequence[tuple[str, str]]:
arch = build_arch.replace('_', '-') arch = build_arch.replace('_', '-')
libc = 'libc6' # we currently don't support musl libc = 'libc6' # we currently don't support musl
def parse(line: str) -> tuple[tuple[str, str, str], str]: def parse(line: str) -> Tuple[Tuple[str, str, str], str]:
lib, path = line.strip().split(' => ') lib, path = line.strip().split(' => ')
lib, typ = lib.split(' ', 1) lib, typ = lib.split(' ', 1)
for test_arch in ('x86-64', 'i386', 'aarch64'): for test_arch in ('x86-64', 'i386', 'aarch64'):
@@ -581,8 +589,8 @@ def find_libs(*args: str) -> Sequence[tuple[str, str]]:
k: v for k, v in (parse(line) for line in data if "=>" in line) k: v for k, v in (parse(line) for line in data if "=>" in line)
} }
def find_lib(lib: str, arch: str, libc: str) -> str | None: def find_lib(lib: str, arch: str, libc: str) -> Optional[str]:
cache: dict[tuple[str, str, str], str] = getattr(find_libs, "cache") cache: Dict[Tuple[str, str, str], str] = getattr(find_libs, "cache")
for k, v in cache.items(): for k, v in cache.items():
if k == (lib, arch, libc): if k == (lib, arch, libc):
return v return v
@@ -591,7 +599,7 @@ def find_libs(*args: str) -> Sequence[tuple[str, str]]:
return v return v
return None return None
res: list[tuple[str, str]] = [] res: List[Tuple[str, str]] = []
for arg in args: for arg in args:
# try exact match, empty libc, empty arch, empty arch and libc # try exact match, empty libc, empty arch, empty arch and libc
file = find_lib(arg, arch, libc) file = find_lib(arg, arch, libc)

View File

@@ -159,6 +159,7 @@ class WorldTestBase(unittest.TestCase):
self.multiworld.game[self.player] = self.game self.multiworld.game[self.player] = self.game
self.multiworld.player_name = {self.player: "Tester"} self.multiworld.player_name = {self.player: "Tester"}
self.multiworld.set_seed(seed) self.multiworld.set_seed(seed)
self.multiworld.state = CollectionState(self.multiworld)
random.seed(self.multiworld.seed) random.seed(self.multiworld.seed)
self.multiworld.seed_name = get_seed_name(random) # only called to get same RNG progression as Generate.py self.multiworld.seed_name = get_seed_name(random) # only called to get same RNG progression as Generate.py
args = Namespace() args = Namespace()
@@ -167,7 +168,6 @@ class WorldTestBase(unittest.TestCase):
1: option.from_any(self.options.get(name, option.default)) 1: option.from_any(self.options.get(name, option.default))
}) })
self.multiworld.set_options(args) self.multiworld.set_options(args)
self.multiworld.state = CollectionState(self.multiworld)
self.world = self.multiworld.worlds[self.player] self.world = self.multiworld.worlds[self.player]
for step in gen_steps: for step in gen_steps:
call_all(self.multiworld, step) call_all(self.multiworld, step)

View File

@@ -59,13 +59,13 @@ def run_locations_benchmark():
multiworld.game[1] = game multiworld.game[1] = game
multiworld.player_name = {1: "Tester"} multiworld.player_name = {1: "Tester"}
multiworld.set_seed(0) multiworld.set_seed(0)
multiworld.state = CollectionState(multiworld)
args = argparse.Namespace() args = argparse.Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types[game].options_dataclass.type_hints.items(): for name, option in AutoWorld.AutoWorldRegister.world_types[game].options_dataclass.type_hints.items():
setattr(args, name, { setattr(args, name, {
1: option.from_any(getattr(option, "default")) 1: option.from_any(getattr(option, "default"))
}) })
multiworld.set_options(args) multiworld.set_options(args)
multiworld.state = CollectionState(multiworld)
gc.collect() gc.collect()
for step in self.gen_steps: for step in self.gen_steps:

View File

@@ -49,6 +49,7 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)} multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)}
multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids} multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
multiworld.set_seed(seed) multiworld.set_seed(seed)
multiworld.state = CollectionState(multiworld)
args = Namespace() args = Namespace()
for player, world_type in enumerate(worlds, 1): for player, world_type in enumerate(worlds, 1):
for key, option in world_type.options_dataclass.type_hints.items(): for key, option in world_type.options_dataclass.type_hints.items():
@@ -56,7 +57,6 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
updated_options[player] = option.from_any(option.default) updated_options[player] = option.from_any(option.default)
setattr(args, key, updated_options) setattr(args, key, updated_options)
multiworld.set_options(args) multiworld.set_options(args)
multiworld.state = CollectionState(multiworld)
for step in steps: for step in steps:
call_all(multiworld, step) call_all(multiworld, step)
return multiworld return multiworld

View File

@@ -1,7 +1,7 @@
import unittest import unittest
from Fill import distribute_items_restrictive from Fill import distribute_items_restrictive
from NetUtils import convert_to_base_types from NetUtils import encode
from worlds.AutoWorld import AutoWorldRegister, call_all from worlds.AutoWorld import AutoWorldRegister, call_all
from worlds import failed_world_loads from worlds import failed_world_loads
from . import setup_solo_multiworld from . import setup_solo_multiworld
@@ -47,28 +47,12 @@ class TestImplemented(unittest.TestCase):
call_all(multiworld, "post_fill") call_all(multiworld, "post_fill")
for key, data in multiworld.worlds[1].fill_slot_data().items(): for key, data in multiworld.worlds[1].fill_slot_data().items():
self.assertIsInstance(key, str, "keys in slot data must be a string") self.assertIsInstance(key, str, "keys in slot data must be a string")
convert_to_base_types(data) # only put base data types into slot data self.assertIsInstance(encode(data), str, f"object {type(data).__name__} not serializable.")
def test_no_failed_world_loads(self): def test_no_failed_world_loads(self):
if failed_world_loads: if failed_world_loads:
self.fail(f"The following worlds failed to load: {failed_world_loads}") self.fail(f"The following worlds failed to load: {failed_world_loads}")
def test_prefill_items(self):
"""Test that every world can reach every location from allstate before pre_fill."""
for gamename, world_type in AutoWorldRegister.world_types.items():
if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"):
with self.subTest(gamename):
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
"set_rules", "connect_entrances", "generate_basic"))
allstate = multiworld.get_all_state(False)
locations = multiworld.get_locations()
reachable = multiworld.get_reachable_locations(allstate)
unreachable = [location for location in locations if location not in reachable]
self.assertTrue(not unreachable,
f"Locations were not reachable with all state before prefill: "
f"{unreachable}. Seed: {multiworld.seed}")
def test_explicit_indirect_conditions_spheres(self): def test_explicit_indirect_conditions_spheres(self):
"""Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit """Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit
indirect conditions""" indirect conditions"""

View File

@@ -26,4 +26,4 @@ class TestBase(unittest.TestCase):
for step in self.test_steps: for step in self.test_steps:
with self.subTest("Step", step=step): with self.subTest("Step", step=step):
call_all(multiworld, step) call_all(multiworld, step)
self.assertTrue(multiworld.get_all_state(False, allow_partial_entrances=True)) self.assertTrue(multiworld.get_all_state(False, True))

View File

@@ -63,12 +63,12 @@ if __name__ == "__main__":
spacer = '=' * 80 spacer = '=' * 80
with TemporaryDirectory() as tempdir: with TemporaryDirectory() as tempdir:
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]] multis = [["Clique"], ["Temp World"], ["Clique", "Temp World"]]
p1_games = [] p1_games = []
data_paths = [] data_paths = []
rooms = [] rooms = []
copy_world("VVVVVV", "Temp World") copy_world("Clique", "Temp World")
try: try:
for n, games in enumerate(multis, 1): for n, games in enumerate(multis, 1):
print(f"Generating [{n}] {', '.join(games)}") print(f"Generating [{n}] {', '.join(games)}")
@@ -101,7 +101,7 @@ if __name__ == "__main__":
with Client(host.address, game, "Player1") as client: with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations) local_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration if collected_items < 2: # Clique only has 2 Locations
client.collect_any() client.collect_any()
# TODO: Ctrl+C test here as well # TODO: Ctrl+C test here as well
@@ -125,7 +125,7 @@ if __name__ == "__main__":
with Client(host.address, game, "Player1") as client: with Client(host.address, game, "Player1") as client:
web_data_packages = client.games_packages web_data_packages = client.games_packages
web_collected_items = len(client.checked_locations) web_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration if collected_items < 2: # Clique only has 2 Locations
client.collect_any() client.collect_any()
if collected_items == 1: if collected_items == 1:
sleep(1) # wait for the server to collect the item sleep(1) # wait for the server to collect the item

View File

@@ -34,7 +34,7 @@ def _generate_local_inner(games: Iterable[str],
f.write(json.dumps({ f.write(json.dumps({
"name": f"Player{n}", "name": f"Player{n}",
"game": game, "game": game,
game: {}, game: {"hard_mode": "true"},
"description": f"generate_local slot {n} ('Player{n}'): {game}", "description": f"generate_local slot {n} ('Player{n}'): {game}",
})) }))

View File

@@ -1,12 +1,13 @@
import re import re
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Dict
__all__ = ["copy", "delete"] __all__ = ["copy", "delete"]
_new_worlds: dict[str, str] = {} _new_worlds: Dict[str, str] = {}
def copy(src: str, dst: str) -> None: def copy(src: str, dst: str) -> None:
@@ -30,7 +31,7 @@ def copy(src: str, dst: str) -> None:
_new_worlds[dst] = str(dst_folder) _new_worlds[dst] = str(dst_folder)
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f: with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
contents = f.read() contents = f.read()
contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents) contents = re.sub(r'game\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f: with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
f.write(contents) f.write(contents)

View File

@@ -382,7 +382,7 @@ class World(metaclass=AutoWorldRegister):
def create_items(self) -> None: def create_items(self) -> None:
""" """
Method for creating and submitting items to the itempool. Items and Regions must *not* be created and submitted Method for creating and submitting items to the itempool. Items and Regions must *not* be created and submitted
to the MultiWorld after this step. If items need to be placed during pre_fill use `get_pre_fill_items`. to the MultiWorld after this step. If items need to be placed during pre_fill use `get_prefill_items`.
""" """
pass pass
@@ -528,7 +528,7 @@ class World(metaclass=AutoWorldRegister):
"""Called when an item is collected in to state. Useful for things such as progressive items or currency.""" """Called when an item is collected in to state. Useful for things such as progressive items or currency."""
name = self.collect_item(state, item) name = self.collect_item(state, item)
if name: if name:
state.add_item(name, self.player) state.prog_items[self.player][name] += 1
return True return True
return False return False
@@ -536,7 +536,9 @@ class World(metaclass=AutoWorldRegister):
"""Called when an item is removed from to state. Useful for things such as progressive items or currency.""" """Called when an item is removed from to state. Useful for things such as progressive items or currency."""
name = self.collect_item(state, item, True) name = self.collect_item(state, item, True)
if name: if name:
state.remove_item(name, self.player) state.prog_items[self.player][name] -= 1
if state.prog_items[self.player][name] < 1:
del (state.prog_items[self.player][name])
return True return True
return False return False

View File

@@ -6,7 +6,6 @@ import zipfile
from enum import IntEnum from enum import IntEnum
import os import os
import threading import threading
from io import BytesIO
from typing import ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence from typing import ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence
@@ -71,18 +70,6 @@ class AutoPatchExtensionRegister(abc.ABCMeta):
container_version: int = 6 container_version: int = 6
def is_ap_player_container(game: str, data: bytes, player: int):
if not zipfile.is_zipfile(BytesIO(data)):
return False
with zipfile.ZipFile(BytesIO(data), mode='r') as zf:
if "archipelago.json" in zf.namelist():
manifest = json.loads(zf.read("archipelago.json"))
if "game" in manifest and "player" in manifest:
if game == manifest["game"] and player == manifest["player"]:
return True
return False
class InvalidDataError(Exception): class InvalidDataError(Exception):
""" """
Since games can override `read_contents` in APContainer, Since games can override `read_contents` in APContainer,
@@ -91,15 +78,24 @@ class InvalidDataError(Exception):
class APContainer: class APContainer:
"""A zipfile containing at least archipelago.json, which contains a manifest json payload.""" """A zipfile containing at least archipelago.json"""
version: ClassVar[int] = container_version version: int = container_version
compression_level: ClassVar[int] = 9 compression_level: int = 9
compression_method: ClassVar[int] = zipfile.ZIP_DEFLATED compression_method: int = zipfile.ZIP_DEFLATED
game: Optional[str] = None
# instance attributes:
path: Optional[str] path: Optional[str]
player: Optional[int]
player_name: str
server: str
def __init__(self, path: Optional[str] = None): def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
player_name: str = "", server: str = ""):
self.path = path self.path = path
self.player = player
self.player_name = player_name
self.server = server
def write(self, file: Optional[Union[str, BinaryIO]] = None) -> None: def write(self, file: Optional[Union[str, BinaryIO]] = None) -> None:
zip_file = file if file else self.path zip_file = file if file else self.path
@@ -139,60 +135,31 @@ class APContainer:
message = f"{arg0} - " message = f"{arg0} - "
raise InvalidDataError(f"{message}This might be the incorrect world version for this file") from e raise InvalidDataError(f"{message}This might be the incorrect world version for this file") from e
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]: def read_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
with opened_zipfile.open("archipelago.json", "r") as f: with opened_zipfile.open("archipelago.json", "r") as f:
manifest = json.load(f) manifest = json.load(f)
if manifest["compatible_version"] > self.version: if manifest["compatible_version"] > self.version:
raise Exception(f"File (version: {manifest['compatible_version']}) too new " raise Exception(f"File (version: {manifest['compatible_version']}) too new "
f"for this handler (version: {self.version})") f"for this handler (version: {self.version})")
return manifest self.player = manifest["player"]
self.server = manifest["server"]
self.player_name = manifest["player_name"]
def get_manifest(self) -> Dict[str, Any]: def get_manifest(self) -> Dict[str, Any]:
return { return {
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
"player": self.player,
"player_name": self.player_name,
"game": self.game,
# minimum version of patch system expected for patching to be successful # minimum version of patch system expected for patching to be successful
"compatible_version": 5, "compatible_version": 5,
"version": container_version, "version": container_version,
} }
class APPlayerContainer(APContainer): class APPatch(APContainer):
"""A zipfile containing at least archipelago.json meant for a player"""
game: ClassVar[Optional[str]] = None
patch_file_ending: str = ""
player: Optional[int]
player_name: str
server: str
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
player_name: str = "", server: str = ""):
super().__init__(path)
self.player = player
self.player_name = player_name
self.server = server
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
manifest = super().read_contents(opened_zipfile)
self.player = manifest["player"]
self.server = manifest["server"]
self.player_name = manifest["player_name"]
return manifest
def get_manifest(self) -> Dict[str, Any]:
manifest = super().get_manifest()
manifest.update({
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
"player": self.player,
"player_name": self.player_name,
"game": self.game,
"patch_file_ending": self.patch_file_ending,
})
return manifest
class APPatch(APPlayerContainer):
""" """
An `APPlayerContainer` that represents a patch file. An `APContainer` that represents a patch file.
It includes the `procedure` key in the manifest to indicate that it is a patch. It includes the `procedure` key in the manifest to indicate that it is a patch.
Your implementation should inherit from this if your output file Your implementation should inherit from this if your output file
@@ -225,6 +192,7 @@ class APProcedurePatch(APAutoPatchInterface):
""" """
hash: Optional[str] # base checksum of source file hash: Optional[str] # base checksum of source file
source_data: bytes source_data: bytes
patch_file_ending: str = ""
files: Dict[str, bytes] files: Dict[str, bytes]
@classmethod @classmethod
@@ -246,6 +214,7 @@ class APProcedurePatch(APAutoPatchInterface):
manifest = super(APProcedurePatch, self).get_manifest() manifest = super(APProcedurePatch, self).get_manifest()
manifest["base_checksum"] = self.hash manifest["base_checksum"] = self.hash
manifest["result_file_ending"] = self.result_file_ending manifest["result_file_ending"] = self.result_file_ending
manifest["patch_file_ending"] = self.patch_file_ending
manifest["procedure"] = self.procedure manifest["procedure"] = self.procedure
if self.procedure == APDeltaPatch.procedure: if self.procedure == APDeltaPatch.procedure:
manifest["compatible_version"] = 5 manifest["compatible_version"] = 5

View File

@@ -210,27 +210,30 @@ components: List[Component] = [
Component('Launcher', 'Launcher', component_type=Type.HIDDEN), Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
# Core # Core
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True, Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
file_identifier=SuffixIdentifier('.archipelago', '.zip'), file_identifier=SuffixIdentifier('.archipelago', '.zip')),
description="Host a generated multiworld on your computer."), Component('Generate', 'Generate', cli=True),
Component('Generate', 'Generate', cli=True, Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld")),
description="Generate a multiworld with the YAMLs in the players folder."), Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient),
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld"),
description="Install an APWorld to play games not included with Archipelago by default."),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
description="Connect to a multiworld using the text client."),
Component('Links Awakening DX Client', 'LinksAwakeningClient', Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')), file_identifier=SuffixIdentifier('.apladx')),
Component('LttP Adjuster', 'LttPAdjuster'), Component('LttP Adjuster', 'LttPAdjuster'),
# Minecraft
Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True,
file_identifier=SuffixIdentifier('.apmc')),
# Ocarina of Time # Ocarina of Time
Component('OoT Client', 'OoTClient', Component('OoT Client', 'OoTClient',
file_identifier=SuffixIdentifier('.apz5')), file_identifier=SuffixIdentifier('.apz5')),
Component('OoT Adjuster', 'OoTAdjuster'), Component('OoT Adjuster', 'OoTAdjuster'),
# FF1
Component('FF1 Client', 'FF1Client'),
# TLoZ # TLoZ
Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')), Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')),
# ChecksFinder # ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'), Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2 # Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'), Component('Starcraft 2 Client', 'Starcraft2Client'),
# Wargroove
Component('Wargroove Client', 'WargrooveClient'),
# Zillion # Zillion
Component('Zillion Client', 'ZillionClient', Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')), file_identifier=SuffixIdentifier('.apzl')),
@@ -243,5 +246,6 @@ components: List[Component] = [
# if registering an icon from within an apworld, the format "ap:module.name/path/to/file.png" can be used # if registering an icon from within an apworld, the format "ap:module.name/path/to/file.png" can be used
icon_paths = { icon_paths = {
'icon': local_path('data', 'icon.png'), 'icon': local_path('data', 'icon.png'),
'mcicon': local_path('data', 'mcicon.png'),
'discord': local_path('data', 'discord-mark-blue.png'), 'discord': local_path('data', 'discord-mark-blue.png'),
} }

View File

@@ -19,8 +19,7 @@ def launch_client(*args) -> None:
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client, component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
file_identifier=SuffixIdentifier(), file_identifier=SuffixIdentifier())
description="Open the BizHawk client, to play games using the Bizhawk emulator.")
components.append(component) components.append(component)

View File

@@ -182,11 +182,10 @@ class AdventureDeltaPatch(APPatch, metaclass=AutoPatchRegister):
json.dumps(self.rom_deltas), json.dumps(self.rom_deltas),
compress_type=zipfile.ZIP_LZMA) compress_type=zipfile.ZIP_LZMA)
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> dict[str, Any]: def read_contents(self, opened_zipfile: zipfile.ZipFile):
manifest = super(AdventureDeltaPatch, self).read_contents(opened_zipfile) super(AdventureDeltaPatch, self).read_contents(opened_zipfile)
self.foreign_items = AdventureDeltaPatch.read_foreign_items(opened_zipfile) self.foreign_items = AdventureDeltaPatch.read_foreign_items(opened_zipfile)
self.autocollect_items = AdventureDeltaPatch.read_autocollect_items(opened_zipfile) self.autocollect_items = AdventureDeltaPatch.read_autocollect_items(opened_zipfile)
return manifest
@classmethod @classmethod
def get_source_data(cls) -> bytes: def get_source_data(cls) -> bytes:

View File

@@ -238,10 +238,10 @@ async def proxy_loop(ctx: AHITContext):
logger.info("Aborting AHIT Proxy Client due to errors") logger.info("Aborting AHIT Proxy Client due to errors")
def launch(*launch_args: str): def launch():
async def main(): async def main():
parser = get_base_parser() parser = get_base_parser()
args = parser.parse_args(launch_args) args = parser.parse_args()
ctx = AHITContext(args.connect, args.password) ctx = AHITContext(args.connect, args.password)
logger.info("Starting A Hat in Time proxy server") logger.info("Starting A Hat in Time proxy server")

View File

@@ -477,7 +477,7 @@ act_completions = {
"Act Completion (Rush Hour)": LocData(2000311210, "Rush Hour", "Act Completion (Rush Hour)": LocData(2000311210, "Rush Hour",
dlc_flags=HatDLC.dlc2, dlc_flags=HatDLC.dlc2,
hookshot=True, hookshot=True,
required_hats=[HatType.ICE, HatType.BREWING, HatType.DWELLER]), required_hats=[HatType.ICE, HatType.BREWING]),
"Act Completion (Time Rift - Rumbi Factory)": LocData(2000312736, "Time Rift - Rumbi Factory", "Act Completion (Time Rift - Rumbi Factory)": LocData(2000312736, "Time Rift - Rumbi Factory",
dlc_flags=HatDLC.dlc2), dlc_flags=HatDLC.dlc2),

View File

@@ -455,7 +455,7 @@ def set_moderate_rules(world: "HatInTimeWorld"):
if "Pink Paw Station Thug" in key and is_location_valid(world, key): if "Pink Paw Station Thug" in key and is_location_valid(world, key):
set_rule(world.multiworld.get_location(key, world.player), lambda state: True) set_rule(world.multiworld.get_location(key, world.player), lambda state: True)
# Moderate: clear Rush Hour without Hookshot or Dweller Mask # Moderate: clear Rush Hour without Hookshot
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player), set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
lambda state: state.has("Metro Ticket - Pink", world.player) lambda state: state.has("Metro Ticket - Pink", world.player)
and state.has("Metro Ticket - Yellow", world.player) and state.has("Metro Ticket - Yellow", world.player)

View File

@@ -16,9 +16,9 @@ from worlds.LauncherComponents import Component, components, icon_paths, launch
from Utils import local_path from Utils import local_path
def launch_client(*args: str): def launch_client():
from .Client import launch from .Client import launch
launch_component(launch, name="AHITClient", args=args) launch_component(launch, name="AHITClient")
components.append(Component("A Hat in Time Client", "AHITClient", func=launch_client, components.append(Component("A Hat in Time Client", "AHITClient", func=launch_client,

View File

@@ -54,13 +54,16 @@ def parse_arguments(argv, no_defaults=False):
ret = parser.parse_args(argv) ret = parser.parse_args(argv)
# cannot be set through CLI currently # cannot be set through CLI currently
ret.plando_items = []
ret.plando_texts = {}
ret.plando_connections = []
if multiargs.multi: if multiargs.multi:
defaults = copy.deepcopy(ret) defaults = copy.deepcopy(ret)
for player in range(1, multiargs.multi + 1): for player in range(1, multiargs.multi + 1):
playerargs = parse_arguments(shlex.split(getattr(ret, f"p{player}")), True) playerargs = parse_arguments(shlex.split(getattr(ret, f"p{player}")), True)
for name in ["game", "sprite", "sprite_pool"]: for name in ["plando_items", "plando_texts", "plando_connections", "game", "sprite", "sprite_pool"]:
value = getattr(defaults, name) if getattr(playerargs, name) is None else getattr(playerargs, name) value = getattr(defaults, name) if getattr(playerargs, name) is None else getattr(playerargs, name)
if player == 1: if player == 1:
setattr(ret, name, {1: value}) setattr(ret, name, {1: value})

View File

@@ -548,12 +548,10 @@ def set_up_take_anys(multiworld, world, player):
old_man_take_any.shop = TakeAny(old_man_take_any, 0x0112, 0xE2, True, True, total_shop_slots) old_man_take_any.shop = TakeAny(old_man_take_any, 0x0112, 0xE2, True, True, total_shop_slots)
multiworld.shops.append(old_man_take_any.shop) multiworld.shops.append(old_man_take_any.shop)
sword_indices = [ swords = [item for item in multiworld.itempool if item.player == player and item.type == 'Sword']
index for index, item in enumerate(multiworld.itempool) if item.player == player and item.type == 'Sword' if swords:
] sword = multiworld.random.choice(swords)
if sword_indices: multiworld.itempool.remove(sword)
sword_index = multiworld.random.choice(sword_indices)
sword = multiworld.itempool.pop(sword_index)
multiworld.itempool.append(item_factory('Rupees (20)', world)) multiworld.itempool.append(item_factory('Rupees (20)', world))
old_man_take_any.shop.add_inventory(0, sword.name, 0, 0) old_man_take_any.shop.add_inventory(0, sword.name, 0, 0)
loc_name = "Old Man Sword Cave" loc_name = "Old Man Sword Cave"

View File

@@ -505,20 +505,20 @@ class ALTTPWorld(World):
def pre_fill(self): def pre_fill(self):
from Fill import fill_restrictive, FillError from Fill import fill_restrictive, FillError
attempts = 5 attempts = 5
all_state = self.multiworld.get_all_state(use_cache=False) world = self.multiworld
player = self.player
all_state = world.get_all_state(use_cache=True)
crystals = [self.create_item(name) for name in ['Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5', 'Crystal 6']] crystals = [self.create_item(name) for name in ['Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5', 'Crystal 6']]
for crystal in crystals: crystal_locations = [world.get_location('Turtle Rock - Prize', player),
all_state.remove(crystal) world.get_location('Eastern Palace - Prize', player),
crystal_locations = [self.get_location('Turtle Rock - Prize'), world.get_location('Desert Palace - Prize', player),
self.get_location('Eastern Palace - Prize'), world.get_location('Tower of Hera - Prize', player),
self.get_location('Desert Palace - Prize'), world.get_location('Palace of Darkness - Prize', player),
self.get_location('Tower of Hera - Prize'), world.get_location('Thieves\' Town - Prize', player),
self.get_location('Palace of Darkness - Prize'), world.get_location('Skull Woods - Prize', player),
self.get_location('Thieves\' Town - Prize'), world.get_location('Swamp Palace - Prize', player),
self.get_location('Skull Woods - Prize'), world.get_location('Ice Palace - Prize', player),
self.get_location('Swamp Palace - Prize'), world.get_location('Misery Mire - Prize', player)]
self.get_location('Ice Palace - Prize'),
self.get_location('Misery Mire - Prize')]
placed_prizes = {loc.item.name for loc in crystal_locations if loc.item} placed_prizes = {loc.item.name for loc in crystal_locations if loc.item}
unplaced_prizes = [crystal for crystal in crystals if crystal.name not in placed_prizes] unplaced_prizes = [crystal for crystal in crystals if crystal.name not in placed_prizes]
empty_crystal_locations = [loc for loc in crystal_locations if not loc.item] empty_crystal_locations = [loc for loc in crystal_locations if not loc.item]
@@ -526,8 +526,8 @@ class ALTTPWorld(World):
try: try:
prizepool = unplaced_prizes.copy() prizepool = unplaced_prizes.copy()
prize_locs = empty_crystal_locations.copy() prize_locs = empty_crystal_locations.copy()
self.multiworld.random.shuffle(prize_locs) world.random.shuffle(prize_locs)
fill_restrictive(self.multiworld, all_state, prize_locs, prizepool, True, lock=True, fill_restrictive(world, all_state, prize_locs, prizepool, True, lock=True,
name="LttP Dungeon Prizes") name="LttP Dungeon Prizes")
except FillError as e: except FillError as e:
lttp_logger.exception("Failed to place dungeon prizes (%s). Will retry %s more times", e, lttp_logger.exception("Failed to place dungeon prizes (%s). Will retry %s more times", e,
@@ -541,7 +541,7 @@ class ALTTPWorld(World):
if self.options.mode == 'standard' and self.options.small_key_shuffle \ if self.options.mode == 'standard' and self.options.small_key_shuffle \
and self.options.small_key_shuffle != small_key_shuffle.option_universal and \ and self.options.small_key_shuffle != small_key_shuffle.option_universal and \
self.options.small_key_shuffle != small_key_shuffle.option_own_dungeons: self.options.small_key_shuffle != small_key_shuffle.option_own_dungeons:
self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1 world.local_early_items[player]["Small Key (Hyrule Castle)"] = 1
@classmethod @classmethod
def stage_pre_fill(cls, world): def stage_pre_fill(cls, world):
@@ -811,15 +811,12 @@ class ALTTPWorld(World):
return GetBeemizerItem(self.multiworld, self.player, item) return GetBeemizerItem(self.multiworld, self.player, item)
def get_pre_fill_items(self): def get_pre_fill_items(self):
res = [self.create_item(name) for name in ('Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', res = []
'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5',
'Crystal 6')]
if self.dungeon_local_item_names: if self.dungeon_local_item_names:
for dungeon in self.dungeons.values(): for dungeon in self.dungeons.values():
for item in dungeon.all_items: for item in dungeon.all_items:
if item.name in self.dungeon_local_item_names: if item.name in self.dungeon_local_item_names:
res.append(item) res.append(item)
return res return res
def fill_slot_data(self): def fill_slot_data(self):

View File

@@ -10,12 +10,12 @@ class LTTPTestBase(unittest.TestCase):
from worlds.alttp.Options import Medallion from worlds.alttp.Options import Medallion
self.multiworld = MultiWorld(1) self.multiworld = MultiWorld(1)
self.multiworld.game[1] = "A Link to the Past" self.multiworld.game[1] = "A Link to the Past"
self.multiworld.state = CollectionState(self.multiworld)
self.multiworld.set_seed(None) self.multiworld.set_seed(None)
args = Namespace() args = Namespace()
for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items(): 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"))}) setattr(args, name, {1: option.from_any(getattr(option, "default"))})
self.multiworld.set_options(args) self.multiworld.set_options(args)
self.multiworld.state = CollectionState(self.multiworld)
self.world = self.multiworld.worlds[1] self.world = self.multiworld.worlds[1]
# by default medallion access is randomized, for unittests we set it to vanilla # by default medallion access is randomized, for unittests we set it to vanilla
self.world.options.misery_mire_medallion.value = Medallion.option_ether self.world.options.misery_mire_medallion.value = Medallion.option_ether

View File

@@ -38,7 +38,7 @@ class DungeonFillTestBase(TestCase):
def test_original_dungeons(self): def test_original_dungeons(self):
self.generate_with_options(DungeonItem.option_original_dungeon) self.generate_with_options(DungeonItem.option_original_dungeon)
for location in self.multiworld.get_filled_locations(): for location in self.multiworld.get_filled_locations():
with (self.subTest(location_name=location.name)): with (self.subTest(location=location)):
if location.parent_region.dungeon is None: if location.parent_region.dungeon is None:
self.assertIs(location.item.dungeon, None) self.assertIs(location.item.dungeon, None)
else: else:
@@ -52,7 +52,7 @@ class DungeonFillTestBase(TestCase):
def test_own_dungeons(self): def test_own_dungeons(self):
self.generate_with_options(DungeonItem.option_own_dungeons) self.generate_with_options(DungeonItem.option_own_dungeons)
for location in self.multiworld.get_filled_locations(): for location in self.multiworld.get_filled_locations():
with self.subTest(location_name=location.name): with self.subTest(location=location):
if location.parent_region.dungeon is None: if location.parent_region.dungeon is None:
self.assertIs(location.item.dungeon, None) self.assertIs(location.item.dungeon, None)
else: else:

View File

@@ -4,7 +4,7 @@ Date: Fri, 15 Mar 2024 18:41:40 +0000
Description: Used to manage Regions in the Aquaria game multiworld randomizer Description: Used to manage Regions in the Aquaria game multiworld randomizer
""" """
from typing import Dict, Optional, Iterable from typing import Dict, Optional
from BaseClasses import MultiWorld, Region, Entrance, Item, ItemClassification, CollectionState from BaseClasses import MultiWorld, Region, Entrance, Item, ItemClassification, CollectionState
from .Items import AquariaItem, ItemNames from .Items import AquariaItem, ItemNames
from .Locations import AquariaLocations, AquariaLocation, AquariaLocationNames from .Locations import AquariaLocations, AquariaLocation, AquariaLocationNames
@@ -34,15 +34,10 @@ def _has_li(state: CollectionState, player: int) -> bool:
return state.has(ItemNames.LI_AND_LI_SONG, player) return state.has(ItemNames.LI_AND_LI_SONG, player)
DAMAGING_ITEMS:Iterable[str] = [ def _has_damaging_item(state: CollectionState, player: int) -> bool:
ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, """`player` in `state` has the shield song item"""
ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, return state.has_any({ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, ItemNames.LI_AND_LI_SONG,
ItemNames.BABY_BLASTER ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, ItemNames.BABY_BLASTER}, player)
]
def _has_damaging_item(state: CollectionState, player: int, damaging_items:Iterable[str] = DAMAGING_ITEMS) -> bool:
"""`player` in `state` has the an item that do damage other than the ones in `to_remove`"""
return state.has_any(damaging_items, player)
def _has_energy_attack_item(state: CollectionState, player: int) -> bool: def _has_energy_attack_item(state: CollectionState, player: int) -> bool:
@@ -571,11 +566,9 @@ class AquariaRegions:
self.__connect_one_way_regions(self.openwater_tr, self.openwater_tr_turtle, self.__connect_one_way_regions(self.openwater_tr, self.openwater_tr_turtle,
lambda state: _has_beast_form_or_arnassi_armor(state, self.player)) lambda state: _has_beast_form_or_arnassi_armor(state, self.player))
self.__connect_one_way_regions(self.openwater_tr_turtle, self.openwater_tr) self.__connect_one_way_regions(self.openwater_tr_turtle, self.openwater_tr)
damaging_items_minus_nature_form = [item for item in DAMAGING_ITEMS if item != ItemNames.NATURE_FORM]
self.__connect_one_way_regions(self.openwater_tr, self.openwater_tr_urns, self.__connect_one_way_regions(self.openwater_tr, self.openwater_tr_urns,
lambda state: _has_bind_song(state, self.player) or lambda state: _has_bind_song(state, self.player) or
_has_damaging_item(state, self.player, _has_damaging_item(state, self.player))
damaging_items_minus_nature_form))
self.__connect_regions(self.openwater_tr, self.openwater_br) self.__connect_regions(self.openwater_tr, self.openwater_br)
self.__connect_regions(self.openwater_tr, self.mithalas_city) self.__connect_regions(self.openwater_tr, self.mithalas_city)
self.__connect_regions(self.openwater_tr, self.veil_b) self.__connect_regions(self.openwater_tr, self.veil_b)

View File

@@ -207,6 +207,7 @@ class BlasphemousWorld(World):
if not self.options.skill_randomizer: if not self.options.skill_randomizer:
self.place_items_from_dict(skill_dict) self.place_items_from_dict(skill_dict)
def place_items_from_set(self, location_set: Set[str], name: str): def place_items_from_set(self, location_set: Set[str], name: str):
for loc in location_set: for loc in location_set:
self.get_location(loc).place_locked_item(self.create_item(name)) self.get_location(loc).place_locked_item(self.create_item(name))

View File

@@ -3,8 +3,7 @@
## Required Software ## Required Software
- ChecksFinder from - ChecksFinder from
the [Github releases Page for the game](https://github.com/jonloveslegos/ChecksFinder/releases) (latest version), or the [Github releases Page for the game](https://github.com/jonloveslegos/ChecksFinder/releases) (latest version)
from the [itch.io Page for the game](https://suncat0.itch.io/checksfinder) (including web version)
## Configuring your YAML file ## Configuring your YAML file
@@ -19,13 +18,13 @@ You can customize your options by visiting the [ChecksFinder Player Options Page
## Joining a MultiWorld Game ## Joining a MultiWorld Game
1. Start ChecksFinder and press `Play Online` 1. Start ChecksFinder
2. Switch to the console window/tab 2. Enter the following information:
3. Enter the following information: - Enter the server url (starting from `wss://` for https connection like archipelago.gg, and starting from `ws://` for http connection and local multiserver)
- Server url - Enter server port
- Server port - Enter the name of the slot you wish to connect to
- The name of the slot you wish to connect to - Enter the room password (optional)
- The room password (optional) - Press `Play Online` to connect
4. Press `Connect` to connect 3. Start playing!
5. Switch to the game window/tab
6. Start playing! Game options and controls are described in the readme on the github repository for the game

View File

@@ -1,9 +1,10 @@
from dataclasses import dataclass from dataclasses import dataclass
import os import os
import io
from typing import TYPE_CHECKING, Dict, List, Optional, cast from typing import TYPE_CHECKING, Dict, List, Optional, cast
import zipfile import zipfile
from BaseClasses import Location from BaseClasses import Location
from worlds.Files import APPlayerContainer from worlds.Files import APContainer, AutoPatchRegister
from .Enum import CivVICheckType from .Enum import CivVICheckType
from .Locations import CivVILocation, CivVILocationData from .Locations import CivVILocation, CivVILocationData
@@ -25,19 +26,22 @@ class CivTreeItem:
ui_tree_row: int ui_tree_row: int
class CivVIContainer(APPlayerContainer): class CivVIContainer(APContainer, metaclass=AutoPatchRegister):
""" """
Responsible for generating the dynamic mod files for the Civ VI multiworld Responsible for generating the dynamic mod files for the Civ VI multiworld
""" """
game: Optional[str] = "Civilization VI" game: Optional[str] = "Civilization VI"
patch_file_ending = ".apcivvi" patch_file_ending = ".apcivvi"
def __init__(self, patch_data: Dict[str, str], base_path: str = "", output_directory: str = "", def __init__(self, patch_data: Dict[str, str] | io.BytesIO, base_path: str = "", output_directory: str = "",
player: Optional[int] = None, player_name: str = "", server: str = ""): player: Optional[int] = None, player_name: str = "", server: str = ""):
self.patch_data = patch_data if isinstance(patch_data, io.BytesIO):
self.file_path = base_path super().__init__(patch_data, player, player_name, server)
container_path = os.path.join(output_directory, base_path + ".apcivvi") else:
super().__init__(container_path, player, player_name, server) self.patch_data = patch_data
self.file_path = base_path
container_path = os.path.join(output_directory, base_path + ".apcivvi")
super().__init__(container_path, player, player_name, server)
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None: def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
for filename, yml in self.patch_data.items(): for filename, yml in self.patch_data.items():

View File

@@ -78,8 +78,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_IRON_WORKING", "BOOST_TECH_IRON_WORKING",
"ERA_CLASSICAL", "ERA_CLASSICAL",
["TECH_MINING", "TECH_BRONZE_WORKING"], ["TECH_MINING"],
2, 1,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -165,9 +165,15 @@ boosts: List[CivVIBoostData] = [
"BOOST_TECH_CASTLES", "BOOST_TECH_CASTLES",
"ERA_MEDIEVAL", "ERA_MEDIEVAL",
[ [
"CIVIC_DIVINE_RIGHT",
"CIVIC_EXPLORATION",
"CIVIC_REFORMED_CHURCH",
"CIVIC_SUFFRAGE", "CIVIC_SUFFRAGE",
"CIVIC_TOTALITARIANISM", "CIVIC_TOTALITARIANISM",
"CIVIC_CLASS_STRUGGLE", "CIVIC_CLASS_STRUGGLE",
"CIVIC_DIGITAL_DEMOCRACY",
"CIVIC_CORPORATE_LIBERTARIANISM",
"CIVIC_SYNTHETIC_TECHNOCRACY",
], ],
1, 1,
"DEFAULT", "DEFAULT",
@@ -387,6 +393,9 @@ boosts: List[CivVIBoostData] = [
"CIVIC_SUFFRAGE", "CIVIC_SUFFRAGE",
"CIVIC_TOTALITARIANISM", "CIVIC_TOTALITARIANISM",
"CIVIC_CLASS_STRUGGLE", "CIVIC_CLASS_STRUGGLE",
"CIVIC_DIGITAL_DEMOCRACY",
"CIVIC_CORPORATE_LIBERTARIANISM",
"CIVIC_SYNTHETIC_TECHNOCRACY",
], ],
1, 1,
"DEFAULT", "DEFAULT",

View File

@@ -20,17 +20,16 @@ A short period after receiving an item, you will get a notification indicating y
## FAQs ## FAQs
- Do I need the DLC to play this? - Do I need the DLC to play this?
- You need both expansions, Rise & Fall and Gathering Storm. You do not need the other DLCs but they fully work with this. - Yes, you need both Rise & Fall and Gathering Storm.
- Does this work with Multiplayer? - Does this work with Multiplayer?
- It does not and, despite my best efforts, probably won't until there's a new way for external programs to be able to interact with the game. - It does not and, despite my best efforts, probably won't until there's a new way for external programs to be able to interact with the game.
- Does this work with other mods? - Does my mod that reskins Barbarians as various Pro Wrestlers work with this?
- A lot of mods seem to work without issues combined with this, but you should avoid any mods that change things in the tech or civic tree, as even if they would work it could cause issues with the logic. - Only one way to find out! Any mods that modify techs/civics will most likely cause issues, though.
- "Help! I can't see any of the items that have been sent to me!" - "Help! I can't see any of the items that have been sent to me!"
- Both trees by default will show you the researchable Archipelago locations. To view the normal tree, you can click "Toggle Archipelago Tree" in the top-left corner of the tree view. - Both trees by default will show you the researchable Archipelago locations. To view the normal tree, you can click "Toggle Archipelago Tree" in the top-left corner of the tree view.
- "Oh no! I received the Machinery tech and now instead of getting an Archer next turn, I have to wait an additional 10 turns to get a Crossbowman!" - "Oh no! I received the Machinery tech and now instead of getting an Archer next turn, I have to wait an additional 10 turns to get a Crossbowman!"
- Vanilla prevents you from building units of the same class from an earlier tech level after you have researched a later variant. For example, this could be problematic if someone unlocks Crossbowmen for you right out the gate since you won't be able to make Archers (which have a much lower production cost). - Vanilla prevents you from building units of the same class from an earlier tech level after you have researched a later variant. For example, this could be problematic if someone unlocks Crossbowmen for you right out the gate since you won't be able to make Archers (which have a much lower production cost).
- Solution: You can now go in to the tech tree, click "Toggle Archipelago Tree" to view your unlocked techs, and then can click any tech you have unlocked to toggle whether it is currently active or not. Solution: You can now go in to the tech tree, click "Toggle Archipelago Tree" to view your unlocked techs, and then can click any tech you have unlocked to toggle whether it is currently active or not.
- If you think you should be able to make Field Cannons but seemingly can't try disabling `Telecommunications`
- "How does DeathLink work? Am I going to have to start a new game every time one of my friends dies?" - "How does DeathLink work? Am I going to have to start a new game every time one of my friends dies?"
- Heavens no, my fellow Archipelago appreciator. When configuring your Archipelago options for Civilization on the options page, there are several choices available for you to fine tune the way you'd like to be punished for the follies of your friends. These include: Having a random unit destroyed, losing a percentage of gold or faith, or even losing a point on your era score. If you can't make up your mind, you can elect to have any of them be selected every time a death link is sent your way. - Heavens no, my fellow Archipelago appreciator. When configuring your Archipelago options for Civilization on the options page, there are several choices available for you to fine tune the way you'd like to be punished for the follies of your friends. These include: Having a random unit destroyed, losing a percentage of gold or faith, or even losing a point on your era score. If you can't make up your mind, you can elect to have any of them be selected every time a death link is sent your way.
In the event you lose one of your units in combat (this means captured units don't count), then you will send a death link event to the rest of your friends. In the event you lose one of your units in combat (this means captured units don't count), then you will send a death link event to the rest of your friends.
@@ -40,8 +39,7 @@ A short period after receiving an item, you will get a notification indicating y
1. `TECH_WRITING` 1. `TECH_WRITING`
2. `TECH_EDUCATION` 2. `TECH_EDUCATION`
3. `TECH_CHEMISTRY` 3. `TECH_CHEMISTRY`
- An important thing to note is that the seaport is part of progressive industrial zones, due to electricity having both an industrial zone building and the seaport. - If you want to see the details around each item, you can review [this file](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/civ_6/data/progressive_districts.json).
- If you want to see the details around each item, you can review [this file](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/civ_6/data/progressive_districts.py).
## Boostsanity ## Boostsanity
Boostsanity takes all of the Eureka & Inspiration events and makes them location checks. This feature is the one to change up the way Civilization is played in an AP multiworld/randomizer. What normally are mundane tasks that are passively collected now become a novel and interesting bucket list that you need to pay attention to in order to unlock items for yourself and others! Boostsanity takes all of the Eureka & Inspiration events and makes them location checks. This feature is the one to change up the way Civilization is played in an AP multiworld/randomizer. What normally are mundane tasks that are passively collected now become a novel and interesting bucket list that you need to pay attention to in order to unlock items for yourself and others!
@@ -58,3 +56,4 @@ Boosts have logic associated with them in order to verify you can always reach t
- The unpredictable timing of boosts and unlocking them can occasionally lead to scenarios where you'll have to first encounter a locked era defeat and then load a previous save. To help reduce the frequency of this, local `PROGRESSIVE_ERA` items will never be located at a boost check. - The unpredictable timing of boosts and unlocking them can occasionally lead to scenarios where you'll have to first encounter a locked era defeat and then load a previous save. To help reduce the frequency of this, local `PROGRESSIVE_ERA` items will never be located at a boost check.
- There's too many boosts, how will I know which one's I should focus on?! - There's too many boosts, how will I know which one's I should focus on?!
- In order to give a little more focus to all the boosts rather than just arbitrarily picking them at random, items in both of the vanilla trees will now have an advisor icon on them if its associated boost contains a progression item. - In order to give a little more focus to all the boosts rather than just arbitrarily picking them at random, items in both of the vanilla trees will now have an advisor icon on them if its associated boost contains a progression item.

View File

@@ -6,14 +6,12 @@ This guide is meant to help you get up and running with Civilization VI in Archi
The following are required in order to play Civ VI in Archipelago: The following are required in order to play Civ VI in Archipelago:
- Windows OS (Firaxis does not support the necessary tooling for Mac, or Linux). - Windows OS (Firaxis does not support the necessary tooling for Mac, or Linux)
- Installed [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases). - Installed [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) v0.4.5 or higher.
- The latest version of the [Civ VI AP Mod](https://github.com/hesto2/civilization_archipelago_mod/releases/latest). - The latest version of the [Civ VI AP Mod](https://github.com/hesto2/civilization_archipelago_mod/releases/latest).
- A copy of the game `Civilization VI` including the two expansions `Rise & Fall` and `Gathering Storm` (both the Steam and Epic version should work).
## Enabling the tuner ## Enabling the tuner
In the main menu, navigate to the "Game Options" page. On the "Game" menu, make sure that "Tuner (disables achievements)" is enabled. In the main menu, navigate to the "Game Options" page. On the "Game" menu, make sure that "Tuner (disables achievements)" is enabled.
@@ -22,32 +20,27 @@ In the main menu, navigate to the "Game Options" page. On the "Game" menu, make
1. Download and unzip the latest release of the mod from [GitHub](https://github.com/hesto2/civilization_archipelago_mod/releases/latest). 1. Download and unzip the latest release of the mod from [GitHub](https://github.com/hesto2/civilization_archipelago_mod/releases/latest).
2. Copy the folder containing the mod files to your Civ VI mods folder. On Windows, this is usually located at `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods`. If you use OneDrive, check if the folder is instead located in your OneDrive file structure, and use that path when relevant in future steps. 2. Copy the folder containing the mod files to your Civ VI mods folder. On Windows, this is usually located at `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods`. If you use OneDrive, check if the folder is instead located in your OneDrive file structure.
3. After the Archipelago host generates a game, you should be given a `.apcivvi` file. Associate the file with the Archipelago Launcher and double click it. 3. After the Archipelago host generates a game, you should be given a `.apcivvi` file. Associate the file with the Archipelago Launcher and double click it.
4. Copy the contents of the new folder it generates (it will have the same name as the `.apcivvi` file) into your Civilization VI Archipelago Mod folder. If double clicking the `.apcivvi` file doesn't generate a folder, you can instead open it as a zip file. You can do this by either right clicking it and opening it with a program that handles zip files, or by right clicking and renaming the file extension from `apcivvi` to `zip`. 4. Copy the contents of the new folder it generates (it will have the same name as the `.apcivvi` file) into your Civilization VI Archipelago Mod folder. If double clicking the `.apcivvi` file doesn't generate a folder, you can just rename it to a file ending with `.zip` and extract its contents to a new folder. To do this, right click the `.apcivvi` file and click "Rename", make sure it ends in `.zip`, then right click it again and select "Extract All".
5. Place the files generated from the `.apcivvi` in your archipelago mod folder (there should be five files placed there from the apcivvi file, overwrite if asked). Your mod path should look something like `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods\civilization_archipelago_mod`. 5. Your finished mod folder should look something like this:
- Civ VI Mods Directory
- civilization_archipelago_mod
- NewItems.xml
- InitOptions.lua
- Archipelago.modinfo
- All the other mod files, etc.
## Configuring your game ## Configuring your game
Make sure you enable the mod in the main title under Additional Content > Mods. When configuring your game, make sure to start the game in the Ancient Era and leave all settings related to starting technologies and civics as the defaults. Other than that, configure difficulty, AI, etc. as you normally would. When configuring your game, make sure to start the game in the Ancient Era and leave all settings related to starting technologies and civics as the defaults. Other than that, configure difficulty, AI, etc. as you normally would.
## Troubleshooting ## Troubleshooting
- If you have troubles with file extension related stuff, make sure Windows shows file extensions as they are turned off by default. If you don't know how to turn them on it is just a quick google search away.
- If you are getting an error: "The remote computer refused the network connection", or something else related to the client (or tuner) not being able to connect, it likely indicates the tuner is not actually enabled. One simple way to verify that it is enabled is, after completing the setup steps, go to Main Menu &rarr; Options &rarr; Look for an option named "Tuner" and verify it is set to "Enabled" - If you are getting an error: "The remote computer refused the network connection", or something else related to the client (or tuner) not being able to connect, it likely indicates the tuner is not actually enabled. One simple way to verify that it is enabled is, after completing the setup steps, go to Main Menu &rarr; Options &rarr; Look for an option named "Tuner" and verify it is set to "Enabled"
- If your game gets in a state where someone has sent you items or you have sent locations but these are not correctly sent to the multiworld, you can run `/resync` from the Civ 6 client. This may take up to a minute depending on how many items there are. This can resend certain items to you, like one time bonuses. - If your game gets in a state where someone has sent you items or you have sent locations but these are not correctly sent to the multiworld, you can run `/resync` from the Civ 6 client. This may take up to a minute depending on how many items there are.
- If the archipelago mod does not appear in the mod selector in the game, make sure the mod is correctly placed as a folder in the `Sid Meier's Civilization VI\Mods` folder, there should not be any loose files in there only folders. As in the path should look something like `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods\civilization_archipelago_mod`.
- If it still does not appear make sure you have the right folder, one way to verify you are in the right place is to find the general folder area where your Civ VI save files are located.
- If you get an error when trying to start a game saying `Error - One or more Mods failed to load content`, make sure the files from the `.apcivvi` are placed into the `civilization_archipelago_mod` as loose files and not as a folder.
- If you still have any errors make sure the two expansions Rise & Fall and Gathering Storm are active in the mod selector (all the official DLC works without issues but Rise & Fall and Gathering Storm are required for the mod).
- If boostsanity is enabled and those items are not being sent out but regular techs are, make sure you placed the files from your new room in the mod folder.

38
worlds/clique/Items.py Normal file
View File

@@ -0,0 +1,38 @@
from typing import Callable, Dict, NamedTuple, Optional, TYPE_CHECKING
from BaseClasses import Item, ItemClassification
if TYPE_CHECKING:
from . import CliqueWorld
class CliqueItem(Item):
game = "Clique"
class CliqueItemData(NamedTuple):
code: Optional[int] = None
type: ItemClassification = ItemClassification.filler
can_create: Callable[["CliqueWorld"], bool] = lambda world: True
item_data_table: Dict[str, CliqueItemData] = {
"Feeling of Satisfaction": CliqueItemData(
code=69696969,
type=ItemClassification.progression,
),
"Button Activation": CliqueItemData(
code=69696968,
type=ItemClassification.progression,
can_create=lambda world: world.options.hard_mode,
),
"A Cool Filler Item (No Satisfaction Guaranteed)": CliqueItemData(
code=69696967,
can_create=lambda world: False # Only created from `get_filler_item_name`.
),
"The Urge to Push": CliqueItemData(
type=ItemClassification.progression,
),
}
item_table = {name: data.code for name, data in item_data_table.items() if data.code is not None}

View File

@@ -0,0 +1,37 @@
from typing import Callable, Dict, NamedTuple, Optional, TYPE_CHECKING
from BaseClasses import Location
if TYPE_CHECKING:
from . import CliqueWorld
class CliqueLocation(Location):
game = "Clique"
class CliqueLocationData(NamedTuple):
region: str
address: Optional[int] = None
can_create: Callable[["CliqueWorld"], bool] = lambda world: True
locked_item: Optional[str] = None
location_data_table: Dict[str, CliqueLocationData] = {
"The Big Red Button": CliqueLocationData(
region="The Button Realm",
address=69696969,
),
"The Item on the Desk": CliqueLocationData(
region="The Button Realm",
address=69696968,
can_create=lambda world: world.options.hard_mode,
),
"In the Player's Mind": CliqueLocationData(
region="The Button Realm",
locked_item="The Urge to Push",
),
}
location_table = {name: data.address for name, data in location_data_table.items() if data.address is not None}
locked_locations = {name: data for name, data in location_data_table.items() if data.locked_item}

34
worlds/clique/Options.py Normal file
View File

@@ -0,0 +1,34 @@
from dataclasses import dataclass
from Options import Choice, Toggle, PerGameCommonOptions, StartInventoryPool
class HardMode(Toggle):
"""Only for the most masochistically inclined... Requires button activation!"""
display_name = "Hard Mode"
class ButtonColor(Choice):
"""Customize your button! Now available in 12 unique colors."""
display_name = "Button Color"
option_red = 0
option_orange = 1
option_yellow = 2
option_green = 3
option_cyan = 4
option_blue = 5
option_magenta = 6
option_purple = 7
option_pink = 8
option_brown = 9
option_white = 10
option_black = 11
@dataclass
class CliqueOptions(PerGameCommonOptions):
color: ButtonColor
hard_mode: HardMode
start_inventory_from_pool: StartInventoryPool
# DeathLink is always on. Always.
# death_link: DeathLink

11
worlds/clique/Regions.py Normal file
View File

@@ -0,0 +1,11 @@
from typing import Dict, List, NamedTuple
class CliqueRegionData(NamedTuple):
connecting_regions: List[str] = []
region_data_table: Dict[str, CliqueRegionData] = {
"Menu": CliqueRegionData(["The Button Realm"]),
"The Button Realm": CliqueRegionData(),
}

13
worlds/clique/Rules.py Normal file
View File

@@ -0,0 +1,13 @@
from typing import Callable, TYPE_CHECKING
from BaseClasses import CollectionState
if TYPE_CHECKING:
from . import CliqueWorld
def get_button_rule(world: "CliqueWorld") -> Callable[[CollectionState], bool]:
if world.options.hard_mode:
return lambda state: state.has("Button Activation", world.player)
return lambda state: True

102
worlds/clique/__init__.py Normal file
View File

@@ -0,0 +1,102 @@
from typing import List, Dict, Any
from BaseClasses import Region, Tutorial
from worlds.AutoWorld import WebWorld, World
from .Items import CliqueItem, item_data_table, item_table
from .Locations import CliqueLocation, location_data_table, location_table, locked_locations
from .Options import CliqueOptions
from .Regions import region_data_table
from .Rules import get_button_rule
class CliqueWebWorld(WebWorld):
theme = "partyTime"
setup_en = Tutorial(
tutorial_name="Start Guide",
description="A guide to playing Clique.",
language="English",
file_name="guide_en.md",
link="guide/en",
authors=["Phar"]
)
setup_de = Tutorial(
tutorial_name="Anleitung zum Anfangen",
description="Eine Anleitung um Clique zu spielen.",
language="Deutsch",
file_name="guide_de.md",
link="guide/de",
authors=["Held_der_Zeit"]
)
tutorials = [setup_en, setup_de]
game_info_languages = ["en", "de"]
class CliqueWorld(World):
"""The greatest game of all time."""
game = "Clique"
web = CliqueWebWorld()
options: CliqueOptions
options_dataclass = CliqueOptions
location_name_to_id = location_table
item_name_to_id = item_table
def create_item(self, name: str) -> CliqueItem:
return CliqueItem(name, item_data_table[name].type, item_data_table[name].code, self.player)
def create_items(self) -> None:
item_pool: List[CliqueItem] = []
for name, item in item_data_table.items():
if item.code and item.can_create(self):
item_pool.append(self.create_item(name))
self.multiworld.itempool += item_pool
def create_regions(self) -> None:
# Create regions.
for region_name in region_data_table.keys():
region = Region(region_name, self.player, self.multiworld)
self.multiworld.regions.append(region)
# Create locations.
for region_name, region_data in region_data_table.items():
region = self.get_region(region_name)
region.add_locations({
location_name: location_data.address for location_name, location_data in location_data_table.items()
if location_data.region == region_name and location_data.can_create(self)
}, CliqueLocation)
region.add_exits(region_data_table[region_name].connecting_regions)
# Place locked locations.
for location_name, location_data in locked_locations.items():
# Ignore locations we never created.
if not location_data.can_create(self):
continue
locked_item = self.create_item(location_data_table[location_name].locked_item)
self.get_location(location_name).place_locked_item(locked_item)
# Set priority location for the Big Red Button!
self.options.priority_locations.value.add("The Big Red Button")
def get_filler_item_name(self) -> str:
return "A Cool Filler Item (No Satisfaction Guaranteed)"
def set_rules(self) -> None:
button_rule = get_button_rule(self)
self.get_location("The Big Red Button").access_rule = button_rule
self.get_location("In the Player's Mind").access_rule = button_rule
# Do not allow button activations on buttons.
self.get_location("The Big Red Button").item_rule = lambda item: item.name != "Button Activation"
# Completion condition.
self.multiworld.completion_condition[self.player] = lambda state: state.has("The Urge to Push", self.player)
def fill_slot_data(self) -> Dict[str, Any]:
return {
"color": self.options.color.current_key
}

View File

@@ -0,0 +1,18 @@
# Clique
## Was ist das für ein Spiel?
~~Clique ist ein psychologisches Überlebens-Horror Spiel, in dem der Spieler der Versuchung wiederstehen muss große~~
~~(rote) Knöpfe zu drücken.~~
Clique ist ein scherzhaftes Spiel, welches für Archipelago im März 2023 entwickelt wurde, um zu zeigen, wie einfach
es sein kann eine Welt für Archipelago zu entwicklen. Das Ziel des Spiels ist es den großen (standardmäßig) roten
Knopf zu drücken. Wenn ein Spieler auf dem `hard_mode` (schwieriger Modus) spielt, muss dieser warten bis jemand
anderes in der Multiworld den Knopf aktiviert, damit er gedrückt werden kann.
Clique kann auf den meisten modernen, HTML5-fähigen Browsern gespielt werden.
## Wo ist die Seite für die Einstellungen?
Die [Seite für die Spielereinstellungen dieses Spiels](../player-options) enthält alle Optionen die man benötigt um
eine YAML-Datei zu konfigurieren und zu exportieren.

View File

@@ -0,0 +1,16 @@
# Clique
## What is this game?
~~Clique is a psychological survival horror game where a player must survive the temptation to press red buttons.~~
Clique is a joke game developed for Archipelago in March 2023 to showcase how easy it can be to develop a world for
Archipelago. The objective of the game is to press the big red button. If a player is playing on `hard_mode`, they must
wait for someone else in the multiworld to "activate" their button before they can press it.
Clique can be played on most modern HTML5-capable browsers.
## Where is the options page?
The [player options page for this game](../player-options) contains all the options you need to configure
and export a config file.

View File

@@ -0,0 +1,25 @@
# Clique Anleitung
Nachdem dein Seed generiert wurde, gehe auf die Website von [Clique dem Spiel](http://clique.pharware.com/) und gib
Server-Daten, deinen Slot-Namen und ein Passwort (falls vorhanden) ein. Klicke dann auf "Connect" (Verbinden).
Wenn du auf "Einfach" spielst, kannst du unbedenklich den Knopf drücken und deine "Befriedigung" erhalten.
Wenn du auf "Schwer" spielst, ist es sehr wahrscheinlich, dass du warten musst bevor du dein Ziel erreichen kannst.
Glücklicherweise läuft Click auf den meißten großen Browsern, die HTML5 unterstützen. Das heißt du kannst Clique auf
deinem Handy starten und produktiv sein während du wartest!
Falls du einige Ideen brauchst was du tun kannst, während du wartest bis der Knopf aktiviert wurde, versuche
(mindestens) eins der Folgenden:
- Dein Zimmer aufräumen.
- Die Wäsche machen.
- Etwas Essen von einem X-Belieben Fast Food Restaruant holen.
- Das tägliche Wordle machen.
- ~~Deine Seele an **Phar** verkaufen.~~
- Deine Hausaufgaben erledigen.
- Deine Post abholen.
~~Solltest du auf irgendwelche Probleme in diesem Spiel stoßen, solltest du keinesfalls nicht **thephar** auf~~
~~Discord kontaktieren. *zwinker* *zwinker*~~

View File

@@ -0,0 +1,22 @@
# Clique Start Guide
After rolling your seed, go to the [Clique Game](http://clique.pharware.com/) site and enter the server details, your
slot name, and a room password if one is required. Then click "Connect".
If you're playing on "easy mode", just click the button and receive "Satisfaction".
If you're playing on "hard mode", you may need to wait for activation before you can complete your objective. Luckily,
Clique runs in most major browsers that support HTML5, so you can load Clique on your phone and be productive while
you wait!
If you need some ideas for what to do while waiting for button activation, give the following a try:
- Clean your room.
- Wash the dishes.
- Get some food from a non-descript fast food restaurant.
- Do the daily Wordle.
- ~~Sell your soul to Phar.~~
- Do your school work.
~~If you run into any issues with this game, definitely do not contact **thephar** on discord. *wink* *wink*~~

View File

@@ -2893,18 +2893,3 @@ dog_bite_ice_trap_fix = [
0x25291CB8, # ADDIU T1, T1, 0x1CB8 0x25291CB8, # ADDIU T1, T1, 0x1CB8
0x01200008 # JR T1 0x01200008 # JR T1
] ]
shimmy_speed_modifier = [
# Increases the player's speed while shimmying as long as they are not holding down Z. If they are holding Z, it
# will be the normal speed, allowing it to still be used to set up any tricks that might require the normal speed
# (like Left Tower Skip).
0x3C088038, # LUI T0, 0x8038
0x91087D7E, # LBU T0, 0x7D7E (T0)
0x31090020, # ANDI T1, T0, 0x0020
0x3C0A800A, # LUI T2, 0x800A
0x240B005A, # ADDIU T3, R0, 0x005A
0x55200001, # BNEZL T1, [forward 0x01]
0x240B0032, # ADDIU T3, R0, 0x0032
0xA14B3641, # SB T3, 0x3641 (T2)
0x0800B7C3 # J 0x8002DF0C
]

View File

@@ -424,7 +424,6 @@ class PantherDash(Choice):
class IncreaseShimmySpeed(Toggle): class IncreaseShimmySpeed(Toggle):
""" """
Increases the speed at which characters shimmy left and right while hanging on ledges. Increases the speed at which characters shimmy left and right while hanging on ledges.
Hold Z to use the regular speed in case it's needed to do something.
""" """
display_name = "Increase Shimmy Speed" display_name = "Increase Shimmy Speed"

View File

@@ -607,10 +607,9 @@ class CV64PatchExtensions(APPatchExtension):
rom_data.write_int32(0xAA530, 0x080FF880) # J 0x803FE200 rom_data.write_int32(0xAA530, 0x080FF880) # J 0x803FE200
rom_data.write_int32s(0xBFE200, patches.coffin_cutscene_skipper) rom_data.write_int32s(0xBFE200, patches.coffin_cutscene_skipper)
# Shimmy speed increase hack # Increase shimmy speed
if options["increase_shimmy_speed"]: if options["increase_shimmy_speed"]:
rom_data.write_int32(0x97EB4, 0x803FE9F0) rom_data.write_byte(0xA4241, 0x5A)
rom_data.write_int32s(0xBFE9F0, patches.shimmy_speed_modifier)
# Disable landing fall damage # Disable landing fall damage
if options["fall_guard"]: if options["fall_guard"]:

View File

@@ -211,8 +211,7 @@ class CVCotMWorld(World):
"ignore_cleansing": self.options.ignore_cleansing.value, "ignore_cleansing": self.options.ignore_cleansing.value,
"skip_tutorials": self.options.skip_tutorials.value, "skip_tutorials": self.options.skip_tutorials.value,
"required_last_keys": self.required_last_keys, "required_last_keys": self.required_last_keys,
"completion_goal": self.options.completion_goal.value, "completion_goal": self.options.completion_goal.value}
"nerf_roc_wing": self.options.nerf_roc_wing.value}
def get_filler_item_name(self) -> str: def get_filler_item_name(self) -> str:
return self.random.choice(FILLER_ITEM_NAMES) return self.random.choice(FILLER_ITEM_NAMES)

View File

@@ -48,17 +48,11 @@ class OtherGameAppearancesInfo(TypedDict):
other_game_item_appearances: Dict[str, Dict[str, OtherGameAppearancesInfo]] = { other_game_item_appearances: Dict[str, Dict[str, OtherGameAppearancesInfo]] = {
# NOTE: Symphony of the Night and Harmony of Dissonance are custom worlds that are not core verified. # NOTE: Symphony of the Night is currently an unsupported world not in main.
"Symphony of the Night": {"Life Vessel": {"type": 0xE4, "Symphony of the Night": {"Life Vessel": {"type": 0xE4,
"appearance": 0x01}, "appearance": 0x01},
"Heart Vessel": {"type": 0xE4, "Heart Vessel": {"type": 0xE4,
"appearance": 0x00}}, "appearance": 0x00}},
"Castlevania - Harmony of Dissonance": {"Life Max Up": {"type": 0xE4,
"appearance": 0x01},
"Heart Max Up": {"type": 0xE4,
"appearance": 0x00}},
"Timespinner": {"Max HP": {"type": 0xE4, "Timespinner": {"Max HP": {"type": 0xE4,
"appearance": 0x01}, "appearance": 0x01},
"Max Aura": {"type": 0xE4, "Max Aura": {"type": 0xE4,
@@ -734,8 +728,8 @@ def get_start_inventory_data(world: "CVCotMWorld") -> Tuple[Dict[int, bytes], bo
magic_items_array[array_offset] += 1 magic_items_array[array_offset] += 1
# Add the start inventory arrays to the offset data in bytes form. # Add the start inventory arrays to the offset data in bytes form.
start_inventory_data[0x690080] = bytes(magic_items_array) start_inventory_data[0x680080] = bytes(magic_items_array)
start_inventory_data[0x6900A0] = bytes(cards_array) start_inventory_data[0x6800A0] = bytes(cards_array)
# Add the extra max HP/MP/Hearts to all classes' base stats. Doing it this way makes us less likely to hit the max # Add the extra max HP/MP/Hearts to all classes' base stats. Doing it this way makes us less likely to hit the max
# possible Max Ups. # possible Max Ups.

View File

@@ -132,40 +132,40 @@ start_inventory_giver = [
# Magic Items # Magic Items
0x13, 0x48, # ldr r0, =0x202572F 0x13, 0x48, # ldr r0, =0x202572F
0x14, 0x49, # ldr r1, =0x8690080 0x14, 0x49, # ldr r1, =0x8680080
0x00, 0x22, # mov r2, #0 0x00, 0x22, # mov r2, #0
0x8B, 0x5C, # ldrb r3, [r1, r2] 0x8B, 0x5C, # ldrb r3, [r1, r2]
0x83, 0x54, # strb r3, [r0, r2] 0x83, 0x54, # strb r3, [r0, r2]
0x01, 0x32, # adds r2, #1 0x01, 0x32, # adds r2, #1
0x08, 0x2A, # cmp r2, #8 0x08, 0x2A, # cmp r2, #8
0xFA, 0xDB, # blt 0x8690006 0xFA, 0xDB, # blt 0x8680006
# Max Ups # Max Ups
0x11, 0x48, # ldr r0, =0x202572C 0x11, 0x48, # ldr r0, =0x202572C
0x12, 0x49, # ldr r1, =0x8690090 0x12, 0x49, # ldr r1, =0x8680090
0x00, 0x22, # mov r2, #0 0x00, 0x22, # mov r2, #0
0x8B, 0x5C, # ldrb r3, [r1, r2] 0x8B, 0x5C, # ldrb r3, [r1, r2]
0x83, 0x54, # strb r3, [r0, r2] 0x83, 0x54, # strb r3, [r0, r2]
0x01, 0x32, # adds r2, #1 0x01, 0x32, # adds r2, #1
0x03, 0x2A, # cmp r2, #3 0x03, 0x2A, # cmp r2, #3
0xFA, 0xDB, # blt 0x8690016 0xFA, 0xDB, # blt 0x8680016
# Cards # Cards
0x0F, 0x48, # ldr r0, =0x2025674 0x0F, 0x48, # ldr r0, =0x2025674
0x10, 0x49, # ldr r1, =0x86900A0 0x10, 0x49, # ldr r1, =0x86800A0
0x00, 0x22, # mov r2, #0 0x00, 0x22, # mov r2, #0
0x8B, 0x5C, # ldrb r3, [r1, r2] 0x8B, 0x5C, # ldrb r3, [r1, r2]
0x83, 0x54, # strb r3, [r0, r2] 0x83, 0x54, # strb r3, [r0, r2]
0x01, 0x32, # adds r2, #1 0x01, 0x32, # adds r2, #1
0x14, 0x2A, # cmp r2, #0x14 0x14, 0x2A, # cmp r2, #0x14
0xFA, 0xDB, # blt 0x8690026 0xFA, 0xDB, # blt 0x8680026
# Inventory Items (not currently supported) # Inventory Items (not currently supported)
0x0D, 0x48, # ldr r0, =0x20256ED 0x0D, 0x48, # ldr r0, =0x20256ED
0x0E, 0x49, # ldr r1, =0x86900C0 0x0E, 0x49, # ldr r1, =0x86800C0
0x00, 0x22, # mov r2, #0 0x00, 0x22, # mov r2, #0
0x8B, 0x5C, # ldrb r3, [r1, r2] 0x8B, 0x5C, # ldrb r3, [r1, r2]
0x83, 0x54, # strb r3, [r0, r2] 0x83, 0x54, # strb r3, [r0, r2]
0x01, 0x32, # adds r2, #1 0x01, 0x32, # adds r2, #1
0x36, 0x2A, # cmp r2, #36 0x36, 0x2A, # cmp r2, #36
0xFA, 0xDB, # blt 0x8690036 0xFA, 0xDB, # blt 0x8680036
# Return to the function that checks for Magician Mode. # Return to the function that checks for Magician Mode.
0xBA, 0x21, # movs r1, #0xBA 0xBA, 0x21, # movs r1, #0xBA
0x89, 0x00, # lsls r1, r1, #2 0x89, 0x00, # lsls r1, r1, #2
@@ -176,13 +176,13 @@ start_inventory_giver = [
# LDR number pool # LDR number pool
0x78, 0x7F, 0x00, 0x08, 0x78, 0x7F, 0x00, 0x08,
0x2F, 0x57, 0x02, 0x02, 0x2F, 0x57, 0x02, 0x02,
0x80, 0x00, 0x69, 0x08, 0x80, 0x00, 0x68, 0x08,
0x2C, 0x57, 0x02, 0x02, 0x2C, 0x57, 0x02, 0x02,
0x90, 0x00, 0x69, 0x08, 0x90, 0x00, 0x68, 0x08,
0x74, 0x56, 0x02, 0x02, 0x74, 0x56, 0x02, 0x02,
0xA0, 0x00, 0x69, 0x08, 0xA0, 0x00, 0x68, 0x08,
0xED, 0x56, 0x02, 0x02, 0xED, 0x56, 0x02, 0x02,
0xC0, 0x00, 0x69, 0x08, 0xC0, 0x00, 0x68, 0x08,
] ]
max_max_up_checker = [ max_max_up_checker = [

View File

@@ -3,7 +3,7 @@
## Quick Links ## Quick Links
- [Setup](/tutorial/Castlevania%20-%20Circle%20of%20the%20Moon/setup/en) - [Setup](/tutorial/Castlevania%20-%20Circle%20of%20the%20Moon/setup/en)
- [Options Page](/games/Castlevania%20-%20Circle%20of%20the%20Moon/player-options) - [Options Page](/games/Castlevania%20-%20Circle%20of%20the%20Moon/player-options)
- [PopTracker Pack](https://github.com/BowserCrusher/Circle-of-the-Moon-AP-Tracker/releases/latest) - [PopTracker Pack](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest)
- [Repo for the original, standalone CotMR](https://github.com/calm-palm/cotm-randomizer) - [Repo for the original, standalone CotMR](https://github.com/calm-palm/cotm-randomizer)
- [Web version of the above randomizer](https://rando.circleofthemoon.com/) - [Web version of the above randomizer](https://rando.circleofthemoon.com/)
- [A more in-depth guide to CotMR's nuances](https://docs.google.com/document/d/1uot4BD9XW7A--A8ecgoY8mLK_vSoQRpY5XCkzgas87c/view?usp=sharing) - [A more in-depth guide to CotMR's nuances](https://docs.google.com/document/d/1uot4BD9XW7A--A8ecgoY8mLK_vSoQRpY5XCkzgas87c/view?usp=sharing)

View File

@@ -22,7 +22,7 @@ clear it.
## Optional Software ## Optional Software
- [Castlevania: Circle of the Moon AP Tracker](https://github.com/BowserCrusher/Circle-of-the-Moon-AP-Tracker/releases/latest), for use with - [Castlevania: Circle of the Moon AP Tracker](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest), for use with
[PopTracker](https://github.com/black-sliver/PopTracker/releases). [PopTracker](https://github.com/black-sliver/PopTracker/releases).
## Generating and Patching a Game ## Generating and Patching a Game
@@ -64,7 +64,7 @@ perfectly safe to make progress offline; everything will re-sync when you reconn
Castlevania: Circle of the Moon has a fully functional map tracker that supports auto-tracking. Castlevania: Circle of the Moon has a fully functional map tracker that supports auto-tracking.
1. Download [Castlevania: Circle of the Moon AP Tracker](https://github.com/BowserCrusher/Circle-of-the-Moon-AP-Tracker/releases/latest) and 1. Download [Castlevania: Circle of the Moon AP Tracker](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest) and
[PopTracker](https://github.com/black-sliver/PopTracker/releases). [PopTracker](https://github.com/black-sliver/PopTracker/releases).
2. Put the tracker pack into `packs/` in your PopTracker install. 2. Put the tracker pack into `packs/` in your PopTracker install.
3. Open PopTracker, and load the Castlevania: Circle of the Moon pack. 3. Open PopTracker, and load the Castlevania: Circle of the Moon pack.

View File

@@ -335,8 +335,8 @@ class CVCotMPatchExtensions(APPatchExtension):
rom_data.write_bytes(0x679A60, patches.kickless_roc_height_shortener) rom_data.write_bytes(0x679A60, patches.kickless_roc_height_shortener)
# Give the player their Start Inventory upon entering their name on a new file. # Give the player their Start Inventory upon entering their name on a new file.
rom_data.write_bytes(0x7F70, [0x00, 0x48, 0x87, 0x46, 0x00, 0x00, 0x69, 0x08]) rom_data.write_bytes(0x7F70, [0x00, 0x48, 0x87, 0x46, 0x00, 0x00, 0x68, 0x08])
rom_data.write_bytes(0x690000, patches.start_inventory_giver) rom_data.write_bytes(0x680000, patches.start_inventory_giver)
# Prevent Max Ups from exceeding 255. # Prevent Max Ups from exceeding 255.
rom_data.write_bytes(0x5E170, [0x00, 0x4A, 0x97, 0x46, 0x00, 0x00, 0x6A, 0x08]) rom_data.write_bytes(0x5E170, [0x00, 0x4A, 0x97, 0x46, 0x00, 0x00, 0x6A, 0x08])

View File

@@ -511,7 +511,7 @@ _vanilla_items = [
DS3ItemData("Elkhorn Round Shield", 0x0133C510, DS3ItemCategory.SHIELD_INFUSIBLE), DS3ItemData("Elkhorn Round Shield", 0x0133C510, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Warrior's Round Shield", 0x0133EC20, DS3ItemCategory.SHIELD_INFUSIBLE), DS3ItemData("Warrior's Round Shield", 0x0133EC20, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Caduceus Round Shield", 0x01341330, DS3ItemCategory.SHIELD_INFUSIBLE), DS3ItemData("Caduceus Round Shield", 0x01341330, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Red and White Round Shield", 0x01343A40, DS3ItemCategory.SHIELD_INFUSIBLE), DS3ItemData("Red and White Shield", 0x01343A40, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Blessed Red and White Shield+1", 0x01343FB9, DS3ItemCategory.SHIELD), DS3ItemData("Blessed Red and White Shield+1", 0x01343FB9, DS3ItemCategory.SHIELD),
DS3ItemData("Plank Shield", 0x01346150, DS3ItemCategory.SHIELD_INFUSIBLE), DS3ItemData("Plank Shield", 0x01346150, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Leather Shield", 0x01348860, DS3ItemCategory.SHIELD_INFUSIBLE), DS3ItemData("Leather Shield", 0x01348860, DS3ItemCategory.SHIELD_INFUSIBLE),

Some files were not shown because too many files have changed in this diff Show More