Skip to content

Commit 4c5cd48

Browse files
el-uFlySniper
authored andcommitted
lufia2ac: coop support + update AP version number to 0.4.2 (ArchipelagoMW#1868)
* Core: typing for async_start * CommonClient: add a framework for clients to subscribe to data storage key notifications * Core: update version to 0.4.2 * lufia2ac: coop support
1 parent 0e0179d commit 4c5cd48

11 files changed

+220
-80
lines changed

CommonClient.py

+32
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ class CommonContext:
191191
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
192192
locations_info: typing.Dict[int, NetworkItem]
193193

194+
# data storage
195+
stored_data: typing.Dict[str, typing.Any]
196+
stored_data_notification_keys: typing.Set[str]
197+
194198
# internals
195199
# current message box through kvui
196200
_messagebox: typing.Optional["kvui.MessageBox"] = None
@@ -226,6 +230,9 @@ def __init__(self, server_address: typing.Optional[str], password: typing.Option
226230
self.server_locations = set() # all locations the server knows of, missing_location | checked_locations
227231
self.locations_info = {}
228232

233+
self.stored_data = {}
234+
self.stored_data_notification_keys = set()
235+
229236
self.input_queue = asyncio.Queue()
230237
self.input_requests = 0
231238

@@ -467,6 +474,21 @@ def consume_network_data_package(self, data_package: dict):
467474
for game, game_data in data_package["games"].items():
468475
Utils.store_data_package_for_checksum(game, game_data)
469476

477+
# data storage
478+
479+
def set_notify(self, *keys: str) -> None:
480+
"""Subscribe to be notified of changes to selected data storage keys.
481+
482+
The values can be accessed via the "stored_data" attribute of this context, which is a dictionary mapping the
483+
names of the data storage keys to the latest values received from the server.
484+
"""
485+
if new_keys := (set(keys) - self.stored_data_notification_keys):
486+
self.stored_data_notification_keys.update(new_keys)
487+
async_start(self.send_msgs([{"cmd": "Get",
488+
"keys": list(new_keys)},
489+
{"cmd": "SetNotify",
490+
"keys": list(new_keys)}]))
491+
470492
# DeathLink hooks
471493

472494
def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
@@ -737,6 +759,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
737759
if ctx.locations_scouted:
738760
msgs.append({"cmd": "LocationScouts",
739761
"locations": list(ctx.locations_scouted)})
762+
if ctx.stored_data_notification_keys:
763+
msgs.append({"cmd": "Get",
764+
"keys": list(ctx.stored_data_notification_keys)})
765+
msgs.append({"cmd": "SetNotify",
766+
"keys": list(ctx.stored_data_notification_keys)})
740767
if msgs:
741768
await ctx.send_msgs(msgs)
742769
if ctx.finished_game:
@@ -800,7 +827,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
800827
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
801828
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
802829
ctx.on_deathlink(args["data"])
830+
831+
elif cmd == "Retrieved":
832+
ctx.stored_data.update(args["keys"])
833+
803834
elif cmd == "SetReply":
835+
ctx.stored_data[args["key"]] = args["value"]
804836
if args["key"] == "EnergyLink":
805837
ctx.current_energy_link_value = args["value"]
806838
if ctx.ui:

Utils.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def as_simple_string(self) -> str:
4242
return ".".join(str(item) for item in self)
4343

4444

45-
__version__ = "0.4.1"
45+
__version__ = "0.4.2"
4646
version_tuple = tuplize_version(__version__)
4747

4848
is_linux = sys.platform.startswith("linux")
@@ -766,10 +766,10 @@ def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
766766
return buffer
767767

768768

769-
_faf_tasks: "Set[asyncio.Task[None]]" = set()
769+
_faf_tasks: "Set[asyncio.Task[typing.Any]]" = set()
770770

771771

772-
def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None:
772+
def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = None) -> None:
773773
"""
774774
Use this to start a task when you don't keep a reference to it or immediately await it,
775775
to prevent early garbage collection. "fire-and-forget"
@@ -782,7 +782,7 @@ def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str]
782782
# ```
783783
# This implementation follows the pattern given in that documentation.
784784

785-
task = asyncio.create_task(co, name=name)
785+
task: asyncio.Task[typing.Any] = asyncio.create_task(co, name=name)
786786
_faf_tasks.add(task)
787787
task.add_done_callback(_faf_tasks.discard)
788788

worlds/lufia2ac/Client.py

+53-21
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import logging
22
import time
33
import typing
4+
import uuid
45
from logging import Logger
5-
from typing import Optional
6+
from typing import Dict, List, Optional
67

78
from NetUtils import ClientStatus, NetworkItem
89
from worlds.AutoSNIClient import SNIClient
910
from .Enemies import enemy_id_to_name
1011
from .Items import start_id as items_start_id
1112
from .Locations import start_id as locations_start_id
13+
from .Options import BlueChestCount
1214

1315
if typing.TYPE_CHECKING:
1416
from SNIClient import SNIContext
@@ -59,6 +61,18 @@ async def game_watcher(self, ctx: SNIContext) -> None:
5961
if signature != b"ArchipelagoLufia":
6062
return
6163

64+
uuid_data: Optional[bytes] = await snes_read(ctx, L2AC_TX_ADDR + 16, 16)
65+
if uuid_data is None:
66+
return
67+
68+
coop_uuid: uuid.UUID = uuid.UUID(bytes=uuid_data)
69+
if coop_uuid.version != 4:
70+
coop_uuid = uuid.uuid4()
71+
snes_buffered_write(ctx, L2AC_TX_ADDR + 16, coop_uuid.bytes)
72+
73+
blue_chests_key: str = f"lufia2ac_blue_chests_checked_T{ctx.team}_P{ctx.slot}"
74+
ctx.set_notify(blue_chests_key)
75+
6276
# Goal
6377
if not ctx.finished_game:
6478
goal_data: Optional[bytes] = await snes_read(ctx, L2AC_GOAL_ADDR, 10)
@@ -78,29 +92,47 @@ async def game_watcher(self, ctx: SNIContext) -> None:
7892
await ctx.send_death(f"{player_name} was totally defeated by {enemy_name}.")
7993

8094
# TX
81-
tx_data: Optional[bytes] = await snes_read(ctx, L2AC_TX_ADDR, 8)
95+
tx_data: Optional[bytes] = await snes_read(ctx, L2AC_TX_ADDR, 12)
8296
if tx_data is not None:
83-
snes_items_sent = int.from_bytes(tx_data[:2], "little")
84-
client_items_sent = int.from_bytes(tx_data[2:4], "little")
85-
client_ap_items_found = int.from_bytes(tx_data[4:6], "little")
86-
87-
if client_items_sent < snes_items_sent:
88-
location_id: int = locations_start_id + client_items_sent
89-
location: str = ctx.location_names[location_id]
90-
client_items_sent += 1
91-
97+
snes_blue_chests_checked: int = int.from_bytes(tx_data[:2], "little")
98+
snes_ap_items_found: int = int.from_bytes(tx_data[6:8], "little")
99+
snes_other_locations_checked: int = int.from_bytes(tx_data[10:12], "little")
100+
101+
blue_chests_checked: Dict[str, int] = ctx.stored_data.get(blue_chests_key) or {}
102+
if blue_chests_checked.get(str(coop_uuid), 0) < snes_blue_chests_checked:
103+
blue_chests_checked[str(coop_uuid)] = snes_blue_chests_checked
104+
if blue_chests_key in ctx.stored_data:
105+
await ctx.send_msgs([{
106+
"cmd": "Set",
107+
"key": blue_chests_key,
108+
"default": {},
109+
"want_reply": True,
110+
"operations": [{
111+
"operation": "update",
112+
"value": {str(coop_uuid): snes_blue_chests_checked},
113+
}],
114+
}])
115+
116+
total_blue_chests_checked: int = min(sum(blue_chests_checked.values()), BlueChestCount.range_end)
117+
snes_buffered_write(ctx, L2AC_TX_ADDR + 8, total_blue_chests_checked.to_bytes(2, "little"))
118+
location_ids: List[int] = [locations_start_id + i for i in range(total_blue_chests_checked)]
119+
120+
loc_data: Optional[bytes] = await snes_read(ctx, L2AC_TX_ADDR + 32, snes_other_locations_checked * 2)
121+
if loc_data is not None:
122+
location_ids.extend(locations_start_id + int.from_bytes(loc_data[2 * i:2 * i + 2], "little")
123+
for i in range(snes_other_locations_checked))
124+
125+
if new_location_ids := [loc_id for loc_id in location_ids if loc_id not in ctx.locations_checked]:
126+
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": new_location_ids}])
127+
for location_id in new_location_ids:
92128
ctx.locations_checked.add(location_id)
93-
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": [location_id]}])
94-
95-
snes_logger.info("New Check: %s (%d/%d)" % (
96-
location,
97-
len(ctx.locations_checked),
98-
len(ctx.missing_locations) + len(ctx.checked_locations)))
99-
snes_buffered_write(ctx, L2AC_TX_ADDR + 2, client_items_sent.to_bytes(2, "little"))
129+
snes_logger.info("%d/%d blue chests" % (
130+
len(list(loc for loc in ctx.locations_checked if not loc & 0x100)),
131+
len(list(loc for loc in ctx.missing_locations | ctx.checked_locations if not loc & 0x100))))
100132

101-
ap_items_found: int = sum(net_item.player != ctx.slot for net_item in ctx.locations_info.values())
102-
if client_ap_items_found < ap_items_found:
103-
snes_buffered_write(ctx, L2AC_TX_ADDR + 4, ap_items_found.to_bytes(2, "little"))
133+
client_ap_items_found: int = sum(net_item.player != ctx.slot for net_item in ctx.locations_info.values())
134+
if client_ap_items_found > snes_ap_items_found:
135+
snes_buffered_write(ctx, L2AC_TX_ADDR + 4, client_ap_items_found.to_bytes(2, "little"))
104136

105137
# RX
106138
rx_data: Optional[bytes] = await snes_read(ctx, L2AC_RX_ADDR, 4)

worlds/lufia2ac/Items.py

+12-10
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99

1010
class ItemType(Enum):
1111
BLUE_CHEST = auto()
12+
BOSS = auto()
1213
CAPSULE_MONSTER = auto()
1314
ENEMY_DROP = auto()
1415
ENTRANCE_CHEST = auto()
16+
IRIS_TREASURE = auto()
1517
PARTY_MEMBER = auto()
1618
RED_CHEST = auto()
1719
RED_CHEST_PATCH = auto()
@@ -451,15 +453,15 @@ def __init__(self, name: str, classification: ItemClassification, code: Optional
451453
# 0x0199: "Bunnysuit"
452454
# 0x019A: "Seethru cape"
453455
# 0x019B: "Seethru silk"
454-
# 0x019C: "Iris sword"
455-
# 0x019D: "Iris shield"
456-
# 0x019E: "Iris helmet"
457-
# 0x019F: "Iris armor"
458-
# 0x01A0: "Iris ring"
459-
# 0x01A1: "Iris jewel"
460-
# 0x01A2: "Iris staff"
461-
# 0x01A3: "Iris pot"
462-
# 0x01A4: "Iris tiara"
456+
"Iris sword": ItemData(0x039C, ItemType.IRIS_TREASURE, ItemClassification.progression_skip_balancing),
457+
"Iris shield": ItemData(0x039D, ItemType.IRIS_TREASURE, ItemClassification.progression_skip_balancing),
458+
"Iris helmet": ItemData(0x039E, ItemType.IRIS_TREASURE, ItemClassification.progression_skip_balancing),
459+
"Iris armor": ItemData(0x039F, ItemType.IRIS_TREASURE, ItemClassification.progression_skip_balancing),
460+
"Iris ring": ItemData(0x03A0, ItemType.IRIS_TREASURE, ItemClassification.progression_skip_balancing),
461+
"Iris jewel": ItemData(0x03A1, ItemType.IRIS_TREASURE, ItemClassification.progression_skip_balancing),
462+
"Iris staff": ItemData(0x03A2, ItemType.IRIS_TREASURE, ItemClassification.progression_skip_balancing),
463+
"Iris pot": ItemData(0x03A3, ItemType.IRIS_TREASURE, ItemClassification.progression_skip_balancing),
464+
"Iris tiara": ItemData(0x03A4, ItemType.IRIS_TREASURE, ItemClassification.progression_skip_balancing),
463465
# 0x01A5: "Power jelly"
464466
# 0x01A6: "Jewel sonar"
465467
# 0x01A7: "Hook"
@@ -489,7 +491,7 @@ def __init__(self, name: str, classification: ItemClassification, code: Optional
489491
# 0x01BF: "Truth key"
490492
# 0x01C0: "Mermaid jade"
491493
# 0x01C1: "Engine"
492-
# 0x01C2: "Ancient key"
494+
"Ancient key": ItemData(0x01C2, ItemType.BOSS, ItemClassification.progression_skip_balancing),
493495
# 0x01C3: "Pretty flwr."
494496
# 0x01C4: "Glass angel"
495497
# 0x01C5: "VIP card"

worlds/lufia2ac/Locations.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
from typing import Dict
22

33
from BaseClasses import Location
4+
from .Options import BlueChestCount
45

56
start_id: int = 0xAC0000
6-
l2ac_location_name_to_id: Dict[str, int] = {f"Blue chest {i + 1}": (start_id + i) for i in range(88)}
7+
8+
l2ac_location_name_to_id: Dict[str, int] = {
9+
**{f"Blue chest {i + 1}": (start_id + i) for i in range(BlueChestCount.range_end + 7 + 6)},
10+
**{f"Iris treasure {i + 1}": (start_id + 0x039C + i) for i in range(9)},
11+
"Boss": start_id + 0x01C2,
12+
}
713

814

915
class L2ACLocation(Location):

worlds/lufia2ac/Options.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,13 @@ class BlueChestCount(Range):
109109
more for each party member or capsule monster if you have shuffle_party_members/shuffle_capsule_monsters enabled.
110110
(You will still encounter blue chests in your world after all the multiworld location checks have been exhausted,
111111
but these chests will then generate items for yourself only.)
112-
Supported values: 10 – 75
112+
Supported values: 10 – 100
113113
Default value: 25
114114
"""
115115

116116
display_name = "Blue chest count"
117117
range_start = 10
118-
range_end = 75
118+
range_end = 100
119119
default = 25
120120

121121

worlds/lufia2ac/__init__.py

+19-13
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from random import Random
66
from typing import Any, ClassVar, Dict, get_type_hints, Iterator, List, Set, Tuple
77

8-
from BaseClasses import Entrance, Item, ItemClassification, MultiWorld, Region, Tutorial
8+
from BaseClasses import Entrance, Item, ItemClassification, Location, MultiWorld, Region, Tutorial
99
from Options import AssembleOptions
1010
from Utils import __version__
1111
from worlds.AutoWorld import WebWorld, World
@@ -50,10 +50,11 @@ class L2ACWorld(World):
5050
item_name_groups: ClassVar[Dict[str, Set[str]]] = {
5151
"Blue chest items": {name for name, data in l2ac_item_table.items() if data.type is ItemType.BLUE_CHEST},
5252
"Capsule monsters": {name for name, data in l2ac_item_table.items() if data.type is ItemType.CAPSULE_MONSTER},
53+
"Iris treasures": {name for name, data in l2ac_item_table.items() if data.type is ItemType.IRIS_TREASURE},
5354
"Party members": {name for name, data in l2ac_item_table.items() if data.type is ItemType.PARTY_MEMBER},
5455
}
55-
data_version: ClassVar[int] = 1
56-
required_client_version: Tuple[int, int, int] = (0, 3, 6)
56+
data_version: ClassVar[int] = 2
57+
required_client_version: Tuple[int, int, int] = (0, 4, 2)
5758

5859
# L2ACWorld specific properties
5960
rom_name: bytearray
@@ -107,17 +108,20 @@ def create_regions(self) -> None:
107108
L2ACLocation(self.player, f"Chest access {i + 1}-{i + CHESTS_PER_SPHERE}", None, ancient_dungeon)
108109
chest_access.place_locked_item(prog_chest_access)
109110
ancient_dungeon.locations.append(chest_access)
110-
treasures = L2ACLocation(self.player, "Iris Treasures", None, ancient_dungeon)
111-
treasures.place_locked_item(L2ACItem("Treasures collected", ItemClassification.progression, None, self.player))
112-
ancient_dungeon.locations.append(treasures)
111+
for iris in self.item_name_groups["Iris treasures"]:
112+
treasure_name: str = f"Iris treasure {self.item_name_to_id[iris] - self.item_name_to_id['Iris sword'] + 1}"
113+
iris_treasure: Location = \
114+
L2ACLocation(self.player, treasure_name, self.location_name_to_id[treasure_name], ancient_dungeon)
115+
iris_treasure.place_locked_item(self.create_item(iris))
116+
ancient_dungeon.locations.append(iris_treasure)
113117
self.multiworld.regions.append(ancient_dungeon)
114118

115119
final_floor = Region("FinalFloor", self.player, self.multiworld, "Ancient Cave Final Floor")
116120
ff_reached = L2ACLocation(self.player, "Final Floor reached", None, final_floor)
117121
ff_reached.place_locked_item(L2ACItem("Final Floor access", ItemClassification.progression, None, self.player))
118122
final_floor.locations.append(ff_reached)
119-
boss = L2ACLocation(self.player, "Boss", None, final_floor)
120-
boss.place_locked_item(L2ACItem("Boss victory", ItemClassification.progression, None, self.player))
123+
boss: Location = L2ACLocation(self.player, "Boss", self.location_name_to_id["Boss"], final_floor)
124+
boss.place_locked_item(self.create_item("Ancient key"))
121125
final_floor.locations.append(boss)
122126
self.multiworld.regions.append(final_floor)
123127

@@ -155,8 +159,9 @@ def set_rules(self) -> None:
155159

156160
set_rule(self.multiworld.get_entrance("FinalFloorEntrance", self.player),
157161
lambda state: state.can_reach(f"Blue chest {self.o.blue_chest_count}", "Location", self.player))
158-
set_rule(self.multiworld.get_location("Iris Treasures", self.player),
159-
lambda state: state.can_reach(f"Blue chest {self.o.blue_chest_count}", "Location", self.player))
162+
for i in range(9):
163+
set_rule(self.multiworld.get_location(f"Iris treasure {i + 1}", self.player),
164+
lambda state: state.can_reach(f"Blue chest {self.o.blue_chest_count}", "Location", self.player))
160165
set_rule(self.multiworld.get_location("Boss", self.player),
161166
lambda state: state.can_reach(f"Blue chest {self.o.blue_chest_count}", "Location", self.player))
162167
if self.o.shuffle_capsule_monsters:
@@ -170,13 +175,14 @@ def set_rules(self) -> None:
170175
lambda state: state.has("Final Floor access", self.player)
171176
elif self.o.goal == Goal.option_iris_treasure_hunt:
172177
self.multiworld.completion_condition[self.player] = \
173-
lambda state: state.has("Treasures collected", self.player)
178+
lambda state: state.has_group("Iris treasures", self.player, int(self.o.iris_treasures_required))
174179
elif self.o.goal == Goal.option_boss:
175180
self.multiworld.completion_condition[self.player] = \
176-
lambda state: state.has("Boss victory", self.player)
181+
lambda state: state.has("Ancient key", self.player)
177182
elif self.o.goal == Goal.option_boss_iris_treasure_hunt:
178183
self.multiworld.completion_condition[self.player] = \
179-
lambda state: state.has("Boss victory", self.player) and state.has("Treasures collected", self.player)
184+
lambda state: (state.has("Ancient key", self.player) and
185+
state.has_group("Iris treasures", self.player, int(self.o.iris_treasures_required)))
180186

181187
def generate_output(self, output_directory: str) -> None:
182188
rom_path: str = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.sfc")

0 commit comments

Comments
 (0)