mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-04-10 02:18:14 -07:00
Compare commits
1 Commits
NewSoupVi-
...
revert-491
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9592af1285 |
2
.github/workflows/label-pull-requests.yml
vendored
2
.github/workflows/label-pull-requests.yml
vendored
@@ -6,8 +6,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -56,6 +56,7 @@ success.txt
|
||||
output/
|
||||
Output Logs/
|
||||
/factorio/
|
||||
/Minecraft Forge Server/
|
||||
/WebHostLib/static/generated
|
||||
/freeze_requirements.txt
|
||||
/Archipelago.zip
|
||||
@@ -183,6 +184,12 @@ _speedups.c
|
||||
_speedups.cpp
|
||||
_speedups.html
|
||||
|
||||
# minecraft server stuff
|
||||
jdk*/
|
||||
minecraft*/
|
||||
minecraft_versions.json
|
||||
!worlds/minecraft/
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from typing import List
|
||||
|
||||
|
||||
import Utils
|
||||
from settings import get_settings
|
||||
from NetUtils import ClientStatus
|
||||
from Utils import async_start
|
||||
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
|
||||
@@ -81,8 +80,8 @@ class AdventureContext(CommonContext):
|
||||
self.local_item_locations = {}
|
||||
self.dragon_speed_info = {}
|
||||
|
||||
options = get_settings().adventure_options
|
||||
self.display_msgs = options.display_msgs
|
||||
options = Utils.get_settings()
|
||||
self.display_msgs = options["adventure_options"]["display_msgs"]
|
||||
|
||||
async def server_auth(self, password_requested: bool = False):
|
||||
if password_requested and not self.password:
|
||||
@@ -103,7 +102,7 @@ class AdventureContext(CommonContext):
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == 'Connected':
|
||||
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
|
||||
async_start(self.get_freeincarnates_used())
|
||||
elif cmd == "RoomInfo":
|
||||
@@ -416,9 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
options = get_settings().adventure_options
|
||||
auto_start = options.rom_start
|
||||
rom_args = options.rom_args
|
||||
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
|
||||
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
|
||||
@@ -439,7 +439,7 @@ class MultiWorld():
|
||||
return self.regions.location_cache[player][location_name]
|
||||
|
||||
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False,
|
||||
collect_pre_fill_items: bool = True, perform_sweep: bool = True) -> CollectionState:
|
||||
collect_pre_fill_items: bool = True) -> CollectionState:
|
||||
cached = getattr(self, "_all_state", None)
|
||||
if use_cache and cached:
|
||||
return cached.copy()
|
||||
@@ -453,8 +453,7 @@ class MultiWorld():
|
||||
subworld = self.worlds[player]
|
||||
for item in subworld.get_pre_fill_items():
|
||||
subworld.collect(ret, item)
|
||||
if perform_sweep:
|
||||
ret.sweep_for_advancements()
|
||||
ret.sweep_for_advancements()
|
||||
|
||||
if use_cache:
|
||||
self._all_state = ret
|
||||
@@ -559,9 +558,7 @@ class MultiWorld():
|
||||
else:
|
||||
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
|
||||
|
||||
def can_beat_game(self,
|
||||
starting_state: Optional[CollectionState] = None,
|
||||
locations: Optional[Iterable[Location]] = None) -> bool:
|
||||
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
|
||||
if starting_state:
|
||||
if self.has_beaten_game(starting_state):
|
||||
return True
|
||||
@@ -570,9 +567,7 @@ class MultiWorld():
|
||||
state = CollectionState(self)
|
||||
if self.has_beaten_game(state):
|
||||
return True
|
||||
|
||||
base_locations = self.get_locations() if locations is None else locations
|
||||
prog_locations = {location for location in base_locations if location.item
|
||||
prog_locations = {location for location in self.get_locations() if location.item
|
||||
and location.item.advancement and location not in state.locations_checked}
|
||||
|
||||
while prog_locations:
|
||||
@@ -1337,8 +1332,8 @@ class Region:
|
||||
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,
|
||||
created entrances will be named "self.name -> connecting_region"
|
||||
:param rules: rules for the exits from this region. format is {"connecting_region": rule}
|
||||
created entrances will be named "self.name -> connecting_region"
|
||||
:param rules: rules for the exits from this region. format is {"connecting_region", rule}
|
||||
"""
|
||||
if not isinstance(exits, Dict):
|
||||
exits = dict.fromkeys(exits)
|
||||
@@ -1607,19 +1602,21 @@ class Spoiler:
|
||||
|
||||
# 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
|
||||
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))):
|
||||
to_delete: Set[Location] = set()
|
||||
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,
|
||||
location.item.player)
|
||||
required_locations.remove(location)
|
||||
if multiworld.can_beat_game(state_cache[num], required_locations):
|
||||
old_item = location.item
|
||||
location.item = None
|
||||
if multiworld.can_beat_game(state_cache[num]):
|
||||
to_delete.add(location)
|
||||
restore_later[location] = old_item
|
||||
else:
|
||||
# still required, got to keep it around
|
||||
required_locations.add(location)
|
||||
location.item = old_item
|
||||
|
||||
# cull entries in spheres for spoiler walkthrough at end
|
||||
sphere -= to_delete
|
||||
@@ -1636,7 +1633,7 @@ class Spoiler:
|
||||
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
|
||||
precollected_items.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`.
|
||||
multiworld.push_precollected(item)
|
||||
else:
|
||||
@@ -1678,6 +1675,9 @@ class Spoiler:
|
||||
self.create_paths(state, collection_spheres)
|
||||
|
||||
# repair the multiworld again
|
||||
for location, item in restore_later.items():
|
||||
location.item = item
|
||||
|
||||
for item in removed_precollected:
|
||||
multiworld.push_precollected(item)
|
||||
|
||||
|
||||
@@ -266,71 +266,38 @@ class CommonContext:
|
||||
last_death_link: float = time.time() # last send/received death link on AP layer
|
||||
|
||||
# remaining type info
|
||||
slot_info: dict[int, NetworkSlot]
|
||||
"""Slot Info from the server for the current connection"""
|
||||
server_address: str | None
|
||||
"""Autoconnect address provided by the ctx constructor"""
|
||||
password: str | None
|
||||
"""Password used for Connecting, expected by server_auth"""
|
||||
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)"""
|
||||
slot_info: typing.Dict[int, NetworkSlot]
|
||||
server_address: typing.Optional[str]
|
||||
password: typing.Optional[str]
|
||||
hint_cost: typing.Optional[int]
|
||||
hint_points: typing.Optional[int]
|
||||
player_names: typing.Dict[int, str]
|
||||
|
||||
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
|
||||
"""Bool to keep track of state for the /ready command"""
|
||||
team: int | None
|
||||
"""Team number of currently connected slot"""
|
||||
slot: int | None
|
||||
"""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"""
|
||||
team: typing.Optional[int]
|
||||
slot: typing.Optional[int]
|
||||
auth: typing.Optional[str]
|
||||
seed_name: typing.Optional[str]
|
||||
|
||||
# locations
|
||||
locations_checked: set[int]
|
||||
"""
|
||||
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting
|
||||
to be used to ensure that a LocationChecks packet does not get lost when disconnected
|
||||
"""
|
||||
locations_scouted: set[int]
|
||||
"""
|
||||
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"""
|
||||
locations_checked: typing.Set[int] # local state
|
||||
locations_scouted: typing.Set[int]
|
||||
items_received: typing.List[NetworkItem]
|
||||
missing_locations: typing.Set[int] # server state
|
||||
checked_locations: typing.Set[int] # server state
|
||||
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
|
||||
locations_info: typing.Dict[int, NetworkItem]
|
||||
|
||||
# data storage
|
||||
stored_data: dict[str, typing.Any]
|
||||
"""
|
||||
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"""
|
||||
stored_data: typing.Dict[str, typing.Any]
|
||||
stored_data_notification_keys: typing.Set[str]
|
||||
|
||||
# internals
|
||||
# current message box through kvui
|
||||
_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
|
||||
"""Message box reporting a loss of connection"""
|
||||
|
||||
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
|
||||
# server state
|
||||
|
||||
267
FF1Client.py
Normal file
267
FF1Client.py
Normal 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()
|
||||
11
Fill.py
11
Fill.py
@@ -890,7 +890,7 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
|
||||
worlds = set()
|
||||
for listed_world in target_world:
|
||||
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)
|
||||
continue
|
||||
worlds.add(world_name_lookup[listed_world])
|
||||
@@ -923,9 +923,9 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
|
||||
if isinstance(locations, str):
|
||||
locations = [locations]
|
||||
|
||||
locations_from_groups: list[str] = []
|
||||
resolved_locations: list[Location] = []
|
||||
for target_player in worlds:
|
||||
locations_from_groups: list[str] = []
|
||||
world_locations = multiworld.get_unfilled_locations(target_player)
|
||||
for group in multiworld.worlds[target_player].location_name_groups:
|
||||
if group in locations:
|
||||
@@ -937,16 +937,13 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
|
||||
|
||||
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))
|
||||
count = 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))
|
||||
|
||||
count["max"] = len(new_block.items)
|
||||
|
||||
new_block.count = count
|
||||
plando_blocks[player].append(new_block)
|
||||
|
||||
99
Launcher.py
99
Launcher.py
@@ -11,7 +11,6 @@ Additional components can be added to worlds.LauncherComponents.components.
|
||||
import argparse
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -42,17 +41,13 @@ def open_host_yaml():
|
||||
if is_linux:
|
||||
exe = which('sensible-editor') or which('gedit') or \
|
||||
which('xdg-open') or which('gnome-open') or which('kde-open')
|
||||
subprocess.Popen([exe, file])
|
||||
elif is_macos:
|
||||
exe = which("open")
|
||||
subprocess.Popen([exe, file])
|
||||
else:
|
||||
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():
|
||||
suffixes = []
|
||||
@@ -97,11 +92,7 @@ def open_folder(folder_path):
|
||||
return
|
||||
|
||||
if exe:
|
||||
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, folder_path], env=env)
|
||||
subprocess.Popen([exe, folder_path])
|
||||
else:
|
||||
logging.warning(f"No file browser available to open {folder_path}")
|
||||
|
||||
@@ -113,48 +104,45 @@ def update_settings():
|
||||
|
||||
components.extend([
|
||||
# Functions
|
||||
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,
|
||||
description="Open a patch file, downloaded from the room page or provided by the host."),
|
||||
Component("Generate Template Options", func=generate_yamls,
|
||||
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("Open host.yaml", func=open_host_yaml),
|
||||
Component("Open Patch", func=open_patch),
|
||||
Component("Generate Template Options", func=generate_yamls),
|
||||
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
|
||||
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
|
||||
Component("Unrated/18+ Discord Server", icon="discord",
|
||||
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,
|
||||
description="Open the Archipelago installation folder in your file browser."),
|
||||
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
|
||||
Component("Browse Files", func=browse_files),
|
||||
])
|
||||
|
||||
|
||||
def handle_uri(path: str) -> tuple[list[Component], Component]:
|
||||
def handle_uri(path: str, launch_args: tuple[str, ...]) -> None:
|
||||
url = urllib.parse.urlparse(path)
|
||||
queries = urllib.parse.parse_qs(url.query)
|
||||
client_components = []
|
||||
launch_args = (path, *launch_args)
|
||||
client_component = []
|
||||
text_client_component = None
|
||||
game = queries["game"][0]
|
||||
for component in components:
|
||||
if component.supports_uri and component.game_name == game:
|
||||
client_components.append(component)
|
||||
client_component.append(component)
|
||||
elif component.display_name == "Text Client":
|
||||
text_client_component = component
|
||||
return client_components, text_client_component
|
||||
|
||||
|
||||
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
|
||||
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()
|
||||
if not client_component:
|
||||
run_component(text_client_component, *launch_args)
|
||||
return
|
||||
else:
|
||||
from kvui import ButtonsPrompt
|
||||
component_options = {
|
||||
text_client_component.display_name: text_client_component,
|
||||
**{component.display_name: component for component in client_component}
|
||||
}
|
||||
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]:
|
||||
@@ -196,8 +184,7 @@ def get_exe(component: str | Component) -> Sequence[str] | None:
|
||||
def launch(exe, in_terminal=False):
|
||||
if in_terminal:
|
||||
if is_windows:
|
||||
# intentionally using a window title with a space so it gets quoted and treated as a title
|
||||
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
|
||||
subprocess.Popen(['start', *exe], shell=True)
|
||||
return
|
||||
elif is_linux:
|
||||
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
|
||||
@@ -225,7 +212,7 @@ def create_shortcut(button: Any, component: Component) -> None:
|
||||
refresh_components: Callable[[], None] | 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 kivy.properties import ObjectProperty
|
||||
from kivy.core.window import Window
|
||||
@@ -258,12 +245,12 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
|
||||
cards: list[LauncherCard]
|
||||
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.ctx = ctx
|
||||
self.icon = r"data/icon.png"
|
||||
self.favorites = []
|
||||
self.launch_components = components
|
||||
self.launch_uri = path
|
||||
self.launch_args = args
|
||||
self.cards = []
|
||||
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
|
||||
@@ -385,9 +372,9 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
|
||||
return self.top_screen
|
||||
|
||||
def on_start(self):
|
||||
if self.launch_components:
|
||||
build_uri_popup(self.launch_components, self.launch_args)
|
||||
self.launch_components = None
|
||||
if self.launch_uri:
|
||||
handle_uri(self.launch_uri, self.launch_args)
|
||||
self.launch_uri = None
|
||||
self.launch_args = None
|
||||
|
||||
@staticmethod
|
||||
@@ -405,7 +392,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
|
||||
if file and component:
|
||||
run_component(component, file)
|
||||
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]):
|
||||
# Activate search as soon as we start typing, no matter if we are focused on the search box or not.
|
||||
@@ -428,7 +415,7 @@ def run_gui(launch_components: list[Component], args: Any) -> None:
|
||||
for filter in self.current_filter))
|
||||
super().on_stop()
|
||||
|
||||
Launcher(components=launch_components, args=args).run()
|
||||
Launcher(path=path, args=args).run()
|
||||
|
||||
# avoiding Launcher reference leak
|
||||
# and don't try to do something with widgets after window closed
|
||||
@@ -455,15 +442,7 @@ def main(args: argparse.Namespace | dict | None = None):
|
||||
|
||||
path = args.get("Patch|Game|Component|url", None)
|
||||
if path is not None:
|
||||
if 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:
|
||||
if not path.startswith("archipelago://"):
|
||||
file, component = identify(path)
|
||||
if file:
|
||||
args['file'] = file
|
||||
@@ -479,7 +458,7 @@ def main(args: argparse.Namespace | dict | None = None):
|
||||
elif "component" in args:
|
||||
run_component(args["component"], *args["args"])
|
||||
elif not args["update_settings"]:
|
||||
run_gui(args.get("launch_components", None), args.get("args", ()))
|
||||
run_gui(path, args.get("args", ()))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -290,9 +290,12 @@ async def gba_sync_task(ctx: MMBN3Context):
|
||||
|
||||
|
||||
async def run_game(romfile):
|
||||
from worlds.mmbn3 import MMBN3World
|
||||
auto_start = MMBN3World.settings.rom_start
|
||||
if auto_start is True:
|
||||
options = Utils.get_options().get("mmbn3_options", None)
|
||||
if options is None:
|
||||
auto_start = True
|
||||
else:
|
||||
auto_start = options.get("rom_start", True)
|
||||
if auto_start:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
elif os.path.isfile(auto_start):
|
||||
|
||||
347
MinecraftClient.py
Normal file
347
MinecraftClient.py
Normal file
@@ -0,0 +1,347 @@
|
||||
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
|
||||
from settings import get_settings
|
||||
|
||||
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.java)
|
||||
if not jdk_exe:
|
||||
jdk_exe = shutil.which("java") # try to fall back to system 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 = get_settings().minecraft_options
|
||||
channel = args.channel or 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.forge_directory
|
||||
max_heap = 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()
|
||||
@@ -458,12 +458,8 @@ class Context:
|
||||
self.generator_version = Version(*decoded_obj["version"])
|
||||
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
|
||||
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():
|
||||
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.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
|
||||
|
||||
@@ -1524,11 +1524,9 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
|
||||
f"dictionary, not {type(items)}")
|
||||
locations = item.get("locations", [])
|
||||
if not locations:
|
||||
locations = item.get("location", [])
|
||||
locations = item.get("location", ["Everywhere"])
|
||||
if locations:
|
||||
count = 1
|
||||
else:
|
||||
locations = ["Everywhere"]
|
||||
if isinstance(locations, str):
|
||||
locations = [locations]
|
||||
if not isinstance(locations, list):
|
||||
|
||||
@@ -7,6 +7,7 @@ Currently, the following games are supported:
|
||||
|
||||
* The Legend of Zelda: A Link to the Past
|
||||
* Factorio
|
||||
* Minecraft
|
||||
* Subnautica
|
||||
* Risk of Rain 2
|
||||
* The Legend of Zelda: Ocarina of Time
|
||||
@@ -81,7 +82,6 @@ Currently, the following games are supported:
|
||||
* 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/).
|
||||
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
|
||||
|
||||
45
Utils.py
45
Utils.py
@@ -166,10 +166,6 @@ def home_path(*path: str) -> str:
|
||||
os.symlink(home_path.cached_path, legacy_home_path)
|
||||
else:
|
||||
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:
|
||||
# not implemented
|
||||
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
|
||||
@@ -181,7 +177,7 @@ def user_path(*path: str) -> str:
|
||||
"""Returns either local_path or home_path based on write permissions."""
|
||||
if hasattr(user_path, "cached_path"):
|
||||
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()
|
||||
else:
|
||||
user_path.cached_path = home_path()
|
||||
@@ -230,12 +226,7 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
|
||||
from shutil import which
|
||||
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."
|
||||
|
||||
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)
|
||||
subprocess.call([open_command, filename])
|
||||
|
||||
|
||||
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
|
||||
@@ -441,6 +432,9 @@ class RestrictedUnpickler(pickle.Unpickler):
|
||||
def find_class(self, module: str, name: str) -> type:
|
||||
if module == "builtins" and name in safe_builtins:
|
||||
return getattr(builtins, name)
|
||||
# used by OptionCounter
|
||||
if module == "collections" and name == "Counter":
|
||||
return collections.Counter
|
||||
# used by MultiServer -> savegame/multidata
|
||||
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
|
||||
"SlotType", "NetworkSlot", "HintStatus"}:
|
||||
@@ -714,30 +708,25 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *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 = "") \
|
||||
-> typing.Optional[str]:
|
||||
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:
|
||||
# prefer native dialog
|
||||
from shutil import which
|
||||
kdialog = which("kdialog")
|
||||
if kdialog:
|
||||
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")
|
||||
if zenity:
|
||||
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
|
||||
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
|
||||
try:
|
||||
@@ -771,18 +760,21 @@ def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args
|
||||
|
||||
|
||||
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:
|
||||
# prefer native dialog
|
||||
from shutil import which
|
||||
kdialog = which("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 ".")
|
||||
zenity = which("zenity")
|
||||
if zenity:
|
||||
z_filters = ("--directory",)
|
||||
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
|
||||
try:
|
||||
@@ -809,6 +801,9 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
|
||||
|
||||
|
||||
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():
|
||||
from kvui import MessageBox
|
||||
MessageBox(title, text, error).open()
|
||||
@@ -819,10 +814,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
|
||||
from shutil import which
|
||||
kdialog = which("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")
|
||||
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:
|
||||
import ctypes
|
||||
|
||||
@@ -61,7 +61,12 @@ def download_slot_file(room_id, player_id: int):
|
||||
else:
|
||||
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:
|
||||
for name in zf.namelist():
|
||||
if name.endswith("info.json"):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
flask>=3.1.1
|
||||
flask>=3.1.0
|
||||
werkzeug>=3.1.3
|
||||
pony>=0.7.19
|
||||
waitress>=3.0.2
|
||||
|
||||
49
WebHostLib/static/assets/minecraftTracker.js
Normal file
49
WebHostLib/static/assets/minecraftTracker.js
Normal 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;
|
||||
});
|
||||
}
|
||||
});
|
||||
102
WebHostLib/static/styles/minecraftTracker.css
Normal file
102
WebHostLib/static/styles/minecraftTracker.css
Normal 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;
|
||||
}
|
||||
@@ -26,7 +26,10 @@
|
||||
<td>{{ patch.game }}</td>
|
||||
<td>
|
||||
{% 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 == "VVVVVV" and room.seed.slots|length == 1 %}
|
||||
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
|
||||
Download APV6 File...</a>
|
||||
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
|
||||
|
||||
84
WebHostLib/templates/tracker__Minecraft.html
Normal file
84
WebHostLib/templates/tracker__Minecraft.html
Normal file
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{{ player_name }}'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>
|
||||
@@ -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
|
||||
_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"]:
|
||||
def render_OcarinaOfTime_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
|
||||
icons = {
|
||||
|
||||
@@ -119,9 +119,9 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
|
||||
# AP Container
|
||||
elif handler:
|
||||
data = zfile.open(file, "r").read()
|
||||
with zipfile.ZipFile(BytesIO(data)) as container:
|
||||
player = json.loads(container.open("archipelago.json").read())["player"]
|
||||
files[player] = data
|
||||
patch = handler(BytesIO(data))
|
||||
patch.read()
|
||||
files[patch.player] = data
|
||||
|
||||
# Spoiler
|
||||
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.")
|
||||
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
|
||||
elif file.filename.endswith(".zip"):
|
||||
|
||||
@@ -24,20 +24,9 @@
|
||||
<BaseButton>:
|
||||
ripple_color: app.theme_cls.primaryColor
|
||||
ripple_duration_in_fast: 0.2
|
||||
<MDNavigationItemBase>:
|
||||
on_release: app.screens.switch_screens(self)
|
||||
|
||||
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
|
||||
<MDTabsItemBase>:
|
||||
ripple_color: app.theme_cls.primaryColor
|
||||
ripple_duration_in_fast: 0.2
|
||||
<TooltipLabel>:
|
||||
adaptive_height: True
|
||||
theme_font_size: "Custom"
|
||||
|
||||
@@ -365,14 +365,18 @@ request_handlers = {
|
||||
["PREFERRED_CORES"] = function (req)
|
||||
local res = {}
|
||||
local preferred_cores = client.getconfig().PreferredCores
|
||||
local systems_enumerator = preferred_cores.Keys:GetEnumerator()
|
||||
|
||||
res["type"] = "PREFERRED_CORES_RESPONSE"
|
||||
res["value"] = {}
|
||||
|
||||
while systems_enumerator:MoveNext() do
|
||||
res["value"][systems_enumerator.Current] = preferred_cores[systems_enumerator.Current]
|
||||
end
|
||||
res["value"]["NES"] = preferred_cores.NES
|
||||
res["value"]["SNES"] = preferred_cores.SNES
|
||||
res["value"]["GB"] = preferred_cores.GB
|
||||
res["value"]["GBC"] = preferred_cores.GBC
|
||||
res["value"]["DGB"] = preferred_cores.DGB
|
||||
res["value"]["SGB"] = preferred_cores.SGB
|
||||
res["value"]["PCE"] = preferred_cores.PCE
|
||||
res["value"]["PCECD"] = preferred_cores.PCECD
|
||||
res["value"]["SGX"] = preferred_cores.SGX
|
||||
|
||||
return res
|
||||
end,
|
||||
|
||||
462
data/lua/connector_ff1.lua
Normal file
462
data/lua/connector_ff1.lua
Normal 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()
|
||||
@@ -477,7 +477,7 @@ function main()
|
||||
elseif (curstate == STATE_UNINITIALIZED) then
|
||||
-- If we're uninitialized, attempt to make the connection.
|
||||
if (frame % 120 == 0) then
|
||||
server:settimeout(120)
|
||||
server:settimeout(2)
|
||||
local client, timeout = server:accept()
|
||||
if timeout == nil then
|
||||
print('Initial Connection Made')
|
||||
|
||||
BIN
data/mcicon.ico
Normal file
BIN
data/mcicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -121,6 +121,9 @@
|
||||
# The Messenger
|
||||
/worlds/messenger/ @alwaysintreble
|
||||
|
||||
# Minecraft
|
||||
/worlds/minecraft/ @KonoTyran @espeon65536
|
||||
|
||||
# Mega Man 2
|
||||
/worlds/mm2/ @Silvris
|
||||
|
||||
@@ -157,9 +160,6 @@
|
||||
# Saving Princess
|
||||
/worlds/saving_princess/ @LeonarthCG
|
||||
|
||||
# shapez
|
||||
/worlds/shapez/ @BlastSlimey
|
||||
|
||||
# Shivers
|
||||
/worlds/shivers/ @GodlFire @korydondzila
|
||||
|
||||
|
||||
@@ -117,6 +117,12 @@ flowchart LR
|
||||
%% Java Based Games
|
||||
subgraph 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
|
||||
AS <-- WebSockets --> JM
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
#### Arguments
|
||||
| Name | Type | Notes |
|
||||
| ---- |-------------| ----- |
|
||||
| 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. |
|
||||
| text | str | A descriptive message of the problem at hand. |
|
||||
| Name | Type | Notes |
|
||||
| ---- | ---- | ----- |
|
||||
| type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. |
|
||||
| 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. |
|
||||
|
||||
##### 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.
|
||||
@@ -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.
|
||||
|
||||
```python
|
||||
from typing import TypedDict
|
||||
from typing import TypedDict, Optional
|
||||
class JSONMessagePart(TypedDict):
|
||||
type: str | None
|
||||
text: str | None
|
||||
color: str | None # only available if type is a color
|
||||
flags: int | None # only available if type is an item_id or item_name
|
||||
player: int | None # only available if type is either item or location
|
||||
hint_status: HintStatus | None # only available if type is hint_status
|
||||
type: Optional[str]
|
||||
text: Optional[str]
|
||||
color: Optional[str] # only available if type is a color
|
||||
flags: Optional[int] # only available if type is an item_id or item_name
|
||||
player: Optional[int] # only available if type is either item or location
|
||||
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.
|
||||
|
||||
@@ -333,7 +333,7 @@ within the world.
|
||||
### TextChoice
|
||||
Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any
|
||||
user defined string as a valid option, so will either need to be validated by adding a validation step to the option
|
||||
class or within world, if necessary. Value for this class is `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.
|
||||
|
||||
### PlandoBosses
|
||||
|
||||
@@ -102,16 +102,17 @@ In worlds, this should only be used for the top level to avoid issues when upgra
|
||||
|
||||
### 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
|
||||
import settings
|
||||
import typing
|
||||
|
||||
class MySettings(settings.Group):
|
||||
class MyBool(settings.Bool):
|
||||
"""Doc string"""
|
||||
|
||||
my_value: MyBool | bool = True
|
||||
my_value: typing.Union[MyBool, bool] = True
|
||||
```
|
||||
|
||||
### UserFilePath
|
||||
@@ -133,15 +134,15 @@ Checks the file against [md5s](#md5s) by default.
|
||||
|
||||
Resolves to an executable (varying file extension based on platform)
|
||||
|
||||
#### description: str | None
|
||||
#### description: Optional\[str\]
|
||||
|
||||
Human-readable name to use in file browser
|
||||
|
||||
#### copy_to: str | None
|
||||
#### copy_to: Optional\[str\]
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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
|
||||
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 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.
|
||||
|
||||
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").
|
||||
|
||||
### 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
|
||||
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
|
||||
|
||||
### 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.
|
||||
* `create_items(self)`
|
||||
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
|
||||
this step. Locations cannot be moved to different regions after this step. This includes event items and locations.
|
||||
items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions
|
||||
after this step. Locations cannot be moved to different regions after this step.
|
||||
* `set_rules(self)`
|
||||
called to set access and item rules on locations and entrances.
|
||||
* `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
|
||||
`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
|
||||
returned from `get_pre_fill_items`.
|
||||
returned from `get_prefill_items`.
|
||||
* `generate_output(self, output_directory: str)`
|
||||
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
|
||||
|
||||
@@ -86,7 +86,6 @@ Type: dirifempty; Name: "{app}"
|
||||
[InstallDelete]
|
||||
Type: files; Name: "{app}\*.exe"
|
||||
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}\EnemizerCLI*"
|
||||
#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\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: "{#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: "";
|
||||
|
||||
178
kvui.py
178
kvui.py
@@ -60,10 +60,7 @@ from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogSupporting
|
||||
from kivymd.uix.gridlayout import MDGridLayout
|
||||
from kivymd.uix.floatlayout import MDFloatLayout
|
||||
from kivymd.uix.boxlayout import MDBoxLayout
|
||||
from kivymd.uix.navigationbar import MDNavigationBar, MDNavigationItem
|
||||
from kivymd.uix.screen import MDScreen
|
||||
from kivymd.uix.screenmanager import MDScreenManager
|
||||
|
||||
from kivymd.uix.tab.tab import MDTabsSecondary, MDTabsItem, MDTabsItemText, MDTabsCarousel
|
||||
from kivymd.uix.menu import MDDropdownMenu
|
||||
from kivymd.uix.menu.menu import MDDropdownTextItem
|
||||
from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText
|
||||
@@ -729,10 +726,6 @@ class MessageBox(Popup):
|
||||
self.height += max(0, label.height - 18)
|
||||
|
||||
|
||||
class MDNavigationItemBase(MDNavigationItem):
|
||||
text = StringProperty(None)
|
||||
|
||||
|
||||
class ButtonsPrompt(MDDialog):
|
||||
def __init__(self, title: str, text: str, response: typing.Callable[[str], None],
|
||||
*prompts: str, **kwargs) -> None:
|
||||
@@ -773,34 +766,58 @@ class ButtonsPrompt(MDDialog):
|
||||
)
|
||||
|
||||
|
||||
class MDScreenManagerBase(MDScreenManager):
|
||||
current_tab: MDNavigationItemBase
|
||||
local_screen_names: list[str]
|
||||
class ClientTabs(MDTabsSecondary):
|
||||
carousel: MDTabsCarousel
|
||||
lock_swiping = True
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.local_screen_names = []
|
||||
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
|
||||
|
||||
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)
|
||||
def _check_panel_height(self, *args):
|
||||
self.ids.tab_scroll.height = dp(38)
|
||||
|
||||
def update_indicator(
|
||||
self, x: float = 0.0, w: float = 0.0, instance: MDTabsItem = None
|
||||
) -> None:
|
||||
def update_indicator(*args):
|
||||
indicator_pos = (0, 0)
|
||||
indicator_size = (0, 0)
|
||||
|
||||
item_text_object = self._get_tab_item_text_icon_object()
|
||||
|
||||
if item_text_object:
|
||||
indicator_pos = (
|
||||
instance.x + dp(12),
|
||||
self.indicator.pos[1]
|
||||
if not self._tabs_carousel
|
||||
else self._tabs_carousel.height,
|
||||
)
|
||||
indicator_size = (
|
||||
instance.width - dp(24),
|
||||
self.indicator_height,
|
||||
)
|
||||
|
||||
Animation(
|
||||
pos=indicator_pos,
|
||||
size=indicator_size,
|
||||
d=0 if not self.indicator_anim else self.indicator_duration,
|
||||
t=self.indicator_transition,
|
||||
).start(self.indicator)
|
||||
|
||||
if not instance:
|
||||
self.indicator.pos = (x, self.indicator.pos[1])
|
||||
self.indicator.size = (w, self.indicator_height)
|
||||
else:
|
||||
self.local_screen_names.append(widget.name)
|
||||
Clock.schedule_once(update_indicator)
|
||||
|
||||
def switch_screens(self, new_tab: MDNavigationItemBase) -> None:
|
||||
"""
|
||||
Called whenever the user clicks a tab to switch to a different screen.
|
||||
|
||||
:param new_tab: The new screen to switch to's tab.
|
||||
"""
|
||||
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
|
||||
def remove_tab(self, tab, content=None):
|
||||
if content is None:
|
||||
content = tab.content
|
||||
self.ids.container.remove_widget(tab)
|
||||
self.carousel.remove_widget(content)
|
||||
self.on_size(self, self.size)
|
||||
|
||||
|
||||
class CommandButton(MDButton, MDTooltip):
|
||||
@@ -828,9 +845,6 @@ class GameManager(ThemedApp):
|
||||
main_area_container: MDGridLayout
|
||||
""" subclasses can add more columns beside the tabs """
|
||||
|
||||
tabs: MDNavigationBar
|
||||
screens: MDScreenManagerBase
|
||||
|
||||
def __init__(self, ctx: context_type):
|
||||
self.title = self.base_title
|
||||
self.ctx = ctx
|
||||
@@ -860,7 +874,7 @@ class GameManager(ThemedApp):
|
||||
@property
|
||||
def tab_count(self):
|
||||
if hasattr(self, "tabs"):
|
||||
return max(1, len(self.tabs.children))
|
||||
return max(1, len(self.tabs.tab_list))
|
||||
return 1
|
||||
|
||||
def on_start(self):
|
||||
@@ -900,32 +914,30 @@ class GameManager(ThemedApp):
|
||||
self.grid.add_widget(self.progressbar)
|
||||
|
||||
# middle part
|
||||
self.screens = MDScreenManagerBase(pos_hint={"center_x": 0.5})
|
||||
self.tabs = MDNavigationBar(orientation="horizontal", size_hint_y=None, height=dp(40), set_bars_color=True)
|
||||
# bind the method to the bar for back compatibility
|
||||
self.tabs.remove_tab = self.remove_client_tab
|
||||
self.screens.current_tab = self.add_client_tab(
|
||||
"All" if len(self.logging_pairs) > 1 else "Archipelago",
|
||||
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
|
||||
self.tabs = ClientTabs(pos_hint={"center_x": 0.5, "center_y": 0.5})
|
||||
self.tabs.add_widget(MDTabsItem(MDTabsItemText(text="All" if len(self.logging_pairs) > 1 else "Archipelago")))
|
||||
self.log_panels["All"] = self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name)
|
||||
for logger_name, name in
|
||||
self.logging_pairs))
|
||||
self.tabs.carousel.add_widget(self.tabs.default_tab_content)
|
||||
|
||||
for logger_name, display_name in self.logging_pairs:
|
||||
bridge_logger = logging.getLogger(logger_name)
|
||||
self.log_panels[display_name] = UILog(bridge_logger)
|
||||
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)
|
||||
hint_panel = self.add_client_tab("Hints", HintLayout(self.hint_log))
|
||||
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)
|
||||
tab_container = MDGridLayout(size_hint_y=1, cols=1)
|
||||
tab_container.add_widget(self.tabs)
|
||||
tab_container.add_widget(self.screens)
|
||||
self.main_area_container.add_widget(tab_container)
|
||||
self.main_area_container.add_widget(self.tabs)
|
||||
|
||||
self.grid.add_widget(self.main_area_container)
|
||||
|
||||
@@ -962,61 +974,25 @@ class GameManager(ThemedApp):
|
||||
|
||||
return self.container
|
||||
|
||||
def add_client_tab(self, title: str, content: Widget, index: int = -1) -> MDNavigationItemBase:
|
||||
"""
|
||||
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.
|
||||
|
||||
: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)
|
||||
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.
|
||||
Returns the new tab widget, with the provided content being placed on the tab as content."""
|
||||
new_tab = MDTabsItem(MDTabsItemText(text=title))
|
||||
new_tab.content = content
|
||||
new_screen = MDScreen(name=title)
|
||||
new_screen.add_widget(content)
|
||||
if -1 < index <= len(self.tabs.children):
|
||||
remapped_index = len(self.tabs.children) - index
|
||||
self.tabs.add_widget(new_tab, index=remapped_index)
|
||||
self.screens.add_widget(new_screen, index=index)
|
||||
if -1 < index <= len(self.tabs.carousel.slides):
|
||||
new_tab.bind(on_release=self.tabs.set_active_item)
|
||||
new_tab._tabs = self.tabs
|
||||
self.tabs.ids.container.add_widget(new_tab, index=index)
|
||||
self.tabs.carousel.add_widget(new_tab.content, index=len(self.tabs.carousel.slides) - index)
|
||||
else:
|
||||
self.tabs.add_widget(new_tab)
|
||||
self.screens.add_widget(new_screen)
|
||||
self.tabs.carousel.add_widget(new_tab.content)
|
||||
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):
|
||||
if hasattr(self.screens.current_tab.content, "fix_heights"):
|
||||
getattr(self.screens.current_tab.content, "fix_heights")()
|
||||
for slide in self.tabs.carousel.slides:
|
||||
if hasattr(slide, "fix_heights"):
|
||||
slide.fix_heights() # TODO: remove this when Kivy fixes this upstream
|
||||
if self.ctx.server:
|
||||
self.title = self.base_title + " " + Utils.__version__ + \
|
||||
f" | Connected to: {self.ctx.server_address} " \
|
||||
|
||||
3
setup.py
3
setup.py
@@ -64,6 +64,7 @@ non_apworlds: set[str] = {
|
||||
"ArchipIDLE",
|
||||
"Archipelago",
|
||||
"Clique",
|
||||
"Final Fantasy",
|
||||
"Lufia II Ancient Cave",
|
||||
"Meritous",
|
||||
"Ocarina of Time",
|
||||
@@ -481,7 +482,7 @@ tmp="${{exe#*/}}"
|
||||
if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then
|
||||
exe="{default_exe.parent}/$exe"
|
||||
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 "$@"
|
||||
""")
|
||||
launcher_filename.chmod(0o755)
|
||||
|
||||
@@ -382,7 +382,7 @@ class World(metaclass=AutoWorldRegister):
|
||||
def create_items(self) -> None:
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@@ -158,7 +158,6 @@ class APContainer:
|
||||
class APPlayerContainer(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
|
||||
@@ -185,7 +184,6 @@ class APPlayerContainer(APContainer):
|
||||
"player": self.player,
|
||||
"player_name": self.player_name,
|
||||
"game": self.game,
|
||||
"patch_file_ending": self.patch_file_ending,
|
||||
})
|
||||
return manifest
|
||||
|
||||
@@ -225,6 +223,7 @@ class APProcedurePatch(APAutoPatchInterface):
|
||||
"""
|
||||
hash: Optional[str] # base checksum of source file
|
||||
source_data: bytes
|
||||
patch_file_ending: str = ""
|
||||
files: Dict[str, bytes]
|
||||
|
||||
@classmethod
|
||||
@@ -246,6 +245,7 @@ class APProcedurePatch(APAutoPatchInterface):
|
||||
manifest = super(APProcedurePatch, self).get_manifest()
|
||||
manifest["base_checksum"] = self.hash
|
||||
manifest["result_file_ending"] = self.result_file_ending
|
||||
manifest["patch_file_ending"] = self.patch_file_ending
|
||||
manifest["procedure"] = self.procedure
|
||||
if self.procedure == APDeltaPatch.procedure:
|
||||
manifest["compatible_version"] = 5
|
||||
|
||||
@@ -210,21 +210,22 @@ components: List[Component] = [
|
||||
Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
|
||||
# Core
|
||||
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
|
||||
file_identifier=SuffixIdentifier('.archipelago', '.zip'),
|
||||
description="Host a generated multiworld on your computer."),
|
||||
Component('Generate', 'Generate', cli=True,
|
||||
description="Generate a multiworld with the YAMLs in the players folder."),
|
||||
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."),
|
||||
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
|
||||
Component('Generate', 'Generate', cli=True),
|
||||
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld")),
|
||||
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient),
|
||||
Component('Links Awakening DX Client', 'LinksAwakeningClient',
|
||||
file_identifier=SuffixIdentifier('.apladx')),
|
||||
Component('LttP Adjuster', 'LttPAdjuster'),
|
||||
# Minecraft
|
||||
Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True,
|
||||
file_identifier=SuffixIdentifier('.apmc')),
|
||||
# Ocarina of Time
|
||||
Component('OoT Client', 'OoTClient',
|
||||
file_identifier=SuffixIdentifier('.apz5')),
|
||||
Component('OoT Adjuster', 'OoTAdjuster'),
|
||||
# FF1
|
||||
Component('FF1 Client', 'FF1Client'),
|
||||
# TLoZ
|
||||
Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')),
|
||||
# ChecksFinder
|
||||
@@ -243,5 +244,6 @@ components: List[Component] = [
|
||||
# if registering an icon from within an apworld, the format "ap:module.name/path/to/file.png" can be used
|
||||
icon_paths = {
|
||||
'icon': local_path('data', 'icon.png'),
|
||||
'mcicon': local_path('data', 'mcicon.png'),
|
||||
'discord': local_path('data', 'discord-mark-blue.png'),
|
||||
}
|
||||
|
||||
@@ -19,8 +19,7 @@ def launch_client(*args) -> None:
|
||||
|
||||
|
||||
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
|
||||
file_identifier=SuffixIdentifier(),
|
||||
description="Open the BizHawk client, to play games using the Bizhawk emulator.")
|
||||
file_identifier=SuffixIdentifier())
|
||||
components.append(component)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
multiworld.shops.append(old_man_take_any.shop)
|
||||
|
||||
sword_indices = [
|
||||
index for index, item in enumerate(multiworld.itempool) if item.player == player and item.type == 'Sword'
|
||||
]
|
||||
if sword_indices:
|
||||
sword_index = multiworld.random.choice(sword_indices)
|
||||
sword = multiworld.itempool.pop(sword_index)
|
||||
swords = [item for item in multiworld.itempool if item.player == player and item.type == 'Sword']
|
||||
if swords:
|
||||
sword = multiworld.random.choice(swords)
|
||||
multiworld.itempool.remove(sword)
|
||||
multiworld.itempool.append(item_factory('Rupees (20)', world))
|
||||
old_man_take_any.shop.add_inventory(0, sword.name, 0, 0)
|
||||
loc_name = "Old Man Sword Cave"
|
||||
|
||||
@@ -38,7 +38,7 @@ class DungeonFillTestBase(TestCase):
|
||||
def test_original_dungeons(self):
|
||||
self.generate_with_options(DungeonItem.option_original_dungeon)
|
||||
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:
|
||||
self.assertIs(location.item.dungeon, None)
|
||||
else:
|
||||
@@ -52,7 +52,7 @@ class DungeonFillTestBase(TestCase):
|
||||
def test_own_dungeons(self):
|
||||
self.generate_with_options(DungeonItem.option_own_dungeons)
|
||||
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:
|
||||
self.assertIs(location.item.dungeon, None)
|
||||
else:
|
||||
|
||||
@@ -4,7 +4,7 @@ Date: Fri, 15 Mar 2024 18:41:40 +0000
|
||||
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 .Items import AquariaItem, ItemNames
|
||||
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)
|
||||
|
||||
|
||||
DAMAGING_ITEMS:Iterable[str] = [
|
||||
ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM,
|
||||
ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA,
|
||||
ItemNames.BABY_BLASTER
|
||||
]
|
||||
|
||||
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_damaging_item(state: CollectionState, player: int) -> bool:
|
||||
"""`player` in `state` has the shield song item"""
|
||||
return state.has_any({ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, ItemNames.LI_AND_LI_SONG,
|
||||
ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, ItemNames.BABY_BLASTER}, player)
|
||||
|
||||
|
||||
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,
|
||||
lambda state: _has_beast_form_or_arnassi_armor(state, self.player))
|
||||
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,
|
||||
lambda state: _has_bind_song(state, self.player) or
|
||||
_has_damaging_item(state, self.player,
|
||||
damaging_items_minus_nature_form))
|
||||
_has_damaging_item(state, self.player))
|
||||
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.veil_b)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
import io
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, cast
|
||||
import zipfile
|
||||
from BaseClasses import Location
|
||||
from worlds.Files import APPlayerContainer
|
||||
from worlds.Files import APContainer, AutoPatchRegister
|
||||
|
||||
from .Enum import CivVICheckType
|
||||
from .Locations import CivVILocation, CivVILocationData
|
||||
@@ -25,19 +26,22 @@ class CivTreeItem:
|
||||
ui_tree_row: int
|
||||
|
||||
|
||||
class CivVIContainer(APPlayerContainer):
|
||||
class CivVIContainer(APContainer, metaclass=AutoPatchRegister):
|
||||
"""
|
||||
Responsible for generating the dynamic mod files for the Civ VI multiworld
|
||||
"""
|
||||
game: Optional[str] = "Civilization VI"
|
||||
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 = ""):
|
||||
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)
|
||||
if isinstance(patch_data, io.BytesIO):
|
||||
super().__init__(patch_data, player, player_name, server)
|
||||
else:
|
||||
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:
|
||||
for filename, yml in self.patch_data.items():
|
||||
|
||||
@@ -20,17 +20,16 @@ A short period after receiving an item, you will get a notification indicating y
|
||||
|
||||
## FAQs
|
||||
- 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?
|
||||
- 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?
|
||||
- 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.
|
||||
- Does my mod that reskins Barbarians as various Pro Wrestlers work with this?
|
||||
- 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!"
|
||||
- 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!"
|
||||
- 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.
|
||||
- If you think you should be able to make Field Cannons but seemingly can't try disabling `Telecommunications`
|
||||
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.
|
||||
- "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.
|
||||
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`
|
||||
2. `TECH_EDUCATION`
|
||||
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.py).
|
||||
- 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).
|
||||
|
||||
## 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!
|
||||
@@ -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.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
- 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).
|
||||
|
||||
- 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
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
- 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 → Options → 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 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.
|
||||
- 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.
|
||||
|
||||
@@ -2893,18 +2893,3 @@ dog_bite_ice_trap_fix = [
|
||||
0x25291CB8, # ADDIU T1, T1, 0x1CB8
|
||||
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
|
||||
]
|
||||
|
||||
@@ -424,7 +424,6 @@ class PantherDash(Choice):
|
||||
class IncreaseShimmySpeed(Toggle):
|
||||
"""
|
||||
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"
|
||||
|
||||
|
||||
@@ -607,10 +607,9 @@ class CV64PatchExtensions(APPatchExtension):
|
||||
rom_data.write_int32(0xAA530, 0x080FF880) # J 0x803FE200
|
||||
rom_data.write_int32s(0xBFE200, patches.coffin_cutscene_skipper)
|
||||
|
||||
# Shimmy speed increase hack
|
||||
# Increase shimmy speed
|
||||
if options["increase_shimmy_speed"]:
|
||||
rom_data.write_int32(0x97EB4, 0x803FE9F0)
|
||||
rom_data.write_int32s(0xBFE9F0, patches.shimmy_speed_modifier)
|
||||
rom_data.write_byte(0xA4241, 0x5A)
|
||||
|
||||
# Disable landing fall damage
|
||||
if options["fall_guard"]:
|
||||
|
||||
@@ -211,8 +211,7 @@ class CVCotMWorld(World):
|
||||
"ignore_cleansing": self.options.ignore_cleansing.value,
|
||||
"skip_tutorials": self.options.skip_tutorials.value,
|
||||
"required_last_keys": self.required_last_keys,
|
||||
"completion_goal": self.options.completion_goal.value,
|
||||
"nerf_roc_wing": self.options.nerf_roc_wing.value}
|
||||
"completion_goal": self.options.completion_goal.value}
|
||||
|
||||
def get_filler_item_name(self) -> str:
|
||||
return self.random.choice(FILLER_ITEM_NAMES)
|
||||
|
||||
@@ -48,17 +48,11 @@ class OtherGameAppearancesInfo(TypedDict):
|
||||
|
||||
|
||||
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,
|
||||
"appearance": 0x01},
|
||||
"Heart Vessel": {"type": 0xE4,
|
||||
"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,
|
||||
"appearance": 0x01},
|
||||
"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
|
||||
|
||||
# Add the start inventory arrays to the offset data in bytes form.
|
||||
start_inventory_data[0x690080] = bytes(magic_items_array)
|
||||
start_inventory_data[0x6900A0] = bytes(cards_array)
|
||||
start_inventory_data[0x680080] = bytes(magic_items_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
|
||||
# possible Max Ups.
|
||||
|
||||
@@ -132,40 +132,40 @@ start_inventory_giver = [
|
||||
|
||||
# Magic Items
|
||||
0x13, 0x48, # ldr r0, =0x202572F
|
||||
0x14, 0x49, # ldr r1, =0x8690080
|
||||
0x14, 0x49, # ldr r1, =0x8680080
|
||||
0x00, 0x22, # mov r2, #0
|
||||
0x8B, 0x5C, # ldrb r3, [r1, r2]
|
||||
0x83, 0x54, # strb r3, [r0, r2]
|
||||
0x01, 0x32, # adds r2, #1
|
||||
0x08, 0x2A, # cmp r2, #8
|
||||
0xFA, 0xDB, # blt 0x8690006
|
||||
0xFA, 0xDB, # blt 0x8680006
|
||||
# Max Ups
|
||||
0x11, 0x48, # ldr r0, =0x202572C
|
||||
0x12, 0x49, # ldr r1, =0x8690090
|
||||
0x12, 0x49, # ldr r1, =0x8680090
|
||||
0x00, 0x22, # mov r2, #0
|
||||
0x8B, 0x5C, # ldrb r3, [r1, r2]
|
||||
0x83, 0x54, # strb r3, [r0, r2]
|
||||
0x01, 0x32, # adds r2, #1
|
||||
0x03, 0x2A, # cmp r2, #3
|
||||
0xFA, 0xDB, # blt 0x8690016
|
||||
0xFA, 0xDB, # blt 0x8680016
|
||||
# Cards
|
||||
0x0F, 0x48, # ldr r0, =0x2025674
|
||||
0x10, 0x49, # ldr r1, =0x86900A0
|
||||
0x10, 0x49, # ldr r1, =0x86800A0
|
||||
0x00, 0x22, # mov r2, #0
|
||||
0x8B, 0x5C, # ldrb r3, [r1, r2]
|
||||
0x83, 0x54, # strb r3, [r0, r2]
|
||||
0x01, 0x32, # adds r2, #1
|
||||
0x14, 0x2A, # cmp r2, #0x14
|
||||
0xFA, 0xDB, # blt 0x8690026
|
||||
0xFA, 0xDB, # blt 0x8680026
|
||||
# Inventory Items (not currently supported)
|
||||
0x0D, 0x48, # ldr r0, =0x20256ED
|
||||
0x0E, 0x49, # ldr r1, =0x86900C0
|
||||
0x0E, 0x49, # ldr r1, =0x86800C0
|
||||
0x00, 0x22, # mov r2, #0
|
||||
0x8B, 0x5C, # ldrb r3, [r1, r2]
|
||||
0x83, 0x54, # strb r3, [r0, r2]
|
||||
0x01, 0x32, # adds r2, #1
|
||||
0x36, 0x2A, # cmp r2, #36
|
||||
0xFA, 0xDB, # blt 0x8690036
|
||||
0xFA, 0xDB, # blt 0x8680036
|
||||
# Return to the function that checks for Magician Mode.
|
||||
0xBA, 0x21, # movs r1, #0xBA
|
||||
0x89, 0x00, # lsls r1, r1, #2
|
||||
@@ -176,13 +176,13 @@ start_inventory_giver = [
|
||||
# LDR number pool
|
||||
0x78, 0x7F, 0x00, 0x08,
|
||||
0x2F, 0x57, 0x02, 0x02,
|
||||
0x80, 0x00, 0x69, 0x08,
|
||||
0x80, 0x00, 0x68, 0x08,
|
||||
0x2C, 0x57, 0x02, 0x02,
|
||||
0x90, 0x00, 0x69, 0x08,
|
||||
0x90, 0x00, 0x68, 0x08,
|
||||
0x74, 0x56, 0x02, 0x02,
|
||||
0xA0, 0x00, 0x69, 0x08,
|
||||
0xA0, 0x00, 0x68, 0x08,
|
||||
0xED, 0x56, 0x02, 0x02,
|
||||
0xC0, 0x00, 0x69, 0x08,
|
||||
0xC0, 0x00, 0x68, 0x08,
|
||||
]
|
||||
|
||||
max_max_up_checker = [
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Quick Links
|
||||
- [Setup](/tutorial/Castlevania%20-%20Circle%20of%20the%20Moon/setup/en)
|
||||
- [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)
|
||||
- [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)
|
||||
|
||||
@@ -22,7 +22,7 @@ clear it.
|
||||
|
||||
## 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).
|
||||
|
||||
## 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.
|
||||
|
||||
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).
|
||||
2. Put the tracker pack into `packs/` in your PopTracker install.
|
||||
3. Open PopTracker, and load the Castlevania: Circle of the Moon pack.
|
||||
|
||||
@@ -335,8 +335,8 @@ class CVCotMPatchExtensions(APPatchExtension):
|
||||
rom_data.write_bytes(0x679A60, patches.kickless_roc_height_shortener)
|
||||
|
||||
# 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(0x690000, patches.start_inventory_giver)
|
||||
rom_data.write_bytes(0x7F70, [0x00, 0x48, 0x87, 0x46, 0x00, 0x00, 0x68, 0x08])
|
||||
rom_data.write_bytes(0x680000, patches.start_inventory_giver)
|
||||
|
||||
# Prevent Max Ups from exceeding 255.
|
||||
rom_data.write_bytes(0x5E170, [0x00, 0x4A, 0x97, 0x46, 0x00, 0x00, 0x6A, 0x08])
|
||||
|
||||
@@ -884,7 +884,7 @@ location_tables: Dict[str, List[DS3LocationData]] = {
|
||||
DS3LocationData("RS: Homeward Bone - balcony by Farron Keep", "Homeward Bone x2"),
|
||||
DS3LocationData("RS: Titanite Shard - woods, surrounded by enemies", "Titanite Shard"),
|
||||
DS3LocationData("RS: Twin Dragon Greatshield - woods by Crucifixion Woods bonfire",
|
||||
"Twin Dragon Greatshield", missable=True), # After Eclipse
|
||||
"Twin Dragon Greatshield"),
|
||||
DS3LocationData("RS: Sorcerer Hood - water beneath stronghold", "Sorcerer Hood",
|
||||
hidden=True), # Hidden fall
|
||||
DS3LocationData("RS: Sorcerer Robe - water beneath stronghold", "Sorcerer Robe",
|
||||
@@ -1887,7 +1887,7 @@ location_tables: Dict[str, List[DS3LocationData]] = {
|
||||
DS3LocationData("AL: Twinkling Titanite - lizard after light cathedral #2",
|
||||
"Twinkling Titanite", lizard=True),
|
||||
DS3LocationData("AL: Aldrich's Ruby - dark cathedral, miniboss", "Aldrich's Ruby",
|
||||
miniboss=True, missable=True), # Deep Accursed drop, missable after defeating Aldrich
|
||||
miniboss=True), # Deep Accursed drop
|
||||
DS3LocationData("AL: Aldrich Faithful - water reserves, talk to McDonnel", "Aldrich Faithful",
|
||||
hidden=True), # Behind illusory wall
|
||||
|
||||
|
||||
@@ -75,13 +75,6 @@ class DarkSouls3World(World):
|
||||
"""The pool of all items within this particular world. This is a subset of
|
||||
`self.multiworld.itempool`."""
|
||||
|
||||
missable_dupe_prog_locs: Set[str] = {"PC: Storm Ruler - Siegward",
|
||||
"US: Pyromancy Flame - Cornyx",
|
||||
"US: Tower Key - kill Irina"}
|
||||
"""Locations whose vanilla item is a missable duplicate of a non-missable progression item.
|
||||
If vanilla, these locations shouldn't be expected progression, so they aren't created and don't get rules.
|
||||
"""
|
||||
|
||||
def __init__(self, multiworld: MultiWorld, player: int):
|
||||
super().__init__(multiworld, player)
|
||||
self.all_excluded_locations = set()
|
||||
@@ -265,7 +258,10 @@ class DarkSouls3World(World):
|
||||
new_location.progress_type = LocationProgressType.EXCLUDED
|
||||
else:
|
||||
# Don't allow missable duplicates of progression items to be expected progression.
|
||||
if location.name in self.missable_dupe_prog_locs: continue
|
||||
if location.name in {"PC: Storm Ruler - Siegward",
|
||||
"US: Pyromancy Flame - Cornyx",
|
||||
"US: Tower Key - kill Irina"}:
|
||||
continue
|
||||
|
||||
# Replace non-randomized items with events that give the default item
|
||||
event_item = (
|
||||
@@ -709,7 +705,7 @@ class DarkSouls3World(World):
|
||||
if self._is_location_available("US: Young White Branch - by white tree #2"):
|
||||
self._add_item_rule(
|
||||
"US: Young White Branch - by white tree #2",
|
||||
lambda item: item.player != self.player or not item.data.unique
|
||||
lambda item: item.player == self.player and not item.data.unique
|
||||
)
|
||||
|
||||
# Make sure the Storm Ruler is available BEFORE Yhorm the Giant
|
||||
@@ -1290,9 +1286,8 @@ class DarkSouls3World(World):
|
||||
data = location_dictionary[location]
|
||||
if data.dlc and not self.options.enable_dlc: continue
|
||||
if data.ngp and not self.options.enable_ngp: continue
|
||||
# Don't add rules to missable duplicates of progression items
|
||||
if location in self.missable_dupe_prog_locs and not self._is_location_available(location): continue
|
||||
|
||||
if not self._is_location_available(location): continue
|
||||
if isinstance(rule, str):
|
||||
assert item_dictionary[rule].classification == ItemClassification.progression
|
||||
rule = lambda state, item=rule: state.has(item, self.player)
|
||||
|
||||
@@ -73,7 +73,7 @@ things to keep in mind:
|
||||
|
||||
* To run the game itself, just run `launchmod_darksouls3.bat` under Proton.
|
||||
|
||||
[.NET Runtime]: https://dotnet.microsoft.com/en-us/download/dotnet/6.0
|
||||
[.NET Runtime]: https://dotnet.microsoft.com/en-us/download/dotnet/8.0
|
||||
[WINE]: https://www.winehq.org/
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -802,10 +802,8 @@ def connect_regions(world: World, level_list):
|
||||
for i in range(0, len(kremwood_forest_levels) - 1):
|
||||
connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[i])
|
||||
|
||||
connection = connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[-1],
|
||||
lambda state: (state.can_reach(LocationName.riverside_race_flag, "Location", world.player)))
|
||||
world.multiworld.register_indirect_condition(world.get_location(LocationName.riverside_race_flag).parent_region,
|
||||
connection)
|
||||
connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[-1],
|
||||
lambda state: (state.can_reach(LocationName.riverside_race_flag, "Location", world.player)))
|
||||
|
||||
# Cotton-Top Cove Connections
|
||||
cotton_top_cove_levels = [
|
||||
@@ -839,11 +837,8 @@ def connect_regions(world: World, level_list):
|
||||
connect(world, world.player, names, LocationName.mekanos_region, LocationName.sky_high_secret_region,
|
||||
lambda state: (state.has(ItemName.bowling_ball, world.player, 1)))
|
||||
else:
|
||||
connection = connect(world, world.player, names, LocationName.mekanos_region,
|
||||
LocationName.sky_high_secret_region,
|
||||
lambda state: (state.can_reach(LocationName.bleaks_house, "Location", world.player)))
|
||||
world.multiworld.register_indirect_condition(world.get_location(LocationName.bleaks_house).parent_region,
|
||||
connection)
|
||||
connect(world, world.player, names, LocationName.mekanos_region, LocationName.sky_high_secret_region,
|
||||
lambda state: (state.can_reach(LocationName.bleaks_house, "Location", world.player)))
|
||||
|
||||
# K3 Connections
|
||||
k3_levels = [
|
||||
@@ -951,4 +946,3 @@ def connect(world: World, player: int, used_names: typing.Dict[str, int], source
|
||||
|
||||
source_region.exits.append(connection)
|
||||
connection.connect(target_region)
|
||||
return connection
|
||||
|
||||
@@ -280,19 +280,16 @@ def set_boss_door_requirements_rules(player, world):
|
||||
set_rule(world.get_entrance("Boss Door", player), has_3_swords)
|
||||
|
||||
|
||||
def set_lfod_self_obtained_items_rules(world_options, player, multiworld):
|
||||
def set_lfod_self_obtained_items_rules(world_options, player, world):
|
||||
if world_options.item_shuffle != Options.ItemShuffle.option_disabled:
|
||||
return
|
||||
world = multiworld.worlds[player]
|
||||
set_rule(world.get_entrance("Vines"),
|
||||
set_rule(world.get_entrance("Vines", player),
|
||||
lambda state: state.has("Incredibly Important Pack", player))
|
||||
set_rule(world.get_entrance("Behind Rocks"),
|
||||
set_rule(world.get_entrance("Behind Rocks", player),
|
||||
lambda state: state.can_reach("Cut Content", 'region', player))
|
||||
multiworld.register_indirect_condition(world.get_region("Cut Content"), world.get_entrance("Behind Rocks"))
|
||||
set_rule(world.get_entrance("Pickaxe Hard Cave"),
|
||||
set_rule(world.get_entrance("Pickaxe Hard Cave", player),
|
||||
lambda state: state.can_reach("Cut Content", 'region', player) and
|
||||
state.has("Name Change Pack", player))
|
||||
multiworld.register_indirect_condition(world.get_region("Cut Content"), world.get_entrance("Pickaxe Hard Cave"))
|
||||
|
||||
|
||||
def set_lfod_shuffled_items_rules(world_options, player, world):
|
||||
|
||||
@@ -69,9 +69,7 @@ class FactorioContext(CommonContext):
|
||||
# updated by spinup server
|
||||
mod_version: Version = Version(0, 0, 0)
|
||||
|
||||
def __init__(self, server_address, password, filter_item_sends: bool, bridge_chat_out: bool,
|
||||
rcon_port: int, rcon_password: str, server_settings_path: str | None,
|
||||
factorio_server_args: tuple[str, ...]):
|
||||
def __init__(self, server_address, password, filter_item_sends: bool, bridge_chat_out: bool):
|
||||
super(FactorioContext, self).__init__(server_address, password)
|
||||
self.send_index: int = 0
|
||||
self.rcon_client = None
|
||||
@@ -84,10 +82,6 @@ class FactorioContext(CommonContext):
|
||||
self.filter_item_sends: bool = filter_item_sends
|
||||
self.multiplayer: bool = False # whether multiple different players have connected
|
||||
self.bridge_chat_out: bool = bridge_chat_out
|
||||
self.rcon_port: int = rcon_port
|
||||
self.rcon_password: str = rcon_password
|
||||
self.server_settings_path: str = server_settings_path
|
||||
self.additional_factorio_server_args = factorio_server_args
|
||||
|
||||
@property
|
||||
def energylink_key(self) -> str:
|
||||
@@ -132,18 +126,6 @@ class FactorioContext(CommonContext):
|
||||
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
|
||||
f"{text}")
|
||||
|
||||
@property
|
||||
def server_args(self) -> tuple[str, ...]:
|
||||
if self.server_settings_path:
|
||||
return (
|
||||
"--rcon-port", str(self.rcon_port),
|
||||
"--rcon-password", self.rcon_password,
|
||||
"--server-settings", self.server_settings_path,
|
||||
*self.additional_factorio_server_args)
|
||||
else:
|
||||
return ("--rcon-port", str(self.rcon_port), "--rcon-password", self.rcon_password,
|
||||
*self.additional_factorio_server_args)
|
||||
|
||||
@property
|
||||
def energy_link_status(self) -> str:
|
||||
if not self.energy_link_increment:
|
||||
@@ -329,7 +311,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
executable, "--create", savegame_name, "--preset", "archipelago"
|
||||
))
|
||||
factorio_process = subprocess.Popen((executable, "--start-server", savegame_name,
|
||||
*ctx.server_args),
|
||||
*(str(elem) for elem in server_args)),
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
@@ -349,7 +331,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
|
||||
factorio_queue.task_done()
|
||||
|
||||
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
||||
ctx.rcon_client = factorio_rcon.RCONClient("localhost", ctx.rcon_port, ctx.rcon_password,
|
||||
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password,
|
||||
timeout=5)
|
||||
if not ctx.server:
|
||||
logger.info("Established bridge to Factorio Server. "
|
||||
@@ -440,7 +422,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
executable, "--create", savegame_name
|
||||
))
|
||||
factorio_process = subprocess.Popen(
|
||||
(executable, "--start-server", savegame_name, *ctx.server_args),
|
||||
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stdin=subprocess.DEVNULL,
|
||||
@@ -469,7 +451,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
"or a Factorio sharing data directories is already running. "
|
||||
"Server could not start up.")
|
||||
if not rcon_client and "Starting RCON interface at IP ADDR:" in msg:
|
||||
rcon_client = factorio_rcon.RCONClient("localhost", ctx.rcon_port, ctx.rcon_password)
|
||||
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
|
||||
if ctx.mod_version == ctx.__class__.mod_version:
|
||||
raise Exception("No Archipelago mod was loaded. Aborting.")
|
||||
await get_info(ctx, rcon_client)
|
||||
@@ -492,8 +474,9 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def main(make_context):
|
||||
ctx = make_context()
|
||||
async def main(args, filter_item_sends: bool, filter_bridge_chat_out: bool):
|
||||
ctx = FactorioContext(args.connect, args.password, filter_item_sends, filter_bridge_chat_out)
|
||||
|
||||
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
|
||||
|
||||
if gui_enabled:
|
||||
@@ -526,42 +509,38 @@ class FactorioJSONtoTextParser(JSONtoTextParser):
|
||||
return self._handle_text(node)
|
||||
|
||||
|
||||
parser = get_base_parser(description="Optional arguments to FactorioClient follow. "
|
||||
"Remaining arguments get passed into bound Factorio instance."
|
||||
"Refer to Factorio --help for those.")
|
||||
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
|
||||
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
|
||||
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
|
||||
|
||||
args, rest = parser.parse_known_args()
|
||||
rcon_port = args.rcon_port
|
||||
rcon_password = args.rcon_password if args.rcon_password else ''.join(
|
||||
random.choice(string.ascii_letters) for x in range(32))
|
||||
factorio_server_logger = logging.getLogger("FactorioServer")
|
||||
settings: FactorioSettings = get_settings().factorio_options
|
||||
if os.path.samefile(settings.executable, sys.executable):
|
||||
selected_executable = settings.executable
|
||||
settings.executable = FactorioSettings.executable # reset to default
|
||||
raise Exception(f"Factorio Client was set to run itself {selected_executable}, aborting process bomb.")
|
||||
raise Exception(f"FactorioClient was set to run itself {selected_executable}, aborting process bomb.")
|
||||
|
||||
executable = settings.executable
|
||||
|
||||
server_settings = args.server_settings if args.server_settings \
|
||||
else getattr(settings, "server_settings", None)
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password)
|
||||
|
||||
def launch(*new_args: str):
|
||||
|
||||
def launch():
|
||||
import colorama
|
||||
global executable
|
||||
global executable, server_settings, server_args
|
||||
colorama.just_fix_windows_console()
|
||||
|
||||
# args handling
|
||||
parser = get_base_parser(description="Optional arguments to Factorio Client follow. "
|
||||
"Remaining arguments get passed into bound Factorio instance."
|
||||
"Refer to Factorio --help for those.")
|
||||
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
|
||||
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
|
||||
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
|
||||
|
||||
args, rest = parser.parse_known_args(args=new_args)
|
||||
rcon_port = args.rcon_port
|
||||
rcon_password = args.rcon_password if args.rcon_password else ''.join(
|
||||
random.choice(string.ascii_letters) for _ in range(32))
|
||||
|
||||
server_settings = args.server_settings if args.server_settings \
|
||||
else getattr(settings, "server_settings", None)
|
||||
|
||||
if server_settings:
|
||||
server_settings = os.path.abspath(server_settings)
|
||||
if not os.path.isfile(server_settings):
|
||||
raise FileNotFoundError(f"Could not find file {server_settings} for server_settings. Aborting.")
|
||||
|
||||
initial_filter_item_sends = bool(settings.filter_item_sends)
|
||||
initial_bridge_chat_out = bool(settings.bridge_chat_out)
|
||||
|
||||
@@ -575,9 +554,14 @@ def launch(*new_args: str):
|
||||
else:
|
||||
raise FileNotFoundError(f"Path {executable} is not an executable file.")
|
||||
|
||||
asyncio.run(main(lambda: FactorioContext(
|
||||
args.connect, args.password,
|
||||
initial_filter_item_sends, initial_bridge_chat_out,
|
||||
rcon_port, rcon_password, server_settings, rest
|
||||
)))
|
||||
if server_settings and os.path.isfile(server_settings):
|
||||
server_args = (
|
||||
"--rcon-port", rcon_port,
|
||||
"--rcon-password", rcon_password,
|
||||
"--server-settings", server_settings,
|
||||
*rest)
|
||||
else:
|
||||
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
|
||||
|
||||
asyncio.run(main(args, initial_filter_item_sends, initial_bridge_chat_out))
|
||||
colorama.deinit()
|
||||
|
||||
@@ -67,7 +67,6 @@ class FactorioModFile(worlds.Files.APPlayerContainer):
|
||||
game = "Factorio"
|
||||
compression_method = zipfile.ZIP_DEFLATED # Factorio can't load LZMA archives
|
||||
writing_tasks: List[Callable[[], Tuple[str, Union[str, bytes]]]]
|
||||
patch_file_ending = ".zip"
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -22,9 +22,9 @@ from .Technologies import base_tech_table, recipe_sources, base_technology_table
|
||||
from .settings import FactorioSettings
|
||||
|
||||
|
||||
def launch_client(*args: str):
|
||||
def launch_client():
|
||||
from .Client import launch
|
||||
launch_component(launch, name="Factorio Client", args=args)
|
||||
launch_component(launch, name="FactorioClient")
|
||||
|
||||
|
||||
components.append(Component("Factorio Client", func=launch_client, component_type=Type.CLIENT))
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
import logging
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from NetUtils import ClientStatus
|
||||
|
||||
import worlds._bizhawk as bizhawk
|
||||
from worlds._bizhawk.client import BizHawkClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from worlds._bizhawk.context import BizHawkClientContext
|
||||
|
||||
|
||||
base_id = 7000
|
||||
logger = logging.getLogger("Client")
|
||||
|
||||
|
||||
rom_name_location = 0x07FFE3
|
||||
locations_array_start = 0x200
|
||||
locations_array_length = 0x100
|
||||
items_obtained = 0x03
|
||||
gp_location_low = 0x1C
|
||||
gp_location_middle = 0x1D
|
||||
gp_location_high = 0x1E
|
||||
weapons_arrays_starts = [0x118, 0x158, 0x198, 0x1D8]
|
||||
armors_arrays_starts = [0x11C, 0x15C, 0x19C, 0x1DC]
|
||||
status_a_location = 0x102
|
||||
status_b_location = 0x0FC
|
||||
status_c_location = 0x0A3
|
||||
|
||||
key_items = ["Lute", "Crown", "Crystal", "Herb", "Key", "Tnt", "Adamant", "Slab", "Ruby", "Rod",
|
||||
"Floater", "Chime", "Tail", "Cube", "Bottle", "Oxyale", "EarthOrb", "FireOrb", "WaterOrb", "AirOrb"]
|
||||
|
||||
consumables = ["Shard", "Tent", "Cabin", "House", "Heal", "Pure", "Soft"]
|
||||
|
||||
weapons = ["WoodenNunchucks", "SmallKnife", "WoodenRod", "Rapier", "IronHammer", "ShortSword", "HandAxe", "Scimitar",
|
||||
"IronNunchucks", "LargeKnife", "IronStaff", "Sabre", "LongSword", "GreatAxe", "Falchon", "SilverKnife",
|
||||
"SilverSword", "SilverHammer", "SilverAxe", "FlameSword", "IceSword", "DragonSword", "GiantSword",
|
||||
"SunSword", "CoralSword", "WereSword", "RuneSword", "PowerRod", "LightAxe", "HealRod", "MageRod", "Defense",
|
||||
"WizardRod", "Vorpal", "CatClaw", "ThorHammer", "BaneSword", "Katana", "Xcalber", "Masamune"]
|
||||
|
||||
armor = ["Cloth", "WoodenArmor", "ChainArmor", "IronArmor", "SteelArmor", "SilverArmor", "FlameArmor", "IceArmor",
|
||||
"OpalArmor", "DragonArmor", "Copper", "Silver", "Gold", "Opal", "WhiteShirt", "BlackShirt", "WoodenShield",
|
||||
"IronShield", "SilverShield", "FlameShield", "IceShield", "OpalShield", "AegisShield", "Buckler", "ProCape",
|
||||
"Cap", "WoodenHelm", "IronHelm", "SilverHelm", "OpalHelm", "HealHelm", "Ribbon", "Gloves", "CopperGauntlets",
|
||||
"IronGauntlets", "SilverGauntlets", "ZeusGauntlets", "PowerGauntlets", "OpalGauntlets", "ProRing"]
|
||||
|
||||
gold_items = ["Gold10", "Gold20", "Gold25", "Gold30", "Gold55", "Gold70", "Gold85", "Gold110", "Gold135", "Gold155",
|
||||
"Gold160", "Gold180", "Gold240", "Gold255", "Gold260", "Gold295", "Gold300", "Gold315", "Gold330",
|
||||
"Gold350", "Gold385", "Gold400", "Gold450", "Gold500", "Gold530", "Gold575", "Gold620", "Gold680",
|
||||
"Gold750", "Gold795", "Gold880", "Gold1020", "Gold1250", "Gold1455", "Gold1520", "Gold1760", "Gold1975",
|
||||
"Gold2000", "Gold2750", "Gold3400", "Gold4150", "Gold5000", "Gold5450", "Gold6400", "Gold6720",
|
||||
"Gold7340", "Gold7690", "Gold7900", "Gold8135", "Gold9000", "Gold9300", "Gold9500", "Gold9900",
|
||||
"Gold10000", "Gold12350", "Gold13000", "Gold13450", "Gold14050", "Gold14720", "Gold15000", "Gold17490",
|
||||
"Gold18010", "Gold19990", "Gold20000", "Gold20010", "Gold26000", "Gold45000", "Gold65000"]
|
||||
|
||||
extended_consumables = ["FullCure", "Phoenix", "Blast", "Smoke",
|
||||
"Refresh", "Flare", "Black", "Guard",
|
||||
"Quick", "HighPotion", "Wizard", "Cloak"]
|
||||
|
||||
ext_consumables_lookup = {"FullCure": "Ext1", "Phoenix": "Ext2", "Blast": "Ext3", "Smoke": "Ext4",
|
||||
"Refresh": "Ext1", "Flare": "Ext2", "Black": "Ext3", "Guard": "Ext4",
|
||||
"Quick": "Ext1", "HighPotion": "Ext2", "Wizard": "Ext3", "Cloak": "Ext4"}
|
||||
|
||||
ext_consumables_locations = {"Ext1": 0x3C, "Ext2": 0x3D, "Ext3": 0x3E, "Ext4": 0x3F}
|
||||
|
||||
|
||||
movement_items = ["Ship", "Bridge", "Canal", "Canoe"]
|
||||
|
||||
no_overworld_items = ["Sigil", "Mark"]
|
||||
|
||||
|
||||
class FF1Client(BizHawkClient):
|
||||
game = "Final Fantasy"
|
||||
system = "NES"
|
||||
|
||||
weapons_queue: deque[int]
|
||||
armor_queue: deque[int]
|
||||
consumable_stack_amounts: dict[str, int] | None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.wram = "RAM"
|
||||
self.sram = "WRAM"
|
||||
self.rom = "PRG ROM"
|
||||
self.consumable_stack_amounts = None
|
||||
self.weapons_queue = deque()
|
||||
self.armor_queue = deque()
|
||||
self.guard_character = 0x00
|
||||
|
||||
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool:
|
||||
try:
|
||||
# Check ROM name/patch version
|
||||
rom_name = ((await bizhawk.read(ctx.bizhawk_ctx, [(rom_name_location, 0x0D, self.rom)]))[0])
|
||||
rom_name = rom_name.decode("ascii")
|
||||
if rom_name != "FINAL FANTASY":
|
||||
return False # Not a Final Fantasy 1 ROM
|
||||
except bizhawk.RequestFailedError:
|
||||
return False # Not able to get a response, say no for now
|
||||
|
||||
ctx.game = self.game
|
||||
ctx.items_handling = 0b111
|
||||
ctx.want_slot_data = True
|
||||
# Resetting these in case of switching ROMs
|
||||
self.consumable_stack_amounts = None
|
||||
self.weapons_queue = deque()
|
||||
self.armor_queue = deque()
|
||||
|
||||
return True
|
||||
|
||||
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
|
||||
if ctx.server is None:
|
||||
return
|
||||
|
||||
if ctx.slot is None:
|
||||
return
|
||||
try:
|
||||
self.guard_character = await self.read_sram_value(ctx, status_a_location)
|
||||
# If the first character's name starts with a 0 value, we're at the title screen/character creation.
|
||||
# In that case, don't allow any read/writes.
|
||||
# We do this by setting the guard to 1 because that's neither a valid character nor the initial value.
|
||||
if self.guard_character == 0:
|
||||
self.guard_character = 0x01
|
||||
|
||||
if self.consumable_stack_amounts is None:
|
||||
self.consumable_stack_amounts = {}
|
||||
self.consumable_stack_amounts["Shard"] = 1
|
||||
other_consumable_amounts = await self.read_rom(ctx, 0x47400, 10)
|
||||
self.consumable_stack_amounts["Tent"] = other_consumable_amounts[0] + 1
|
||||
self.consumable_stack_amounts["Cabin"] = other_consumable_amounts[1] + 1
|
||||
self.consumable_stack_amounts["House"] = other_consumable_amounts[2] + 1
|
||||
self.consumable_stack_amounts["Heal"] = other_consumable_amounts[3] + 1
|
||||
self.consumable_stack_amounts["Pure"] = other_consumable_amounts[4] + 1
|
||||
self.consumable_stack_amounts["Soft"] = other_consumable_amounts[5] + 1
|
||||
self.consumable_stack_amounts["Ext1"] = other_consumable_amounts[6] + 1
|
||||
self.consumable_stack_amounts["Ext2"] = other_consumable_amounts[7] + 1
|
||||
self.consumable_stack_amounts["Ext3"] = other_consumable_amounts[8] + 1
|
||||
self.consumable_stack_amounts["Ext4"] = other_consumable_amounts[9] + 1
|
||||
|
||||
await self.location_check(ctx)
|
||||
await self.received_items_check(ctx)
|
||||
await self.process_weapons_queue(ctx)
|
||||
await self.process_armor_queue(ctx)
|
||||
|
||||
except bizhawk.RequestFailedError:
|
||||
# The connector didn't respond. Exit handler and return to main loop to reconnect
|
||||
pass
|
||||
|
||||
async def location_check(self, ctx: "BizHawkClientContext"):
|
||||
locations_data = await self.read_sram_values_guarded(ctx, locations_array_start, locations_array_length)
|
||||
if locations_data is None:
|
||||
return
|
||||
locations_checked = []
|
||||
if len(locations_data) > 0xFE and locations_data[0xFE] & 0x02 != 0 and not ctx.finished_game:
|
||||
await ctx.send_msgs([
|
||||
{"cmd": "StatusUpdate",
|
||||
"status": ClientStatus.CLIENT_GOAL}
|
||||
])
|
||||
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
|
||||
if locations_data[index] & flag != 0:
|
||||
locations_checked.append(location)
|
||||
|
||||
found_locations = await ctx.check_locations(locations_checked)
|
||||
for location in found_locations:
|
||||
ctx.locations_checked.add(location)
|
||||
location_name = ctx.location_names.lookup_in_game(location)
|
||||
logger.info(
|
||||
f'New Check: {location_name} ({len(ctx.locations_checked)}/'
|
||||
f'{len(ctx.missing_locations) + len(ctx.checked_locations)})')
|
||||
|
||||
|
||||
async def received_items_check(self, ctx: "BizHawkClientContext") -> None:
|
||||
assert self.consumable_stack_amounts, "shouldn't call this function without reading consumable_stack_amounts"
|
||||
write_list: list[tuple[int, list[int], str]] = []
|
||||
items_received_count = await self.read_sram_value_guarded(ctx, items_obtained)
|
||||
if items_received_count is None:
|
||||
return
|
||||
if items_received_count < len(ctx.items_received):
|
||||
current_item = ctx.items_received[items_received_count]
|
||||
current_item_id = current_item.item
|
||||
current_item_name = ctx.item_names.lookup_in_game(current_item_id, ctx.game)
|
||||
if current_item_name in key_items:
|
||||
location = current_item_id - 0xE0
|
||||
write_list.append((location, [1], self.sram))
|
||||
elif current_item_name in movement_items:
|
||||
location = current_item_id - 0x1E0
|
||||
if current_item_name != "Canal":
|
||||
write_list.append((location, [1], self.sram))
|
||||
else:
|
||||
write_list.append((location, [0], self.sram))
|
||||
elif current_item_name in no_overworld_items:
|
||||
if current_item_name == "Sigil":
|
||||
location = 0x28
|
||||
else:
|
||||
location = 0x12
|
||||
write_list.append((location, [1], self.sram))
|
||||
elif current_item_name in gold_items:
|
||||
gold_amount = int(current_item_name[4:])
|
||||
current_gold_value = await self.read_sram_values_guarded(ctx, gp_location_low, 3)
|
||||
if current_gold_value is None:
|
||||
return
|
||||
current_gold = int.from_bytes(current_gold_value, "little")
|
||||
new_gold = min(gold_amount + current_gold, 999999)
|
||||
lower_byte = new_gold % (2 ** 8)
|
||||
middle_byte = (new_gold // (2 ** 8)) % (2 ** 8)
|
||||
upper_byte = new_gold // (2 ** 16)
|
||||
write_list.append((gp_location_low, [lower_byte], self.sram))
|
||||
write_list.append((gp_location_middle, [middle_byte], self.sram))
|
||||
write_list.append((gp_location_high, [upper_byte], self.sram))
|
||||
elif current_item_name in consumables:
|
||||
location = current_item_id - 0xE0
|
||||
current_value = await self.read_sram_value_guarded(ctx, location)
|
||||
if current_value is None:
|
||||
return
|
||||
amount_to_add = self.consumable_stack_amounts[current_item_name]
|
||||
new_value = min(current_value + amount_to_add, 99)
|
||||
write_list.append((location, [new_value], self.sram))
|
||||
elif current_item_name in extended_consumables:
|
||||
ext_name = ext_consumables_lookup[current_item_name]
|
||||
location = ext_consumables_locations[ext_name]
|
||||
current_value = await self.read_sram_value_guarded(ctx, location)
|
||||
if current_value is None:
|
||||
return
|
||||
amount_to_add = self.consumable_stack_amounts[ext_name]
|
||||
new_value = min(current_value + amount_to_add, 99)
|
||||
write_list.append((location, [new_value], self.sram))
|
||||
elif current_item_name in weapons:
|
||||
self.weapons_queue.appendleft(current_item_id - 0x11B)
|
||||
elif current_item_name in armor:
|
||||
self.armor_queue.appendleft(current_item_id - 0x143)
|
||||
write_list.append((items_obtained, [items_received_count + 1], self.sram))
|
||||
write_successful = await self.write_sram_values_guarded(ctx, write_list)
|
||||
if write_successful:
|
||||
await bizhawk.display_message(ctx.bizhawk_ctx, f"Received {current_item_name}")
|
||||
|
||||
async def process_weapons_queue(self, ctx: "BizHawkClientContext"):
|
||||
empty_slots = deque()
|
||||
char1_slots = await self.read_sram_values_guarded(ctx, weapons_arrays_starts[0], 4)
|
||||
char2_slots = await self.read_sram_values_guarded(ctx, weapons_arrays_starts[1], 4)
|
||||
char3_slots = await self.read_sram_values_guarded(ctx, weapons_arrays_starts[2], 4)
|
||||
char4_slots = await self.read_sram_values_guarded(ctx, weapons_arrays_starts[3], 4)
|
||||
if char1_slots is None or char2_slots is None or char3_slots is None or char4_slots is None:
|
||||
return
|
||||
for i, slot in enumerate(char1_slots):
|
||||
if slot == 0:
|
||||
empty_slots.appendleft(weapons_arrays_starts[0] + i)
|
||||
for i, slot in enumerate(char2_slots):
|
||||
if slot == 0:
|
||||
empty_slots.appendleft(weapons_arrays_starts[1] + i)
|
||||
for i, slot in enumerate(char3_slots):
|
||||
if slot == 0:
|
||||
empty_slots.appendleft(weapons_arrays_starts[2] + i)
|
||||
for i, slot in enumerate(char4_slots):
|
||||
if slot == 0:
|
||||
empty_slots.appendleft(weapons_arrays_starts[3] + i)
|
||||
while len(empty_slots) > 0 and len(self.weapons_queue) > 0:
|
||||
current_slot = empty_slots.pop()
|
||||
current_weapon = self.weapons_queue.pop()
|
||||
await self.write_sram_guarded(ctx, current_slot, current_weapon)
|
||||
|
||||
async def process_armor_queue(self, ctx: "BizHawkClientContext"):
|
||||
empty_slots = deque()
|
||||
char1_slots = await self.read_sram_values_guarded(ctx, armors_arrays_starts[0], 4)
|
||||
char2_slots = await self.read_sram_values_guarded(ctx, armors_arrays_starts[1], 4)
|
||||
char3_slots = await self.read_sram_values_guarded(ctx, armors_arrays_starts[2], 4)
|
||||
char4_slots = await self.read_sram_values_guarded(ctx, armors_arrays_starts[3], 4)
|
||||
if char1_slots is None or char2_slots is None or char3_slots is None or char4_slots is None:
|
||||
return
|
||||
for i, slot in enumerate(char1_slots):
|
||||
if slot == 0:
|
||||
empty_slots.appendleft(armors_arrays_starts[0] + i)
|
||||
for i, slot in enumerate(char2_slots):
|
||||
if slot == 0:
|
||||
empty_slots.appendleft(armors_arrays_starts[1] + i)
|
||||
for i, slot in enumerate(char3_slots):
|
||||
if slot == 0:
|
||||
empty_slots.appendleft(armors_arrays_starts[2] + i)
|
||||
for i, slot in enumerate(char4_slots):
|
||||
if slot == 0:
|
||||
empty_slots.appendleft(armors_arrays_starts[3] + i)
|
||||
while len(empty_slots) > 0 and len(self.armor_queue) > 0:
|
||||
current_slot = empty_slots.pop()
|
||||
current_armor = self.armor_queue.pop()
|
||||
await self.write_sram_guarded(ctx, current_slot, current_armor)
|
||||
|
||||
|
||||
async def read_sram_value(self, ctx: "BizHawkClientContext", location: int):
|
||||
value = ((await bizhawk.read(ctx.bizhawk_ctx, [(location, 1, self.sram)]))[0])
|
||||
return int.from_bytes(value, "little")
|
||||
|
||||
async def read_sram_values_guarded(self, ctx: "BizHawkClientContext", location: int, size: int):
|
||||
value = await bizhawk.guarded_read(ctx.bizhawk_ctx,
|
||||
[(location, size, self.sram)],
|
||||
[(status_a_location, [self.guard_character], self.sram)])
|
||||
if value is None:
|
||||
return None
|
||||
return value[0]
|
||||
|
||||
async def read_sram_value_guarded(self, ctx: "BizHawkClientContext", location: int):
|
||||
value = await bizhawk.guarded_read(ctx.bizhawk_ctx,
|
||||
[(location, 1, self.sram)],
|
||||
[(status_a_location, [self.guard_character], self.sram)])
|
||||
if value is None:
|
||||
return None
|
||||
return int.from_bytes(value[0], "little")
|
||||
|
||||
async def read_rom(self, ctx: "BizHawkClientContext", location: int, size: int):
|
||||
return (await bizhawk.read(ctx.bizhawk_ctx, [(location, size, self.rom)]))[0]
|
||||
|
||||
async def write_sram_guarded(self, ctx: "BizHawkClientContext", location: int, value: int):
|
||||
return await bizhawk.guarded_write(ctx.bizhawk_ctx,
|
||||
[(location, [value], self.sram)],
|
||||
[(status_a_location, [self.guard_character], self.sram)])
|
||||
|
||||
async def write_sram_values_guarded(self, ctx: "BizHawkClientContext", write_list):
|
||||
return await bizhawk.guarded_write(ctx.bizhawk_ctx,
|
||||
write_list,
|
||||
[(status_a_location, [self.guard_character], self.sram)])
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, Set, NamedTuple, List
|
||||
|
||||
from BaseClasses import Item, ItemClassification
|
||||
@@ -37,13 +37,15 @@ class FF1Items:
|
||||
_item_table_lookup: Dict[str, ItemData] = {}
|
||||
|
||||
def _populate_item_table_from_data(self):
|
||||
file = pkgutil.get_data(__name__, "data/items.json").decode("utf-8")
|
||||
items = json.loads(file)
|
||||
# Hardcode progression and categories for now
|
||||
self._item_table = [ItemData(name, code, "FF1Item", ItemClassification.progression if name in
|
||||
FF1_PROGRESSION_LIST else ItemClassification.useful if name in FF1_USEFUL_LIST else
|
||||
ItemClassification.filler) for name, code in items.items()]
|
||||
self._item_table_lookup = {item.name: item for item in self._item_table}
|
||||
base_path = Path(__file__).parent
|
||||
file_path = (base_path / "data/items.json").resolve()
|
||||
with open(file_path) as file:
|
||||
items = json.load(file)
|
||||
# Hardcode progression and categories for now
|
||||
self._item_table = [ItemData(name, code, "FF1Item", ItemClassification.progression if name in
|
||||
FF1_PROGRESSION_LIST else ItemClassification.useful if name in FF1_USEFUL_LIST else
|
||||
ItemClassification.filler) for name, code in items.items()]
|
||||
self._item_table_lookup = {item.name: item for item in self._item_table}
|
||||
|
||||
def _get_item_table(self) -> List[ItemData]:
|
||||
if not self._item_table or not self._item_table_lookup:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
import pkgutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, NamedTuple, List, Optional
|
||||
|
||||
from BaseClasses import Region, Location, MultiWorld
|
||||
@@ -18,11 +18,13 @@ class FF1Locations:
|
||||
_location_table_lookup: Dict[str, LocationData] = {}
|
||||
|
||||
def _populate_item_table_from_data(self):
|
||||
file = pkgutil.get_data(__name__, "data/locations.json")
|
||||
locations = json.loads(file)
|
||||
# Hardcode progression and categories for now
|
||||
self._location_table = [LocationData(name, code) for name, code in locations.items()]
|
||||
self._location_table_lookup = {item.name: item for item in self._location_table}
|
||||
base_path = Path(__file__).parent
|
||||
file_path = (base_path / "data/locations.json").resolve()
|
||||
with open(file_path) as file:
|
||||
locations = json.load(file)
|
||||
# Hardcode progression and categories for now
|
||||
self._location_table = [LocationData(name, code) for name, code in locations.items()]
|
||||
self._location_table_lookup = {item.name: item for item in self._location_table}
|
||||
|
||||
def _get_location_table(self) -> List[LocationData]:
|
||||
if not self._location_table or not self._location_table_lookup:
|
||||
|
||||
@@ -7,7 +7,6 @@ from .Items import ItemData, FF1Items, FF1_STARTER_ITEMS, FF1_PROGRESSION_LIST,
|
||||
from .Locations import EventId, FF1Locations, generate_rule, CHAOS_TERMINATED_EVENT
|
||||
from .Options import FF1Options
|
||||
from ..AutoWorld import World, WebWorld
|
||||
from .Client import FF1Client
|
||||
|
||||
|
||||
class FF1Settings(settings.Group):
|
||||
|
||||
@@ -22,6 +22,11 @@ All items can appear in other players worlds, including consumables, shards, wea
|
||||
|
||||
## What does another world's item look like in Final Fantasy
|
||||
|
||||
All local and remote items appear the same. Final Fantasy will say that you received an item, then the client log will
|
||||
display what was found external to the in-game text box.
|
||||
All local and remote items appear the same. Final Fantasy will say that you received an item, then BOTH the client log and the
|
||||
emulator will display what was found external to the in-game text box.
|
||||
|
||||
## Unique Local Commands
|
||||
The following commands are only available when using the FF1Client for the Final Fantasy Randomizer.
|
||||
|
||||
- `/nes` Shows the current status of the NES connection.
|
||||
- `/toggle_msgs` Toggle displaying messages in EmuHawk
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
## Required Software
|
||||
|
||||
- BizHawk: [BizHawk Releases from TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory)
|
||||
- Detailed installation instructions for BizHawk can be found at the above link.
|
||||
- Windows users must run the prerequisite installer first, which can also be found at the above link.
|
||||
- The built-in BizHawk client, which can be installed [here](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- The FF1Client
|
||||
- Bundled with Archipelago: [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
- The BizHawk emulator. Versions 2.3.1 and higher are supported. Version 2.7 is recommended
|
||||
- [BizHawk at TASVideos](https://tasvideos.org/BizHawk)
|
||||
- Your legally obtained Final Fantasy (USA Edition) ROM file, probably named `Final Fantasy (USA).nes`. Neither
|
||||
Archipelago.gg nor the Final Fantasy Randomizer Community can supply you with this.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
1. Download and install the latest version of Archipelago.
|
||||
1. On Windows, download Setup.Archipelago.<HighestVersion\>.exe and run it
|
||||
2. Assign EmuHawk as your default program for launching `.nes` files.
|
||||
2. Assign EmuHawk version 2.3.1 or higher as your default program for launching `.nes` files.
|
||||
1. Extract your BizHawk folder to your Desktop, or somewhere you will remember. Below are optional additional steps
|
||||
for loading ROMs more conveniently
|
||||
1. Right-click on a ROM file and select **Open with...**
|
||||
@@ -46,7 +46,7 @@ please refer to the [game agnostic setup guide](/tutorial/Archipelago/setup/en).
|
||||
|
||||
Once the Archipelago server has been hosted:
|
||||
|
||||
1. Navigate to your Archipelago install folder and run `ArchipelagoBizhawkClient.exe`
|
||||
1. Navigate to your Archipelago install folder and run `ArchipelagoFF1Client.exe`
|
||||
2. Notice the `/connect command` on the server hosting page (It should look like `/connect archipelago.gg:*****`
|
||||
where ***** are numbers)
|
||||
3. Type the connect command into the client OR add the port to the pre-populated address on the top bar (it should
|
||||
@@ -54,11 +54,16 @@ Once the Archipelago server has been hosted:
|
||||
|
||||
### Running Your Game and Connecting to the Client Program
|
||||
|
||||
1. Open EmuHawk and load your ROM OR click your ROM file if it is already associated with the
|
||||
1. Open EmuHawk 2.3.1 or higher and load your ROM OR click your ROM file if it is already associated with the
|
||||
extension `*.nes`
|
||||
2. Navigate to where you installed Archipelago, then to `data/lua`, and drag+drop the `connector_bizhawk_generic.lua`
|
||||
script onto the main EmuHawk window. You can also instead open the Lua Console manually, click `Script` 〉 `Open Script`,
|
||||
and navigate to `connector_bizhawk_generic.lua` with the file picker.
|
||||
2. Navigate to where you installed Archipelago, then to `data/lua`, and drag+drop the `connector_ff1.lua` script onto
|
||||
the main EmuHawk window.
|
||||
1. You could instead open the Lua Console manually, click `Script` 〉 `Open Script`, and navigate to
|
||||
`connector_ff1.lua` with the file picker.
|
||||
2. If it gives a `NLua.Exceptions.LuaScriptException: .\socket.lua:13: module 'socket.core' not found:` exception
|
||||
close your emulator entirely, restart it and re-run these steps
|
||||
3. If it says `Must use a version of BizHawk 2.3.1 or higher`, double-check your BizHawk version by clicking **
|
||||
Help** -> **About**
|
||||
|
||||
## Play the game
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ world and the beginning of another world. You can also combine multiple files by
|
||||
### Example
|
||||
|
||||
```yaml
|
||||
description: Example of generating multiple worlds. World 1 of 2
|
||||
description: Example of generating multiple worlds. World 1 of 3
|
||||
name: Mario
|
||||
game: Super Mario 64
|
||||
requires:
|
||||
@@ -310,6 +310,31 @@ Super Mario 64:
|
||||
|
||||
---
|
||||
|
||||
description: Example of generating multiple worlds. World 2 of 3
|
||||
name: Minecraft
|
||||
game: Minecraft
|
||||
Minecraft:
|
||||
progression_balancing: 50
|
||||
accessibility: items
|
||||
advancement_goal: 40
|
||||
combat_difficulty: hard
|
||||
include_hard_advancements: false
|
||||
include_unreasonable_advancements: false
|
||||
include_postgame_advancements: false
|
||||
shuffle_structures: true
|
||||
structure_compasses: true
|
||||
send_defeated_mobs: true
|
||||
bee_traps: 15
|
||||
egg_shards_required: 7
|
||||
egg_shards_available: 10
|
||||
required_bosses:
|
||||
none: 0
|
||||
ender_dragon: 1
|
||||
wither: 0
|
||||
both: 0
|
||||
|
||||
---
|
||||
|
||||
description: Example of generating multiple worlds. World 2 of 2
|
||||
name: ExampleFinder
|
||||
game: ChecksFinder
|
||||
@@ -319,6 +344,6 @@ ChecksFinder:
|
||||
accessibility: items
|
||||
```
|
||||
|
||||
The above example will generate 2 worlds - one Super Mario 64 and one ChecksFinder.
|
||||
The above example will generate 3 worlds - one Super Mario 64, one Minecraft, and one ChecksFinder.
|
||||
|
||||
|
||||
|
||||
@@ -27,176 +27,73 @@ requires:
|
||||
plando: bosses, items, texts, connections
|
||||
```
|
||||
|
||||
For a basic understanding of YAML files, refer to
|
||||
[YAML Formatting](/tutorial/Archipelago/advanced_settings/en#yaml-formatting)
|
||||
in Advanced Settings.
|
||||
|
||||
## Item Plando
|
||||
Item plando allows a player to place an item in a specific location or specific locations, or place multiple items into a
|
||||
list of specific locations both in their own game or in another player's game.
|
||||
|
||||
Item Plando allows a player to place an item in a specific location or locations, or place multiple items into a list
|
||||
of specific locations in their own game and/or in another player's game.
|
||||
* The options for item plando are `from_pool`, `world`, `percentage`, `force`, `count`, and either `item` and
|
||||
`location`, or `items` and `locations`.
|
||||
* `from_pool` determines if the item should be taken *from* the item pool or *added* to it. This can be true or
|
||||
false and defaults to true if omitted.
|
||||
* `world` is the target world to place the item in.
|
||||
* It gets ignored if only one world is generated.
|
||||
* Can be a number, name, true, false, null, or a list. False is the default.
|
||||
* If a number is used, it targets that slot or player number in the multiworld.
|
||||
* If a name is used, it will target the world with that player name.
|
||||
* If set to true, it will be any player's world besides your own.
|
||||
* If set to false, it will target your own world.
|
||||
* If set to null, it will target a random world in the multiworld.
|
||||
* If a list of names is used, it will target the games with the player names specified.
|
||||
* `force` determines whether the generator will fail if the item can't be placed in the location. Can be true, false,
|
||||
or silent. Silent is the default.
|
||||
* If set to true, the item must be placed and the generator will throw an error if it is unable to do so.
|
||||
* If set to false, the generator will log a warning if the placement can't be done but will still generate.
|
||||
* If set to silent and the placement fails, it will be ignored entirely.
|
||||
* `percentage` is the percentage chance for the relevant block to trigger. This can be any value from 0 to 100 and
|
||||
if omitted will default to 100.
|
||||
* Single Placement is when you use a plando block to place a single item at a single location.
|
||||
* `item` is the item you would like to place and `location` is the location to place it.
|
||||
* Multi Placement uses a plando block to place multiple items in multiple locations until either list is exhausted.
|
||||
* `items` defines the items to use, each with a number for the amount. Using `true` instead of a number uses however many of that item are in your item pool.
|
||||
* `locations` is a list of possible locations those items can be placed in.
|
||||
* Some special location group names can be specified:
|
||||
* `early_locations` will add all sphere 1 locations (locations logically reachable only with your starting inventory)
|
||||
* `non_early_locations` will add all locations beyond sphere 1 (locations that require finding at least one item before they become logically reachable)
|
||||
* Using the multi placement method, placements are picked randomly.
|
||||
|
||||
To add item plando to your player yaml, you add them under the `plando_items` block. You should start with `item` if you
|
||||
want to do Single Placement, or `items` if you want to do Multi Placement. A list of items can still be defined under
|
||||
`item` but only one of them will be chosen at random to be used.
|
||||
* `count` can be used to set the maximum number of items placed from the block. The default is 1 if using `item` and False if using `items`
|
||||
* If a number is used, it will try to place this number of items.
|
||||
* If set to false, it will try to place as many items from the block as it can.
|
||||
* If `min` and `max` are defined, it will try to place a number of items between these two numbers at random.
|
||||
|
||||
After you define `item/items`, you would define `location` or `locations`, depending on if you want to fill one
|
||||
location or many. Note that both `location` and `locations` are optional. A list of locations can still be defined under
|
||||
`location` but only one of them will be chosen at random to be used.
|
||||
|
||||
You may do any combination of `item/items` and `location/locations` in a plando block, but the block only places items
|
||||
in locations **until the shorter of the two lists is used up.**
|
||||
|
||||
Once you are satisfied with your first block, you may continue to define ones under the same `plando_items` parent.
|
||||
Each block can have several different options to tailor it the way you like.
|
||||
|
||||
* The `items` section defines the items to use. Each item name can be followed by a colon and a value.
|
||||
* A numerical value indicates the amount of that item.
|
||||
* A `true` value uses all copies of that item that are in your item pool.
|
||||
|
||||
* The `item` section defines a list of items to use, from which one will be chosen at random. Each item name can be
|
||||
followed by a colon and a value. The value indicates the weight of that item being chosen.
|
||||
|
||||
* The `locations` section defines possible locations those items can be placed in. Two special location groups exist:
|
||||
* `early_locations` will add all sphere 1 locations (locations logically reachable only with your starting
|
||||
inventory).
|
||||
* `non_early_locations` will add all locations beyond sphere 1 (locations that require finding at least one item
|
||||
before they become logically reachable).
|
||||
|
||||
* `from_pool` determines if the item should be taken *from* the item pool or *created* from scratch.
|
||||
* `false`: Create a new item with the same name (the world will determine its properties e.g. classification).
|
||||
* `true`: Take the existing item, if it exists, from the item pool. If it does not exist, one will be created from
|
||||
scratch. **(Default)**
|
||||
|
||||
* `world` is the target world to place the item in. It gets ignored if only one world is generated.
|
||||
* **A number:** Use this slot or player number in the multiworld.
|
||||
* **A name:** Use the world with that player name.
|
||||
* **A list of names:** Use the worlds with the player names specified.
|
||||
* `true`: Locations will be in any player's world besides your own.
|
||||
* `false`: Locations will be in your own world. **(Default)**
|
||||
* `null`: Locations will be in a random world in the multiworld.
|
||||
|
||||
* `force` determines whether the generator will fail if the plando block cannot be fulfilled.
|
||||
* `true`: The generator will throw an error if it is unable to place an item.
|
||||
* `false`: The generator will log a warning if it is unable to place an item, but it will still generate.
|
||||
* `silent`: If the placement fails, it will be ignored entirely. **(Default)**
|
||||
|
||||
* `percentage` is the percentage chance for the block to trigger. This can be any integer from 0 to 100.
|
||||
**(Default: 100)**
|
||||
|
||||
* `count` sets the number of items placed from the list.
|
||||
* **Default: 1 if using `item` or `location`, and `false` otherwise.**
|
||||
* **A number:** It will place this number of items.
|
||||
* `false`: It will place as many items from the list as it can.
|
||||
* **If `min` is defined,** it will place at least `min` many items (can be combined with `max`).
|
||||
* **If `max` is defined,** it will place at most `max` many items (can be combined with `min`).
|
||||
|
||||
### Available Items and Locations
|
||||
|
||||
A list of all available items and locations can be found in the [website's datapackage](/datapackage). The items and
|
||||
locations will be in the `"item_name_to_id"` and `"location_name_to_id"` sections of the relevant game. Names are
|
||||
case-sensitive. You can also use item groups and location groups that are defined in the datapackage.
|
||||
A list of all available items and locations can be found in the [website's datapackage](/datapackage). The items and locations will be in the `"item_name_to_id"` and `"location_name_to_id"` sections of the relevant game. You do not need the quotes but the name must be entered in the same as it appears on that page and is case-sensitive.
|
||||
|
||||
## Item Plando Examples
|
||||
```yaml
|
||||
plando_items:
|
||||
# Example block - Pokémon Red and Blue
|
||||
- items:
|
||||
Potion: 3
|
||||
locations:
|
||||
- "Route 1 - Free Sample Man"
|
||||
- "Mt Moon 1F - West Item"
|
||||
- "Mt Moon 1F - South Item"
|
||||
```
|
||||
This block will lock 3 Potion items on the Route 1 Pokémart employee and 2 Mt Moon items. Note these are all
|
||||
Potions in the vanilla game. The world value has not been specified, so these locations must be in this player's own
|
||||
world by default.
|
||||
### Examples
|
||||
|
||||
```yaml
|
||||
plando_items:
|
||||
# Example block - A Link to the Past
|
||||
- items:
|
||||
Progressive Sword: 4
|
||||
world:
|
||||
- BobsWitness
|
||||
- BobsRogueLegacy
|
||||
count:
|
||||
min: 1
|
||||
max: 4
|
||||
```
|
||||
This block will attempt to place a random number, between 1 and 4, of Progressive Swords into any locations within the
|
||||
game slots named "BobsWitness" and "BobsRogueLegacy."
|
||||
|
||||
```yaml
|
||||
plando_items:
|
||||
# Example block - Secret of Evermore
|
||||
- items:
|
||||
Levitate: 1
|
||||
Revealer: 1
|
||||
Energize: 1
|
||||
locations:
|
||||
- Master Sword Pedestal
|
||||
- Desert Discard
|
||||
world: true
|
||||
count: 2
|
||||
```
|
||||
This block will choose 2 from the Levitate, Revealer, and Energize items at random and attempt to put them into the
|
||||
locations named "Master Sword Pedestal" and "Desert Discard". Because the world value is `true`, these locations
|
||||
must be in other players' worlds.
|
||||
|
||||
```yaml
|
||||
plando_items:
|
||||
# Example block - Timespinner
|
||||
# example block 1 - Timespinner
|
||||
- item:
|
||||
Empire Orb: 1
|
||||
Radiant Orb: 3
|
||||
Radiant Orb: 1
|
||||
location: Starter Chest 1
|
||||
from_pool: false
|
||||
from_pool: true
|
||||
world: true
|
||||
percentage: 50
|
||||
```
|
||||
This block will place a single item, either the Empire Orb or Radiant Orb, on the location "Starter Chest 1". There is
|
||||
a 25% chance it is Empire Orb, and 75% chance it is Radiant Orb (1 to 3 odds). The world value is `true`, so this
|
||||
location must be in another player's world. Because the from_pool value is `false`, a copy of these items is added to
|
||||
these locations, while the originals remain in the item pool to be shuffled. Unlike the previous examples, which will
|
||||
always trigger, this block only has a 50% chance to trigger.
|
||||
|
||||
```yaml
|
||||
plando_items:
|
||||
# Example block - Factorio
|
||||
- items:
|
||||
progressive-electric-energy-distribution: 2
|
||||
electric-energy-accumulators: 1
|
||||
progressive-turret: 2
|
||||
locations:
|
||||
- AP-1-001
|
||||
- AP-1-002
|
||||
- AP-1-003
|
||||
- AP-1-004
|
||||
percentage: 80
|
||||
force: true
|
||||
from_pool: true
|
||||
world: false
|
||||
```
|
||||
This block lists 5 items but only 4 locations, so it will place all but 1 of the items randomly among the locations
|
||||
chosen here. This block has an 80% chance of occurring. Because force is `true`, the Generator will fail if it cannot
|
||||
place one of the selected items (not including the fifth item). From_pool and World have been set to their default
|
||||
values here, but they can be omitted and have the same result: items will be removed from the pool, and the locations
|
||||
are in this player's own world.
|
||||
|
||||
**NOTE:** Factorio's locations are dynamically generated, so the locations listed above may not exist in your game,
|
||||
they are here for demonstration only.
|
||||
|
||||
```yaml
|
||||
plando_items:
|
||||
# Example block - Ocarina of Time
|
||||
|
||||
# example block 2 - Ocarina of Time
|
||||
- items:
|
||||
Kokiri Sword: 1
|
||||
Biggoron Sword: 1
|
||||
Bow: 1
|
||||
Magic Meter: 1
|
||||
Progressive Strength Upgrade: 3
|
||||
Progressive Hookshot: 2
|
||||
locations:
|
||||
- Deku Tree Slingshot Chest
|
||||
- Dodongos Cavern Bomb Bag Chest
|
||||
- Jabu Jabus Belly Boomerang Chest
|
||||
- Bottom of the Well Lens of Truth Chest
|
||||
@@ -205,16 +102,53 @@ they are here for demonstration only.
|
||||
- Water Temple Longshot Chest
|
||||
- Shadow Temple Hover Boots Chest
|
||||
- Spirit Temple Silver Gauntlets Chest
|
||||
from_pool: false
|
||||
|
||||
- item: Kokiri Sword
|
||||
location: Deku Tree Slingshot Chest
|
||||
from_pool: false
|
||||
world: false
|
||||
|
||||
# example block 3 - Factorio
|
||||
- items:
|
||||
progressive-electric-energy-distribution: 2
|
||||
electric-energy-accumulators: 1
|
||||
progressive-turret: 2
|
||||
locations:
|
||||
- military
|
||||
- gun-turret
|
||||
- logistic-science-pack
|
||||
- steel-processing
|
||||
percentage: 80
|
||||
force: true
|
||||
|
||||
# example block 4 - Secret of Evermore
|
||||
- items:
|
||||
Levitate: 1
|
||||
Revealer: 1
|
||||
Energize: 1
|
||||
locations:
|
||||
- Master Sword Pedestal
|
||||
- Boss Relic 1
|
||||
world: true
|
||||
count: 2
|
||||
|
||||
# example block 5 - A Link to the Past
|
||||
- items:
|
||||
Progressive Sword: 4
|
||||
world:
|
||||
- BobsSlaytheSpire
|
||||
- BobsRogueLegacy
|
||||
count:
|
||||
min: 1
|
||||
max: 4
|
||||
```
|
||||
The first block will place the player's Biggoron Sword, Bow, Magic Meter, strength upgrades, and hookshots in the
|
||||
dungeon major item chests. Because the from_pool value is `false`, a copy of these items is added to these locations,
|
||||
while the originals remain in the item pool to be shuffled. The second block will place the Kokiri Sword in the Deku
|
||||
Tree Slingshot Chest, again not from the pool.
|
||||
1. This block has a 50% chance to occur, and if it does, it will place either the Empire Orb or Radiant Orb on another
|
||||
player's Starter Chest 1 and removes the chosen item from the item pool.
|
||||
2. This block will always trigger and will place the player's swords, bow, magic meter, strength upgrades, and hookshots
|
||||
in their own dungeon major item chests.
|
||||
3. This block has an 80% chance of occurring, and when it does, it will place all but 1 of the items randomly among the
|
||||
four locations chosen here.
|
||||
4. This block will always trigger and will attempt to place a random 2 of Levitate, Revealer and Energize into
|
||||
other players' Master Sword Pedestals or Boss Relic 1 locations.
|
||||
5. This block will always trigger and will attempt to place a random number, between 1 and 4, of progressive swords
|
||||
into any locations within the game slots named BobsSlaytheSpire and BobsRogueLegacy.
|
||||
|
||||
|
||||
## Boss Plando
|
||||
|
||||
@@ -260,7 +194,7 @@ relevant guide: [A Link to the Past Plando Guide](/tutorial/A%20Link%20to%20the%
|
||||
|
||||
## Connection Plando
|
||||
|
||||
This is currently only supported by a few games, including A Link to the Past and Ocarina of Time. As the way that these games interact with their
|
||||
This is currently only supported by a few games, including A Link to the Past, Minecraft, and Ocarina of Time. As the way that these games interact with their
|
||||
connections is different, only the basics are explained here. More specific information for connection plando in A Link to the Past can be found in
|
||||
its [plando guide](/tutorial/A%20Link%20to%20the%20Past/plando/en#connections).
|
||||
|
||||
@@ -273,6 +207,7 @@ its [plando guide](/tutorial/A%20Link%20to%20the%20Past/plando/en#connections).
|
||||
|
||||
[A Link to the Past connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/EntranceShuffle.py#L3852)
|
||||
|
||||
[Minecraft connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/data/regions.json#L18****)
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -288,10 +223,19 @@ its [plando guide](/tutorial/A%20Link%20to%20the%20Past/plando/en#connections).
|
||||
- entrance: Agahnims Tower
|
||||
exit: Old Man Cave Exit (West)
|
||||
direction: exit
|
||||
|
||||
|
||||
# example block 2 - Minecraft
|
||||
- entrance: Overworld Structure 1
|
||||
exit: Nether Fortress
|
||||
direction: both
|
||||
- entrance: Overworld Structure 2
|
||||
exit: Village
|
||||
direction: both
|
||||
```
|
||||
|
||||
1. These connections are decoupled, so going into the Lake Hylia Cave Shop will take you to the inside of Cave 45, and
|
||||
when you leave the interior, you will exit to the Cave 45 ledge. Going into the Cave 45 entrance will then take you to
|
||||
the Lake Hylia Cave Shop. Walking into the entrance for the Old Man Cave and Agahnim's Tower entrance will both take
|
||||
you to their locations as normal, but leaving Old Man Cave will exit at Agahnim's Tower.
|
||||
2. This will force a Nether fortress and a village to be the Overworld structures for your game. Note that for the
|
||||
Minecraft connection plando to work structure shuffle must be enabled.
|
||||
|
||||
@@ -34,9 +34,9 @@ from .locations import (JakAndDaxterLocation,
|
||||
cache_location_table,
|
||||
orb_location_table)
|
||||
from .regions import create_regions
|
||||
from .rules import (enforce_mp_absolute_limits,
|
||||
enforce_mp_friendly_limits,
|
||||
enforce_sp_limits,
|
||||
from .rules import (enforce_multiplayer_limits,
|
||||
enforce_singleplayer_limits,
|
||||
verify_orb_trade_amounts,
|
||||
set_orb_trade_rule)
|
||||
from .locs import (cell_locations as cells,
|
||||
scout_locations as scouts,
|
||||
@@ -258,32 +258,19 @@ class JakAndDaxterWorld(World):
|
||||
self.options.mountain_pass_cell_count.value = self.power_cell_thresholds[1]
|
||||
self.options.lava_tube_cell_count.value = self.power_cell_thresholds[2]
|
||||
|
||||
# We would have done this earlier, but we needed to sort the power cell thresholds first. Don't worry, we'll
|
||||
# come back to them.
|
||||
enforce_friendly_options = self.settings.enforce_friendly_options
|
||||
if self.multiworld.players == 1:
|
||||
# For singleplayer games, always enforce/clamp the cell counts to valid values.
|
||||
enforce_sp_limits(self)
|
||||
else:
|
||||
if enforce_friendly_options:
|
||||
# For multiplayer games, we have a host setting to make options fair/sane for other players.
|
||||
# If this setting is enabled, enforce/clamp some friendly limitations on our options.
|
||||
enforce_mp_friendly_limits(self)
|
||||
else:
|
||||
# Even if the setting is disabled, some values must be clamped to avoid generation errors.
|
||||
enforce_mp_absolute_limits(self)
|
||||
|
||||
# That's right, set the collection of thresholds again. Don't just clamp the values without updating this list!
|
||||
self.power_cell_thresholds = [
|
||||
self.options.fire_canyon_cell_count.value,
|
||||
self.options.mountain_pass_cell_count.value,
|
||||
self.options.lava_tube_cell_count.value,
|
||||
100, # The 100 Power Cell Door.
|
||||
]
|
||||
|
||||
# Now that the threshold list is finalized, store this for the remove function.
|
||||
# Store this for remove function.
|
||||
self.power_cell_thresholds_minus_one = [x - 1 for x in self.power_cell_thresholds]
|
||||
|
||||
# For the fairness of other players in a multiworld game, enforce some friendly limitations on our options,
|
||||
# so we don't cause chaos during seed generation. These friendly limits should **guarantee** a successful gen.
|
||||
# We would have done this earlier, but we needed to sort the power cell thresholds first.
|
||||
enforce_friendly_options = self.settings.enforce_friendly_options
|
||||
if enforce_friendly_options:
|
||||
if self.multiworld.players > 1:
|
||||
enforce_multiplayer_limits(self)
|
||||
else:
|
||||
enforce_singleplayer_limits(self)
|
||||
|
||||
# Calculate the number of power cells needed for full region access, the number being replaced by traps,
|
||||
# and the number of remaining filler.
|
||||
if self.options.jak_completion_condition == options.CompletionCondition.option_open_100_cell_door:
|
||||
@@ -295,6 +282,11 @@ class JakAndDaxterWorld(World):
|
||||
self.options.filler_power_cells_replaced_with_traps.value = self.total_trap_cells
|
||||
self.total_filler_cells = non_prog_cells - self.total_trap_cells
|
||||
|
||||
# Verify that we didn't overload the trade amounts with more orbs than exist in the world.
|
||||
# This is easy to do by accident even in a singleplayer world.
|
||||
self.total_trade_orbs = (9 * self.options.citizen_orb_trade_amount) + (6 * self.options.oracle_orb_trade_amount)
|
||||
verify_orb_trade_amounts(self)
|
||||
|
||||
# Cache the orb bundle size and item name for quicker reference.
|
||||
if self.options.enable_orbsanity == options.EnableOrbsanity.option_per_level:
|
||||
self.orb_bundle_size = self.options.level_orbsanity_bundle_size.value
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
- [What do Traps do?](#what-do-traps-do)
|
||||
- [What kind of Traps are there?](#what-kind-of-traps-are-there)
|
||||
- [I got soft-locked and cannot leave, how do I get out of here?](#i-got-soft-locked-and-cannot-leave-how-do-i-get-out-of-here)
|
||||
- [How do I generate seeds with 1 Orb Orbsanity and other extreme options?](#how-do-i-generate-seeds-with-1-orb-orbsanity-and-other-extreme-options)
|
||||
- [Why did I get an Option Error when generating a seed, and how do I fix it?](#why-did-i-get-an-option-error-when-generating-a-seed-and-how-do-i-fix-it)
|
||||
- [How do I check my player options in-game?](#how-do-i-check-my-player-options-in-game)
|
||||
- [How does the HUD work?](#how-does-the-hud-work)
|
||||
- [I think I found a bug, where should I report it?](#i-think-i-found-a-bug-where-should-i-report-it)
|
||||
@@ -201,19 +201,16 @@ Open the game's menu, navigate to `Options`, then `Archipelago Options`, then `W
|
||||
Selecting this option will ask if you want to be teleported to Geyser Rock. From there, you can teleport back
|
||||
to the nearest sage's hut to continue your journey.
|
||||
|
||||
## How do I generate seeds with 1 orb orbsanity and other extreme options?
|
||||
## Why did I get an Option Error when generating a seed and how do I fix it
|
||||
Depending on your player YAML, Jak and Daxter can have a lot of items, which can sometimes be overwhelming or
|
||||
disruptive to multiworld games. There are also options that are mutually incompatible with each other, even in a solo
|
||||
game. To prevent the game from disrupting multiworlds, or generating an impossible solo seed, some options have
|
||||
"friendly limits" that prevent you from choosing more extreme values.
|
||||
Singleplayer and Multiplayer Minimums and Maximums, collectively called "friendly limits."
|
||||
|
||||
You can override **some**, not all, of those limits by editing the `host.yaml`. In the Archipelago Launcher, click
|
||||
`Open host.yaml`, then search for `jakanddaxter_options`, then search for `enforce_friendly_options`, then change this
|
||||
value from `true` to `false`. You can then generate a seed locally, and upload that to the Archipelago website to host
|
||||
for you (or host it yourself).
|
||||
|
||||
**Remember:** disabling this setting allows for more disruptive and challenging options, but it may cause seed
|
||||
generation to fail. **Use at your own risk!**
|
||||
If you're generating a solo game, or your multiworld host agrees to your request, you can override those limits by
|
||||
editing the `host.yaml`. In the Archipelago Launcher, click `Open host.yaml`, then search for `jakanddaxter_options`,
|
||||
then search for `enforce_friendly_options`, then change this value from `true` to `false`. Disabling this allows for
|
||||
more disruptive and challenging options, but it may cause seed generation to fail. **Use at your own risk!**
|
||||
|
||||
## How do I check my player options in-game
|
||||
When you connect your text client to the Archipelago Server, the server will tell the game what options were chosen
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
- A legally purchased copy of *Jak And Daxter: The Precursor Legacy.*
|
||||
- [The OpenGOAL Launcher](https://opengoal.dev/)
|
||||
- [The Jak and Daxter .APWORLD package](https://github.com/ArchipelaGOAL/Archipelago/releases)
|
||||
|
||||
At this time, this method of setup works on Windows only, but Linux support is a strong likelihood in the near future as OpenGOAL itself supports Linux.
|
||||
|
||||
@@ -74,7 +75,7 @@ If you are in the middle of an async game, and you do not want to update the mod
|
||||
### New Game
|
||||
|
||||
- Run the Archipelago Launcher.
|
||||
- From the client list, find and click `Jak and Daxter Client`.
|
||||
- From the right-most list, find and click `Jak and Daxter Client`.
|
||||
- 3 new windows should appear:
|
||||
- The OpenGOAL compiler will launch and compile the game. They should take about 30 seconds to compile.
|
||||
- You should hear a musical cue to indicate the compilation was a success. If you do not, see the Troubleshooting section.
|
||||
|
||||
@@ -1,78 +1,22 @@
|
||||
from dataclasses import dataclass
|
||||
from functools import cached_property
|
||||
from Options import PerGameCommonOptions, StartInventoryPool, Toggle, Choice, Range, DefaultOnToggle, OptionCounter, \
|
||||
AssembleOptions
|
||||
from Options import PerGameCommonOptions, StartInventoryPool, Toggle, Choice, Range, DefaultOnToggle, OptionCounter
|
||||
from .items import trap_item_table
|
||||
|
||||
|
||||
class readonly_classproperty:
|
||||
"""This decorator is used for getting friendly or unfriendly range_end values for options like FireCanyonCellCount
|
||||
and CitizenOrbTradeAmount. We only need to provide a getter as we will only be setting a single int to one of two
|
||||
values."""
|
||||
def __init__(self, getter):
|
||||
self.getter = getter
|
||||
class StaticGetter:
|
||||
def __init__(self, func):
|
||||
self.fget = func
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
return self.getter(owner)
|
||||
return self.fget(owner)
|
||||
|
||||
|
||||
@readonly_classproperty
|
||||
@StaticGetter
|
||||
def determine_range_end(cls) -> int:
|
||||
from . import JakAndDaxterWorld # Avoid circular imports.
|
||||
friendly = JakAndDaxterWorld.settings.enforce_friendly_options
|
||||
return cls.friendly_maximum if friendly else cls.absolute_maximum
|
||||
|
||||
|
||||
class classproperty:
|
||||
"""This decorator (?) is used for getting and setting friendly or unfriendly option values for the Orbsanity
|
||||
options."""
|
||||
def __init__(self, getter, setter):
|
||||
self.getter = getter
|
||||
self.setter = setter
|
||||
|
||||
def __get__(self, obj, value):
|
||||
return self.getter(obj)
|
||||
|
||||
def __set__(self, obj, value):
|
||||
self.setter(obj, value)
|
||||
|
||||
|
||||
class AllowedChoiceMeta(AssembleOptions):
|
||||
"""This metaclass overrides AssembleOptions and provides inheriting classes a way to filter out "disallowed" values
|
||||
by way of implementing get_disallowed_options. This function is used by Jak and Daxter to check host.yaml settings
|
||||
without circular imports or breaking the settings API."""
|
||||
_name_lookup: dict[int, str]
|
||||
_options: dict[str, int]
|
||||
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
ret = super().__new__(mcs, name, bases, attrs)
|
||||
ret._name_lookup = attrs["name_lookup"]
|
||||
ret._options = attrs["options"]
|
||||
return ret
|
||||
|
||||
def set_name_lookup(cls, value : dict[int, str]):
|
||||
cls._name_lookup = value
|
||||
|
||||
def get_name_lookup(cls) -> dict[int, str]:
|
||||
cls._name_lookup = {k: v for k, v in cls._name_lookup.items() if k not in cls.get_disallowed_options()}
|
||||
return cls._name_lookup
|
||||
|
||||
def set_options(cls, value: dict[str, int]):
|
||||
cls._options = value
|
||||
|
||||
def get_options(cls) -> dict[str, int]:
|
||||
cls._options = {k: v for k, v in cls._options.items() if v not in cls.get_disallowed_options()}
|
||||
return cls._options
|
||||
|
||||
def get_disallowed_options(cls):
|
||||
return {}
|
||||
|
||||
name_lookup = classproperty(get_name_lookup, set_name_lookup)
|
||||
options = classproperty(get_options, set_options)
|
||||
|
||||
|
||||
class AllowedChoice(Choice, metaclass=AllowedChoiceMeta):
|
||||
pass
|
||||
from . import JakAndDaxterWorld
|
||||
enforce_friendly_options = JakAndDaxterWorld.settings.enforce_friendly_options
|
||||
return cls.friendly_maximum if enforce_friendly_options else cls.absolute_maximum
|
||||
|
||||
|
||||
class EnableMoveRandomizer(Toggle):
|
||||
@@ -100,13 +44,12 @@ class EnableOrbsanity(Choice):
|
||||
default = 0
|
||||
|
||||
|
||||
class GlobalOrbsanityBundleSize(AllowedChoice):
|
||||
class GlobalOrbsanityBundleSize(Choice):
|
||||
"""The orb bundle size for Global Orbsanity. This only applies if "Enable Orbsanity" is set to "Global."
|
||||
There are 2000 orbs in the game, so your bundle size must be a factor of 2000.
|
||||
|
||||
This value is restricted to safe minimum and maximum values to ensure valid singleplayer games and
|
||||
non-disruptive multiplayer games, but the host can remove this restriction by turning off enforce_friendly_options
|
||||
in host.yaml."""
|
||||
Multiplayer Minimum: 10
|
||||
Multiplayer Maximum: 200"""
|
||||
display_name = "Global Orbsanity Bundle Size"
|
||||
option_1_orb = 1
|
||||
option_2_orbs = 2
|
||||
@@ -132,33 +75,12 @@ class GlobalOrbsanityBundleSize(AllowedChoice):
|
||||
friendly_maximum = 200
|
||||
default = 20
|
||||
|
||||
@classmethod
|
||||
def get_disallowed_options(cls) -> set[int]:
|
||||
try:
|
||||
from . import JakAndDaxterWorld
|
||||
if JakAndDaxterWorld.settings.enforce_friendly_options:
|
||||
return {cls.option_1_orb,
|
||||
cls.option_2_orbs,
|
||||
cls.option_4_orbs,
|
||||
cls.option_5_orbs,
|
||||
cls.option_8_orbs,
|
||||
cls.option_250_orbs,
|
||||
cls.option_400_orbs,
|
||||
cls.option_500_orbs,
|
||||
cls.option_1000_orbs,
|
||||
cls.option_2000_orbs}
|
||||
except ImportError:
|
||||
pass
|
||||
return set()
|
||||
|
||||
|
||||
class PerLevelOrbsanityBundleSize(AllowedChoice):
|
||||
class PerLevelOrbsanityBundleSize(Choice):
|
||||
"""The orb bundle size for Per Level Orbsanity. This only applies if "Enable Orbsanity" is set to "Per Level."
|
||||
There are 50, 150, or 200 orbs per level, so your bundle size must be a factor of 50.
|
||||
|
||||
This value is restricted to safe minimum and maximum values to ensure valid singleplayer games and
|
||||
non-disruptive multiplayer games, but the host can remove this restriction by turning off enforce_friendly_options
|
||||
in host.yaml."""
|
||||
Multiplayer Minimum: 10"""
|
||||
display_name = "Per Level Orbsanity Bundle Size"
|
||||
option_1_orb = 1
|
||||
option_2_orbs = 2
|
||||
@@ -169,18 +91,6 @@ class PerLevelOrbsanityBundleSize(AllowedChoice):
|
||||
friendly_minimum = 10
|
||||
default = 25
|
||||
|
||||
@classmethod
|
||||
def get_disallowed_options(cls) -> set[int]:
|
||||
try:
|
||||
from . import JakAndDaxterWorld
|
||||
if JakAndDaxterWorld.settings.enforce_friendly_options:
|
||||
return {cls.option_1_orb,
|
||||
cls.option_2_orbs,
|
||||
cls.option_5_orbs}
|
||||
except ImportError:
|
||||
pass
|
||||
return set()
|
||||
|
||||
|
||||
class FireCanyonCellCount(Range):
|
||||
"""The number of power cells you need to cross Fire Canyon. This value is restricted to a safe maximum value to
|
||||
@@ -324,7 +234,7 @@ class CompletionCondition(Choice):
|
||||
option_cross_fire_canyon = 69
|
||||
option_cross_mountain_pass = 87
|
||||
option_cross_lava_tube = 89
|
||||
# option_defeat_dark_eco_plant = 6
|
||||
option_defeat_dark_eco_plant = 6
|
||||
option_defeat_klaww = 86
|
||||
option_defeat_gol_and_maia = 112
|
||||
option_open_100_cell_door = 116
|
||||
|
||||
@@ -115,8 +115,8 @@ def create_regions(world: "JakAndDaxterWorld"):
|
||||
elif options.jak_completion_condition == CompletionCondition.option_cross_lava_tube:
|
||||
multiworld.completion_condition[player] = lambda state: state.can_reach(gmc, "Region", player)
|
||||
|
||||
# elif options.jak_completion_condition == CompletionCondition.option_defeat_dark_eco_plant:
|
||||
# multiworld.completion_condition[player] = lambda state: state.can_reach(fjp, "Region", player)
|
||||
elif options.jak_completion_condition == CompletionCondition.option_defeat_dark_eco_plant:
|
||||
multiworld.completion_condition[player] = lambda state: state.can_reach(fjp, "Region", player)
|
||||
|
||||
elif options.jak_completion_condition == CompletionCondition.option_defeat_klaww:
|
||||
multiworld.completion_condition[player] = lambda state: state.can_reach(mp, "Region", player)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import logging
|
||||
import math
|
||||
import typing
|
||||
from BaseClasses import CollectionState
|
||||
from Options import OptionError
|
||||
@@ -133,138 +131,100 @@ def can_fight(state: CollectionState, player: int) -> bool:
|
||||
return state.has_any(("Jump Dive", "Jump Kick", "Punch", "Kick"), player)
|
||||
|
||||
|
||||
def clamp_cell_limits(world: "JakAndDaxterWorld") -> str:
|
||||
def enforce_multiplayer_limits(world: "JakAndDaxterWorld"):
|
||||
options = world.options
|
||||
friendly_message = ""
|
||||
|
||||
if (options.enable_orbsanity == EnableOrbsanity.option_global
|
||||
and (options.global_orbsanity_bundle_size.value < GlobalOrbsanityBundleSize.friendly_minimum
|
||||
or options.global_orbsanity_bundle_size.value > GlobalOrbsanityBundleSize.friendly_maximum)):
|
||||
friendly_message += (f" "
|
||||
f"{options.global_orbsanity_bundle_size.display_name} must be no less than "
|
||||
f"{GlobalOrbsanityBundleSize.friendly_minimum} and no greater than "
|
||||
f"{GlobalOrbsanityBundleSize.friendly_maximum} (currently "
|
||||
f"{options.global_orbsanity_bundle_size.value}).\n")
|
||||
|
||||
if (options.enable_orbsanity == EnableOrbsanity.option_per_level
|
||||
and options.level_orbsanity_bundle_size.value < PerLevelOrbsanityBundleSize.friendly_minimum):
|
||||
friendly_message += (f" "
|
||||
f"{options.level_orbsanity_bundle_size.display_name} must be no less than "
|
||||
f"{PerLevelOrbsanityBundleSize.friendly_minimum} (currently "
|
||||
f"{options.level_orbsanity_bundle_size.value}).\n")
|
||||
|
||||
if options.fire_canyon_cell_count.value > FireCanyonCellCount.friendly_maximum:
|
||||
friendly_message += (f" "
|
||||
f"{options.fire_canyon_cell_count.display_name} must be no greater than "
|
||||
f"{FireCanyonCellCount.friendly_maximum} (currently "
|
||||
f"{options.fire_canyon_cell_count.value}).\n")
|
||||
|
||||
if options.mountain_pass_cell_count.value > MountainPassCellCount.friendly_maximum:
|
||||
friendly_message += (f" "
|
||||
f"{options.mountain_pass_cell_count.display_name} must be no greater than "
|
||||
f"{MountainPassCellCount.friendly_maximum} (currently "
|
||||
f"{options.mountain_pass_cell_count.value}).\n")
|
||||
|
||||
if options.lava_tube_cell_count.value > LavaTubeCellCount.friendly_maximum:
|
||||
friendly_message += (f" "
|
||||
f"{options.lava_tube_cell_count.display_name} must be no greater than "
|
||||
f"{LavaTubeCellCount.friendly_maximum} (currently "
|
||||
f"{options.lava_tube_cell_count.value}).\n")
|
||||
|
||||
if options.citizen_orb_trade_amount.value > CitizenOrbTradeAmount.friendly_maximum:
|
||||
friendly_message += (f" "
|
||||
f"{options.citizen_orb_trade_amount.display_name} must be no greater than "
|
||||
f"{CitizenOrbTradeAmount.friendly_maximum} (currently "
|
||||
f"{options.citizen_orb_trade_amount.value}).\n")
|
||||
|
||||
if options.oracle_orb_trade_amount.value > OracleOrbTradeAmount.friendly_maximum:
|
||||
friendly_message += (f" "
|
||||
f"{options.oracle_orb_trade_amount.display_name} must be no greater than "
|
||||
f"{OracleOrbTradeAmount.friendly_maximum} (currently "
|
||||
f"{options.oracle_orb_trade_amount.value}).\n")
|
||||
|
||||
if friendly_message != "":
|
||||
raise OptionError(f"{world.player_name}: The options you have chosen may disrupt the multiworld. \n"
|
||||
f"Please adjust the following Options for a multiplayer game. \n"
|
||||
f"{friendly_message}"
|
||||
f"Or use 'random-range-x-y' instead of 'random' in your player yaml.\n"
|
||||
f"Or set 'enforce_friendly_options' in the seed generator's host.yaml to false. "
|
||||
f"(Use at your own risk!)")
|
||||
|
||||
|
||||
def enforce_singleplayer_limits(world: "JakAndDaxterWorld"):
|
||||
options = world.options
|
||||
friendly_message = ""
|
||||
|
||||
if options.fire_canyon_cell_count.value > FireCanyonCellCount.friendly_maximum:
|
||||
old_value = options.fire_canyon_cell_count.value
|
||||
options.fire_canyon_cell_count.value = FireCanyonCellCount.friendly_maximum
|
||||
friendly_message += (f" "
|
||||
f"{options.fire_canyon_cell_count.display_name} must be no greater than "
|
||||
f"{FireCanyonCellCount.friendly_maximum} (was {old_value}), "
|
||||
f"changed option to appropriate value.\n")
|
||||
f"{FireCanyonCellCount.friendly_maximum} (currently "
|
||||
f"{options.fire_canyon_cell_count.value}).\n")
|
||||
|
||||
if options.mountain_pass_cell_count.value > MountainPassCellCount.friendly_maximum:
|
||||
old_value = options.mountain_pass_cell_count.value
|
||||
options.mountain_pass_cell_count.value = MountainPassCellCount.friendly_maximum
|
||||
friendly_message += (f" "
|
||||
f"{options.mountain_pass_cell_count.display_name} must be no greater than "
|
||||
f"{MountainPassCellCount.friendly_maximum} (was {old_value}), "
|
||||
f"changed option to appropriate value.\n")
|
||||
f"{MountainPassCellCount.friendly_maximum} (currently "
|
||||
f"{options.mountain_pass_cell_count.value}).\n")
|
||||
|
||||
if options.lava_tube_cell_count.value > LavaTubeCellCount.friendly_maximum:
|
||||
old_value = options.lava_tube_cell_count.value
|
||||
options.lava_tube_cell_count.value = LavaTubeCellCount.friendly_maximum
|
||||
friendly_message += (f" "
|
||||
f"{options.lava_tube_cell_count.display_name} must be no greater than "
|
||||
f"{LavaTubeCellCount.friendly_maximum} (was {old_value}), "
|
||||
f"changed option to appropriate value.\n")
|
||||
f"{LavaTubeCellCount.friendly_maximum} (currently "
|
||||
f"{options.lava_tube_cell_count.value}).\n")
|
||||
|
||||
return friendly_message
|
||||
if friendly_message != "":
|
||||
raise OptionError(f"The options you have chosen may result in seed generation failures. \n"
|
||||
f"Please adjust the following Options for a singleplayer game. \n"
|
||||
f"{friendly_message}"
|
||||
f"Or use 'random-range-x-y' instead of 'random' in your player yaml.\n"
|
||||
f"Or set 'enforce_friendly_options' in your host.yaml to false. "
|
||||
f"(Use at your own risk!)")
|
||||
|
||||
|
||||
def clamp_trade_total_limits(world: "JakAndDaxterWorld"):
|
||||
"""Check if we need to recalculate the 2 trade orb options so the total fits under 2000. If so let's keep them
|
||||
proportional relative to each other. Then we'll recalculate total_trade_orbs. Remember this situation is
|
||||
only possible if both values are greater than 0, otherwise the absolute maximums would keep them under 2000."""
|
||||
options = world.options
|
||||
friendly_message = ""
|
||||
def verify_orb_trade_amounts(world: "JakAndDaxterWorld"):
|
||||
|
||||
world.total_trade_orbs = (9 * options.citizen_orb_trade_amount) + (6 * options.oracle_orb_trade_amount)
|
||||
if world.total_trade_orbs > 2000:
|
||||
old_total = world.total_trade_orbs
|
||||
old_citizen_value = options.citizen_orb_trade_amount.value
|
||||
old_oracle_value = options.oracle_orb_trade_amount.value
|
||||
|
||||
coefficient = old_oracle_value / old_citizen_value
|
||||
|
||||
options.citizen_orb_trade_amount.value = math.floor(2000 / (9 + (6 * coefficient)))
|
||||
options.oracle_orb_trade_amount.value = math.floor(coefficient * options.citizen_orb_trade_amount.value)
|
||||
world.total_trade_orbs = (9 * options.citizen_orb_trade_amount) + (6 * options.oracle_orb_trade_amount)
|
||||
|
||||
friendly_message += (f" "
|
||||
f"Required number of orbs ({old_total}) must be no greater than total orbs in the game "
|
||||
f"(2000). Reduced the value of {world.options.citizen_orb_trade_amount.display_name} "
|
||||
f"from {old_citizen_value} to {options.citizen_orb_trade_amount.value} and "
|
||||
f"{world.options.oracle_orb_trade_amount.display_name} from {old_oracle_value} to "
|
||||
f"{options.oracle_orb_trade_amount.value}.\n")
|
||||
|
||||
return friendly_message
|
||||
|
||||
|
||||
def enforce_mp_friendly_limits(world: "JakAndDaxterWorld"):
|
||||
options = world.options
|
||||
friendly_message = ""
|
||||
|
||||
if options.enable_orbsanity == EnableOrbsanity.option_global:
|
||||
if options.global_orbsanity_bundle_size.value < GlobalOrbsanityBundleSize.friendly_minimum:
|
||||
old_value = options.global_orbsanity_bundle_size.value
|
||||
options.global_orbsanity_bundle_size.value = GlobalOrbsanityBundleSize.friendly_minimum
|
||||
friendly_message += (f" "
|
||||
f"{options.global_orbsanity_bundle_size.display_name} must be no less than "
|
||||
f"{GlobalOrbsanityBundleSize.friendly_minimum} (was {old_value}), "
|
||||
f"changed option to appropriate value.\n")
|
||||
|
||||
if options.global_orbsanity_bundle_size.value > GlobalOrbsanityBundleSize.friendly_maximum:
|
||||
old_value = options.global_orbsanity_bundle_size.value
|
||||
options.global_orbsanity_bundle_size.value = GlobalOrbsanityBundleSize.friendly_maximum
|
||||
friendly_message += (f" "
|
||||
f"{options.global_orbsanity_bundle_size.display_name} must be no greater than "
|
||||
f"{GlobalOrbsanityBundleSize.friendly_maximum} (was {old_value}), "
|
||||
f"changed option to appropriate value.\n")
|
||||
|
||||
if options.enable_orbsanity == EnableOrbsanity.option_per_level:
|
||||
if options.level_orbsanity_bundle_size.value < PerLevelOrbsanityBundleSize.friendly_minimum:
|
||||
old_value = options.level_orbsanity_bundle_size.value
|
||||
options.level_orbsanity_bundle_size.value = PerLevelOrbsanityBundleSize.friendly_minimum
|
||||
friendly_message += (f" "
|
||||
f"{options.level_orbsanity_bundle_size.display_name} must be no less than "
|
||||
f"{PerLevelOrbsanityBundleSize.friendly_minimum} (was {old_value}), "
|
||||
f"changed option to appropriate value.\n")
|
||||
|
||||
if options.citizen_orb_trade_amount.value > CitizenOrbTradeAmount.friendly_maximum:
|
||||
old_value = options.citizen_orb_trade_amount.value
|
||||
options.citizen_orb_trade_amount.value = CitizenOrbTradeAmount.friendly_maximum
|
||||
friendly_message += (f" "
|
||||
f"{options.citizen_orb_trade_amount.display_name} must be no greater than "
|
||||
f"{CitizenOrbTradeAmount.friendly_maximum} (was {old_value}), "
|
||||
f"changed option to appropriate value.\n")
|
||||
|
||||
if options.oracle_orb_trade_amount.value > OracleOrbTradeAmount.friendly_maximum:
|
||||
old_value = options.oracle_orb_trade_amount.value
|
||||
options.oracle_orb_trade_amount.value = OracleOrbTradeAmount.friendly_maximum
|
||||
friendly_message += (f" "
|
||||
f"{options.oracle_orb_trade_amount.display_name} must be no greater than "
|
||||
f"{OracleOrbTradeAmount.friendly_maximum} (was {old_value}), "
|
||||
f"changed option to appropriate value.\n")
|
||||
|
||||
friendly_message += clamp_cell_limits(world)
|
||||
friendly_message += clamp_trade_total_limits(world)
|
||||
|
||||
if friendly_message != "":
|
||||
logging.warning(f"{world.player_name}: Your options have been modified to avoid disrupting the multiworld.\n"
|
||||
f"{friendly_message}"
|
||||
f"You can access more advanced options by setting 'enforce_friendly_options' in the seed "
|
||||
f"generator's host.yaml to false and generating locally. (Use at your own risk!)")
|
||||
|
||||
|
||||
def enforce_mp_absolute_limits(world: "JakAndDaxterWorld"):
|
||||
friendly_message = ""
|
||||
|
||||
friendly_message += clamp_trade_total_limits(world)
|
||||
|
||||
if friendly_message != "":
|
||||
logging.warning(f"{world.player_name}: Your options have been modified to avoid seed generation failures.\n"
|
||||
f"{friendly_message}")
|
||||
|
||||
|
||||
def enforce_sp_limits(world: "JakAndDaxterWorld"):
|
||||
friendly_message = ""
|
||||
|
||||
friendly_message += clamp_cell_limits(world)
|
||||
friendly_message += clamp_trade_total_limits(world)
|
||||
|
||||
if friendly_message != "":
|
||||
logging.warning(f"{world.player_name}: Your options have been modified to avoid seed generation failures.\n"
|
||||
f"{friendly_message}")
|
||||
raise OptionError(f"{world.player_name}: Required number of orbs for all trades ({world.total_trade_orbs}) "
|
||||
f"is more than all the orbs in the game (2000). Reduce the value of either "
|
||||
f"{world.options.citizen_orb_trade_amount.display_name} "
|
||||
f"or {world.options.oracle_orb_trade_amount.display_name}.")
|
||||
|
||||
@@ -4,14 +4,14 @@ from .bases import JakAndDaxterTestBase
|
||||
class TradesCostNothingTest(JakAndDaxterTestBase):
|
||||
options = {
|
||||
"enable_orbsanity": 2,
|
||||
"global_orbsanity_bundle_size": 10,
|
||||
"global_orbsanity_bundle_size": 5,
|
||||
"citizen_orb_trade_amount": 0,
|
||||
"oracle_orb_trade_amount": 0
|
||||
}
|
||||
|
||||
def test_orb_items_are_filler(self):
|
||||
self.collect_all_but("")
|
||||
self.assertNotIn("10 Precursor Orbs", self.multiworld.state.prog_items)
|
||||
self.assertNotIn("5 Precursor Orbs", self.multiworld.state.prog_items)
|
||||
|
||||
def test_trades_are_accessible(self):
|
||||
self.assertTrue(self.multiworld
|
||||
@@ -22,15 +22,15 @@ class TradesCostNothingTest(JakAndDaxterTestBase):
|
||||
class TradesCostEverythingTest(JakAndDaxterTestBase):
|
||||
options = {
|
||||
"enable_orbsanity": 2,
|
||||
"global_orbsanity_bundle_size": 10,
|
||||
"global_orbsanity_bundle_size": 5,
|
||||
"citizen_orb_trade_amount": 120,
|
||||
"oracle_orb_trade_amount": 150
|
||||
}
|
||||
|
||||
def test_orb_items_are_progression(self):
|
||||
self.collect_all_but("")
|
||||
self.assertIn("10 Precursor Orbs", self.multiworld.state.prog_items[self.player])
|
||||
self.assertEqual(198, self.multiworld.state.prog_items[self.player]["10 Precursor Orbs"])
|
||||
self.assertIn("5 Precursor Orbs", self.multiworld.state.prog_items[self.player])
|
||||
self.assertEqual(396, self.multiworld.state.prog_items[self.player]["5 Precursor Orbs"])
|
||||
|
||||
def test_trades_are_accessible(self):
|
||||
self.collect_all_but("")
|
||||
|
||||
@@ -90,7 +90,7 @@ def cmd_gift(self: "SNIClientCommandProcessor") -> None:
|
||||
async_start(update_object(self.ctx, f"Giftboxes;{self.ctx.team}", {
|
||||
f"{self.ctx.slot}":
|
||||
{
|
||||
"is_open": handler.gifting,
|
||||
"IsOpen": handler.gifting,
|
||||
**kdl3_gifting_options
|
||||
}
|
||||
}))
|
||||
@@ -175,11 +175,11 @@ class KDL3SNIClient(SNIClient):
|
||||
key, gift = ctx.stored_data[self.giftbox_key].popitem()
|
||||
await pop_object(ctx, self.giftbox_key, key)
|
||||
# first, special cases
|
||||
traits = [trait["trait"] for trait in gift["traits"]]
|
||||
traits = [trait["Trait"] for trait in gift["Traits"]]
|
||||
if "Candy" in traits or "Invincible" in traits:
|
||||
# apply invincibility candy
|
||||
self.item_queue.append(0x43)
|
||||
elif "Tomato" in traits or "tomato" in gift["item_name"].lower():
|
||||
elif "Tomato" in traits or "tomato" in gift["ItemName"].lower():
|
||||
# apply maxim tomato
|
||||
# only want tomatos here, no other vegetable is that good
|
||||
self.item_queue.append(0x42)
|
||||
@@ -187,7 +187,7 @@ class KDL3SNIClient(SNIClient):
|
||||
# Apply 1-Up
|
||||
self.item_queue.append(0x41)
|
||||
elif "Currency" in traits or "Star" in traits:
|
||||
value = gift.get("item_value", 1)
|
||||
value = gift["ItemValue"]
|
||||
if value >= 50000:
|
||||
self.item_queue.append(0x46)
|
||||
elif value >= 30000:
|
||||
@@ -210,8 +210,8 @@ class KDL3SNIClient(SNIClient):
|
||||
# check if it's tasty
|
||||
if any(x in traits for x in ["Consumable", "Food", "Drink", "Heal", "Health"]):
|
||||
# it's tasty!, use quality to decide how much to heal
|
||||
quality = max((trait.get("quality", 1.0) for trait in gift["traits"]
|
||||
if trait["trait"] in ["Consumable", "Food", "Drink", "Heal", "Health"]))
|
||||
quality = max((trait["Quality"] for trait in gift["Traits"]
|
||||
if trait["Trait"] in ["Consumable", "Food", "Drink", "Heal", "Health"]))
|
||||
quality = min(10, quality * 2)
|
||||
else:
|
||||
# it's not really edible, but he'll eat it anyway
|
||||
@@ -236,23 +236,23 @@ class KDL3SNIClient(SNIClient):
|
||||
for slot, info in ctx.stored_data[self.motherbox_key].items():
|
||||
if int(slot) == ctx.slot and len(ctx.stored_data[self.motherbox_key]) > 1:
|
||||
continue
|
||||
desire = len(set(info["desired_traits"]).intersection([trait["trait"] for trait in gift_base["traits"]]))
|
||||
desire = len(set(info["DesiredTraits"]).intersection([trait["Trait"] for trait in gift_base["Traits"]]))
|
||||
if desire > most_applicable:
|
||||
most_applicable = desire
|
||||
most_applicable_slot = int(slot)
|
||||
elif most_applicable_slot == ctx.slot and most_applicable == -1 and info["accepts_any_gift"]:
|
||||
elif most_applicable_slot != ctx.slot and most_applicable == -1 and info["AcceptsAnyGift"]:
|
||||
# only send to ourselves if no one else will take it
|
||||
most_applicable_slot = int(slot)
|
||||
# print(most_applicable, most_applicable_slot)
|
||||
item_uuid = uuid.uuid4().hex
|
||||
item = {
|
||||
**gift_base,
|
||||
"id": item_uuid,
|
||||
"sender_slot": ctx.slot,
|
||||
"receiver_slot": most_applicable_slot,
|
||||
"sender_team": ctx.team,
|
||||
"receiver_team": ctx.team, # for the moment
|
||||
"is_refund": False
|
||||
"ID": item_uuid,
|
||||
"Sender": ctx.player_names[ctx.slot],
|
||||
"Receiver": ctx.player_names[most_applicable_slot],
|
||||
"SenderTeam": ctx.team,
|
||||
"ReceiverTeam": ctx.team, # for the moment
|
||||
"IsRefund": False
|
||||
}
|
||||
# print(item)
|
||||
await update_object(ctx, f"Giftbox;{ctx.team};{most_applicable_slot}", {
|
||||
@@ -276,9 +276,8 @@ class KDL3SNIClient(SNIClient):
|
||||
if not self.initialize_gifting:
|
||||
self.giftbox_key = f"Giftbox;{ctx.team};{ctx.slot}"
|
||||
self.motherbox_key = f"Giftboxes;{ctx.team}"
|
||||
enable_gifting = await snes_read(ctx, KDL3_GIFTING_FLAG, 0x02)
|
||||
await initialize_giftboxes(ctx, self.giftbox_key, self.motherbox_key,
|
||||
bool(int.from_bytes(enable_gifting, "little")))
|
||||
enable_gifting = await snes_read(ctx, KDL3_GIFTING_FLAG, 0x01)
|
||||
await initialize_giftboxes(ctx, self.giftbox_key, self.motherbox_key, bool(enable_gifting[0]))
|
||||
self.initialize_gifting = True
|
||||
# can't check debug anymore, without going and copying the value. might be important later.
|
||||
if not self.levels:
|
||||
@@ -351,19 +350,19 @@ class KDL3SNIClient(SNIClient):
|
||||
self.item_queue.append(item_idx | 0x80)
|
||||
|
||||
# handle gifts here
|
||||
gifting_status = int.from_bytes(await snes_read(ctx, KDL3_GIFTING_FLAG, 0x02), "little")
|
||||
if hasattr(self, "gifting") and self.gifting:
|
||||
if gifting_status:
|
||||
gifting_status = await snes_read(ctx, KDL3_GIFTING_FLAG, 0x01)
|
||||
if hasattr(ctx, "gifting") and ctx.gifting:
|
||||
if gifting_status[0]:
|
||||
gift = await snes_read(ctx, KDL3_GIFTING_SEND, 0x01)
|
||||
if gift[0]:
|
||||
# we have a gift to send
|
||||
await self.pick_gift_recipient(ctx, gift[0])
|
||||
snes_buffered_write(ctx, KDL3_GIFTING_SEND, bytes([0x00]))
|
||||
else:
|
||||
snes_buffered_write(ctx, KDL3_GIFTING_FLAG, bytes([0x01, 0x00]))
|
||||
snes_buffered_write(ctx, KDL3_GIFTING_FLAG, bytes([0x01]))
|
||||
else:
|
||||
if gifting_status:
|
||||
snes_buffered_write(ctx, KDL3_GIFTING_FLAG, bytes([0x00, 0x00]))
|
||||
if gifting_status[0]:
|
||||
snes_buffered_write(ctx, KDL3_GIFTING_FLAG, bytes([0x00]))
|
||||
|
||||
await snes_flush_writes(ctx)
|
||||
|
||||
|
||||
@@ -37,158 +37,157 @@ async def initialize_giftboxes(ctx: "SNIContext", giftbox_key: str, motherbox_ke
|
||||
ctx.set_notify(motherbox_key, giftbox_key)
|
||||
await update_object(ctx, f"Giftboxes;{ctx.team}", {f"{ctx.slot}":
|
||||
{
|
||||
"is_open": is_open,
|
||||
"IsOpen": is_open,
|
||||
**kdl3_gifting_options
|
||||
}})
|
||||
await update_object(ctx, f"Giftbox;{ctx.team};{ctx.slot}", {})
|
||||
ctx.client_handler.gifting = is_open
|
||||
|
||||
|
||||
kdl3_gifting_options = {
|
||||
"accepts_any_gift": True,
|
||||
"desired_traits": [
|
||||
"AcceptsAnyGift": True,
|
||||
"DesiredTraits": [
|
||||
"Consumable", "Food", "Drink", "Candy", "Tomato",
|
||||
"Invincible", "Life", "Heal", "Health", "Trap",
|
||||
"Goo", "Gel", "Slow", "Slowness", "Eject", "Removal"
|
||||
],
|
||||
"minimum_gift_version": 3,
|
||||
"MinimumGiftVersion": 2,
|
||||
}
|
||||
|
||||
kdl3_gifts = {
|
||||
1: {
|
||||
"item_name": "1-Up",
|
||||
"amount": 1,
|
||||
"item_value": 400000,
|
||||
"traits": [
|
||||
"ItemName": "1-Up",
|
||||
"Amount": 1,
|
||||
"ItemValue": 400000,
|
||||
"Traits": [
|
||||
{
|
||||
"trait": "Consumable",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Consumable",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Life",
|
||||
"quality": 1,
|
||||
"duration": 1
|
||||
"Trait": "Life",
|
||||
"Quality": 1,
|
||||
"Duration": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
2: {
|
||||
"item_name": "Maxim Tomato",
|
||||
"amount": 1,
|
||||
"item_value": 500000,
|
||||
"traits": [
|
||||
"ItemName": "Maxim Tomato",
|
||||
"Amount": 1,
|
||||
"ItemValue": 500000,
|
||||
"Traits": [
|
||||
{
|
||||
"trait": "Consumable",
|
||||
"quality": 5,
|
||||
"duration": 1,
|
||||
"Trait": "Consumable",
|
||||
"Quality": 5,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Heal",
|
||||
"quality": 5,
|
||||
"duration": 1,
|
||||
"Trait": "Heal",
|
||||
"Quality": 5,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Food",
|
||||
"quality": 5,
|
||||
"duration": 1,
|
||||
"Trait": "Food",
|
||||
"Quality": 5,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Tomato",
|
||||
"quality": 5,
|
||||
"duration": 1,
|
||||
"Trait": "Tomato",
|
||||
"Quality": 5,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Vegetable",
|
||||
"quality": 5,
|
||||
"duration": 1,
|
||||
"Trait": "Vegetable",
|
||||
"Quality": 5,
|
||||
"Duration": 1,
|
||||
}
|
||||
]
|
||||
},
|
||||
3: {
|
||||
"item_name": "Energy Drink",
|
||||
"amount": 1,
|
||||
"item_value": 100000,
|
||||
"traits": [
|
||||
"ItemName": "Energy Drink",
|
||||
"Amount": 1,
|
||||
"ItemValue": 100000,
|
||||
"Traits": [
|
||||
{
|
||||
"trait": "Consumable",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Consumable",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Heal",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Heal",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Drink",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Drink",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
]
|
||||
},
|
||||
5: {
|
||||
"item_name": "Small Star Piece",
|
||||
"amount": 1,
|
||||
"item_value": 10000,
|
||||
"traits": [
|
||||
"ItemName": "Small Star Piece",
|
||||
"Amount": 1,
|
||||
"ItemValue": 10000,
|
||||
"Traits": [
|
||||
{
|
||||
"trait": "Currency",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Currency",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Money",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Money",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Star",
|
||||
"quality": 1,
|
||||
"duration": 1
|
||||
"Trait": "Star",
|
||||
"Quality": 1,
|
||||
"Duration": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
6: {
|
||||
"item_name": "Medium Star Piece",
|
||||
"amount": 1,
|
||||
"item_value": 30000,
|
||||
"traits": [
|
||||
"ItemName": "Medium Star Piece",
|
||||
"Amount": 1,
|
||||
"ItemValue": 30000,
|
||||
"Traits": [
|
||||
{
|
||||
"trait": "Currency",
|
||||
"quality": 3,
|
||||
"duration": 1,
|
||||
"Trait": "Currency",
|
||||
"Quality": 3,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Money",
|
||||
"quality": 3,
|
||||
"duration": 1,
|
||||
"Trait": "Money",
|
||||
"Quality": 3,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Star",
|
||||
"quality": 3,
|
||||
"duration": 1
|
||||
"Trait": "Star",
|
||||
"Quality": 3,
|
||||
"Duration": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
7: {
|
||||
"item_name": "Large Star Piece",
|
||||
"amount": 1,
|
||||
"item_value": 50000,
|
||||
"traits": [
|
||||
"ItemName": "Large Star Piece",
|
||||
"Amount": 1,
|
||||
"ItemValue": 50000,
|
||||
"Traits": [
|
||||
{
|
||||
"trait": "Currency",
|
||||
"quality": 5,
|
||||
"duration": 1,
|
||||
"Trait": "Currency",
|
||||
"Quality": 5,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Money",
|
||||
"quality": 5,
|
||||
"duration": 1,
|
||||
"Trait": "Money",
|
||||
"Quality": 5,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Star",
|
||||
"quality": 5,
|
||||
"duration": 1
|
||||
"Trait": "Star",
|
||||
"Quality": 5,
|
||||
"Duration": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -196,90 +195,90 @@ kdl3_gifts = {
|
||||
|
||||
kdl3_trap_gifts = {
|
||||
0: {
|
||||
"item_name": "Gooey Bag",
|
||||
"amount": 1,
|
||||
"item_value": 10000,
|
||||
"traits": [
|
||||
"ItemName": "Gooey Bag",
|
||||
"Amount": 1,
|
||||
"ItemValue": 10000,
|
||||
"Traits": [
|
||||
{
|
||||
"trait": "Trap",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Trap",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Goo",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Goo",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Gel",
|
||||
"quality": 1,
|
||||
"duration": 1
|
||||
"Trait": "Gel",
|
||||
"Quality": 1,
|
||||
"Duration": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
1: {
|
||||
"item_name": "Slowness",
|
||||
"amount": 1,
|
||||
"item_value": 10000,
|
||||
"traits": [
|
||||
"ItemName": "Slowness",
|
||||
"Amount": 1,
|
||||
"ItemValue": 10000,
|
||||
"Traits": [
|
||||
{
|
||||
"trait": "Trap",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Trap",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Slow",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Slow",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Slowness",
|
||||
"quality": 1,
|
||||
"duration": 1
|
||||
"Trait": "Slowness",
|
||||
"Quality": 1,
|
||||
"Duration": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
2: {
|
||||
"item_name": "Eject Ability",
|
||||
"amount": 1,
|
||||
"item_value": 10000,
|
||||
"traits": [
|
||||
"ItemName": "Eject Ability",
|
||||
"Amount": 1,
|
||||
"ItemValue": 10000,
|
||||
"Traits": [
|
||||
{
|
||||
"trait": "Trap",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Trap",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Eject",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Eject",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Removal",
|
||||
"quality": 1,
|
||||
"duration": 1
|
||||
"Trait": "Removal",
|
||||
"Quality": 1,
|
||||
"Duration": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
3: {
|
||||
"item_name": "Bad Meal",
|
||||
"amount": 1,
|
||||
"item_value": 10000,
|
||||
"traits": [
|
||||
"ItemName": "Bad Meal",
|
||||
"Amount": 1,
|
||||
"ItemValue": 10000,
|
||||
"Traits": [
|
||||
{
|
||||
"trait": "Trap",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Trap",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Damage",
|
||||
"quality": 1,
|
||||
"duration": 1,
|
||||
"Trait": "Damage",
|
||||
"Quality": 1,
|
||||
"Duration": 1,
|
||||
},
|
||||
{
|
||||
"trait": "Food",
|
||||
"quality": 1,
|
||||
"duration": 1
|
||||
"Trait": "Food",
|
||||
"Quality": 1,
|
||||
"Duration": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -289,7 +289,7 @@ class KirbyFlavorPreset(Choice):
|
||||
option_lime = 12
|
||||
option_lavender = 13
|
||||
option_miku = 14
|
||||
option_custom = -1
|
||||
option_custom = 15
|
||||
default = 0
|
||||
|
||||
@classmethod
|
||||
@@ -297,7 +297,7 @@ class KirbyFlavorPreset(Choice):
|
||||
text = text.lower()
|
||||
if text == "random":
|
||||
choice_list = list(cls.name_lookup)
|
||||
choice_list.remove(-1)
|
||||
choice_list.remove(14)
|
||||
return cls(random.choice(choice_list))
|
||||
return super().from_text(text)
|
||||
|
||||
@@ -347,7 +347,7 @@ class GooeyFlavorPreset(Choice):
|
||||
option_orange = 11
|
||||
option_lime = 12
|
||||
option_lavender = 13
|
||||
option_custom = -1
|
||||
option_custom = 14
|
||||
default = 0
|
||||
|
||||
@classmethod
|
||||
@@ -355,7 +355,7 @@ class GooeyFlavorPreset(Choice):
|
||||
text = text.lower()
|
||||
if text == "random":
|
||||
choice_list = list(cls.name_lookup)
|
||||
choice_list.remove(-1)
|
||||
choice_list.remove(14)
|
||||
return cls(random.choice(choice_list))
|
||||
return super().from_text(text)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import hashlib
|
||||
import os
|
||||
import struct
|
||||
|
||||
import settings
|
||||
from worlds.Files import APProcedurePatch, APTokenMixin, APTokenTypes, APPatchExtension
|
||||
from .aesthetics import get_palette_bytes, kirby_target_palettes, get_kirby_palette, gooey_target_palettes, \
|
||||
get_gooey_palette
|
||||
@@ -474,7 +475,8 @@ def patch_rom(world: "KDL3World", patch: KDL3ProcedurePatch) -> None:
|
||||
patch.write_token(APTokenTypes.WRITE, 0x3D016, world.options.ow_boss_requirement.value.to_bytes(2, "little"))
|
||||
patch.write_token(APTokenTypes.WRITE, 0x3D018, world.options.consumables.value.to_bytes(2, "little"))
|
||||
patch.write_token(APTokenTypes.WRITE, 0x3D01A, world.options.starsanity.value.to_bytes(2, "little"))
|
||||
patch.write_token(APTokenTypes.WRITE, 0x3D01C, world.options.gifting.value.to_bytes(2, "little"))
|
||||
patch.write_token(APTokenTypes.WRITE, 0x3D01C, world.options.gifting.value.to_bytes(2, "little")
|
||||
if world.multiworld.players > 1 else bytes([0, 0]))
|
||||
patch.write_token(APTokenTypes.WRITE, 0x3D01E, world.options.strict_bosses.value.to_bytes(2, "little"))
|
||||
# don't write gifting for solo game, since there's no one to send anything to
|
||||
|
||||
@@ -592,9 +594,9 @@ def get_base_rom_bytes() -> bytes:
|
||||
|
||||
|
||||
def get_base_rom_path(file_name: str = "") -> str:
|
||||
from . import KDL3World
|
||||
options: settings.Settings = settings.get_settings()
|
||||
if not file_name:
|
||||
file_name = KDL3World.settings.rom_file
|
||||
file_name = options["kdl3_options"]["rom_file"]
|
||||
if not os.path.exists(file_name):
|
||||
file_name = Utils.user_path(file_name)
|
||||
return file_name
|
||||
|
||||
@@ -34,7 +34,7 @@ class KH2Context(CommonContext):
|
||||
self.growthlevel = None
|
||||
self.kh2connected = False
|
||||
self.kh2_finished_game = False
|
||||
self.serverconnected = False
|
||||
self.serverconneced = False
|
||||
self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()}
|
||||
self.location_name_to_data = {name: data for name, data, in all_locations.items()}
|
||||
self.kh2_data_package = {}
|
||||
@@ -47,8 +47,6 @@ class KH2Context(CommonContext):
|
||||
self.location_name_to_worlddata = {name: data for name, data, in all_world_locations.items()}
|
||||
|
||||
self.sending = []
|
||||
self.slot_name = None
|
||||
self.disconnect_from_server = False
|
||||
# list used to keep track of locations+items player has. Used for disoneccting
|
||||
self.kh2_seed_save_cache = {
|
||||
"itemIndex": -1,
|
||||
@@ -187,20 +185,11 @@ class KH2Context(CommonContext):
|
||||
if password_requested and not self.password:
|
||||
await super(KH2Context, self).server_auth(password_requested)
|
||||
await self.get_username()
|
||||
# if slot name != first time login or previous name
|
||||
# and seed name is none or saved seed name
|
||||
if not self.slot_name and not self.kh2seedname:
|
||||
await self.send_connect()
|
||||
elif self.slot_name == self.auth and self.kh2seedname:
|
||||
await self.send_connect()
|
||||
else:
|
||||
logger.info(f"You are trying to connect with data still cached in the client. Close client or connect to the correct slot: {self.slot_name}")
|
||||
self.serverconnected = False
|
||||
self.disconnect_from_server = True
|
||||
await self.send_connect()
|
||||
|
||||
async def connection_closed(self):
|
||||
self.kh2connected = False
|
||||
self.serverconnected = False
|
||||
self.serverconneced = False
|
||||
if self.kh2seedname is not None and self.auth is not None:
|
||||
with open(self.kh2_seed_save_path_join, 'w') as f:
|
||||
f.write(json.dumps(self.kh2_seed_save, indent=4))
|
||||
@@ -208,8 +197,7 @@ class KH2Context(CommonContext):
|
||||
|
||||
async def disconnect(self, allow_autoreconnect: bool = False):
|
||||
self.kh2connected = False
|
||||
self.serverconnected = False
|
||||
self.locations_checked = []
|
||||
self.serverconneced = False
|
||||
if self.kh2seedname not in {None} and self.auth not in {None}:
|
||||
with open(self.kh2_seed_save_path_join, 'w') as f:
|
||||
f.write(json.dumps(self.kh2_seed_save, indent=4))
|
||||
@@ -251,15 +239,7 @@ class KH2Context(CommonContext):
|
||||
|
||||
def on_package(self, cmd: str, args: dict):
|
||||
if cmd == "RoomInfo":
|
||||
if not self.kh2seedname:
|
||||
self.kh2seedname = args['seed_name']
|
||||
elif self.kh2seedname != args['seed_name']:
|
||||
self.disconnect_from_server = True
|
||||
self.serverconnected = False
|
||||
self.kh2connected = False
|
||||
logger.info("Connection to the wrong seed, connect to the correct seed or close the client.")
|
||||
return
|
||||
|
||||
self.kh2seedname = args['seed_name']
|
||||
self.kh2_seed_save_path = f"kh2save2{self.kh2seedname}{self.auth}.json"
|
||||
self.kh2_seed_save_path_join = os.path.join(self.game_communication_path, self.kh2_seed_save_path)
|
||||
|
||||
@@ -358,7 +338,7 @@ class KH2Context(CommonContext):
|
||||
},
|
||||
},
|
||||
}
|
||||
if start_index > self.kh2_seed_save_cache["itemIndex"] and self.serverconnected:
|
||||
if start_index > self.kh2_seed_save_cache["itemIndex"] and self.serverconneced:
|
||||
self.kh2_seed_save_cache["itemIndex"] = start_index
|
||||
for item in args['items']:
|
||||
asyncio.create_task(self.give_item(item.item, item.location))
|
||||
@@ -390,14 +370,12 @@ class KH2Context(CommonContext):
|
||||
if not self.kh2:
|
||||
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
|
||||
self.get_addresses()
|
||||
#
|
||||
|
||||
except Exception as e:
|
||||
if self.kh2connected:
|
||||
self.kh2connected = False
|
||||
logger.info("Game is not open.")
|
||||
|
||||
self.serverconnected = True
|
||||
self.slot_name = self.auth
|
||||
self.serverconneced = True
|
||||
|
||||
def data_package_kh2_cache(self, loc_to_id, item_to_id):
|
||||
self.kh2_loc_name_to_id = loc_to_id
|
||||
@@ -515,38 +493,23 @@ class KH2Context(CommonContext):
|
||||
|
||||
async def give_item(self, item, location):
|
||||
try:
|
||||
# sleep so we can get the datapackage and not miss any items that were sent to us while we didnt have our item id dicts
|
||||
# todo: ripout all the itemtype stuff and just have one dictionary. the only thing that needs to be tracked from the server/local is abilites
|
||||
#sleep so we can get the datapackage and not miss any items that were sent to us while we didnt have our item id dicts
|
||||
while not self.lookup_id_to_item:
|
||||
await asyncio.sleep(0.5)
|
||||
itemname = self.lookup_id_to_item[item]
|
||||
itemdata = self.item_name_to_data[itemname]
|
||||
# itemcode = self.kh2_item_name_to_id[itemname]
|
||||
if itemdata.ability:
|
||||
if location in self.all_weapon_location_id:
|
||||
return
|
||||
# growth have reserved ability slots because of how the goa handles them
|
||||
if itemname in {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}:
|
||||
self.kh2_seed_save_cache["AmountInvo"]["Growth"][itemname] += 1
|
||||
return
|
||||
|
||||
if itemname not in self.kh2_seed_save_cache["AmountInvo"]["Ability"]:
|
||||
self.kh2_seed_save_cache["AmountInvo"]["Ability"][itemname] = []
|
||||
# appending the slot that the ability should be in
|
||||
# abilities have a limit amount of slots.
|
||||
# we start from the back going down to not mess with stuff.
|
||||
# Front of Invo
|
||||
# Sora: Save+24F0+0x54 : 0x2546
|
||||
# Donald: Save+2604+0x54 : 0x2658
|
||||
# Goofy: Save+2718+0x54 : 0x276C
|
||||
# Back of Invo. Sora has 6 ability slots that are reserved
|
||||
# Sora: Save+24F0+0x54+0x92 : 0x25D8
|
||||
# Donald: Save+2604+0x54+0x9C : 0x26F4
|
||||
# Goofy: Save+2718+0x54+0x9C : 0x2808
|
||||
# seed has 2 scans in sora's abilities
|
||||
# recieved second scan
|
||||
# if len(seed_save(Scan:[ability slot 52]) < (2)amount of that ability they should have from slot data
|
||||
# ability_slot = back of inventory that isnt taken
|
||||
# add ability_slot to seed_save(Scan[]) so now its Scan:[ability slot 52,50]
|
||||
# decrease back of inventory since its ability_slot is already taken
|
||||
# appending the slot that the ability should be in
|
||||
if len(self.kh2_seed_save_cache["AmountInvo"]["Ability"][itemname]) < \
|
||||
self.AbilityQuantityDict[itemname]:
|
||||
if itemname in self.sora_ability_set:
|
||||
@@ -565,21 +528,18 @@ class KH2Context(CommonContext):
|
||||
if ability_slot in self.front_ability_slots:
|
||||
self.front_ability_slots.remove(ability_slot)
|
||||
|
||||
# if itemdata in {bitmask} all the forms,summons and a few other things are bitmasks
|
||||
elif itemdata.memaddr in {0x36C4, 0x36C5, 0x36C6, 0x36C0, 0x36CA}:
|
||||
# if memaddr is in a bitmask location in memory
|
||||
if itemname not in self.kh2_seed_save_cache["AmountInvo"]["Bitmask"]:
|
||||
self.kh2_seed_save_cache["AmountInvo"]["Bitmask"].append(itemname)
|
||||
|
||||
# if itemdata in {magic}
|
||||
elif itemdata.memaddr in {0x3594, 0x3595, 0x3596, 0x3597, 0x35CF, 0x35D0}:
|
||||
# if memaddr is in magic addresses
|
||||
self.kh2_seed_save_cache["AmountInvo"]["Magic"][itemname] += 1
|
||||
|
||||
# equipment is a list instead of dict because you can only have 1 currently
|
||||
elif itemname in self.all_equipment:
|
||||
self.kh2_seed_save_cache["AmountInvo"]["Equipment"].append(itemname)
|
||||
|
||||
# weapons are done differently since you can only have one and has to check it differently
|
||||
elif itemname in self.all_weapons:
|
||||
if itemname in self.keyblade_set:
|
||||
self.kh2_seed_save_cache["AmountInvo"]["Weapon"]["Sora"].append(itemname)
|
||||
@@ -588,11 +548,9 @@ class KH2Context(CommonContext):
|
||||
else:
|
||||
self.kh2_seed_save_cache["AmountInvo"]["Weapon"]["Goofy"].append(itemname)
|
||||
|
||||
# TODO: this can just be removed and put into the else below it
|
||||
elif itemname in self.stat_increase_set:
|
||||
self.kh2_seed_save_cache["AmountInvo"]["StatIncrease"][itemname] += 1
|
||||
else:
|
||||
# "normal" items. They have a unique byte reserved for how many they have
|
||||
if itemname in self.kh2_seed_save_cache["AmountInvo"]["Amount"]:
|
||||
self.kh2_seed_save_cache["AmountInvo"]["Amount"][itemname] += 1
|
||||
else:
|
||||
@@ -972,7 +930,7 @@ def finishedGame(ctx: KH2Context):
|
||||
async def kh2_watcher(ctx: KH2Context):
|
||||
while not ctx.exit_event.is_set():
|
||||
try:
|
||||
if ctx.kh2connected and ctx.serverconnected:
|
||||
if ctx.kh2connected and ctx.serverconneced:
|
||||
ctx.sending = []
|
||||
await asyncio.create_task(ctx.checkWorldLocations())
|
||||
await asyncio.create_task(ctx.checkLevels())
|
||||
@@ -986,19 +944,13 @@ async def kh2_watcher(ctx: KH2Context):
|
||||
if ctx.sending:
|
||||
message = [{"cmd": 'LocationChecks', "locations": ctx.sending}]
|
||||
await ctx.send_msgs(message)
|
||||
elif not ctx.kh2connected and ctx.serverconnected:
|
||||
logger.info("Game Connection lost. trying to reconnect.")
|
||||
elif not ctx.kh2connected and ctx.serverconneced:
|
||||
logger.info("Game Connection lost. waiting 15 seconds until trying to reconnect.")
|
||||
ctx.kh2 = None
|
||||
while not ctx.kh2connected and ctx.serverconnected:
|
||||
try:
|
||||
ctx.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
|
||||
ctx.get_addresses()
|
||||
logger.info("Game Connection Established.")
|
||||
except Exception as e:
|
||||
await asyncio.sleep(5)
|
||||
if ctx.disconnect_from_server:
|
||||
ctx.disconnect_from_server = False
|
||||
await ctx.disconnect()
|
||||
while not ctx.kh2connected and ctx.serverconneced:
|
||||
await asyncio.sleep(15)
|
||||
ctx.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
|
||||
ctx.get_addresses()
|
||||
except Exception as e:
|
||||
if ctx.kh2connected:
|
||||
ctx.kh2connected = False
|
||||
|
||||
@@ -13,7 +13,6 @@ from worlds.Files import APPlayerContainer
|
||||
|
||||
class KH2Container(APPlayerContainer):
|
||||
game: str = 'Kingdom Hearts 2'
|
||||
patch_file_ending = ".zip"
|
||||
|
||||
def __init__(self, patch_data: dict, base_path: str, output_directory: str,
|
||||
player=None, player_name: str = "", server: str = ""):
|
||||
|
||||
@@ -277,7 +277,9 @@ class KH2World(World):
|
||||
if self.options.FillerItemsLocal:
|
||||
for item in filler_items:
|
||||
self.options.local_items.value.add(item)
|
||||
|
||||
# By imitating remote this doesn't have to be plandoded filler anymore
|
||||
# for location in {LocationName.JunkMedal, LocationName.JunkMedal}:
|
||||
# self.plando_locations[location] = random_stt_item
|
||||
if not self.options.SummonLevelLocationToggle:
|
||||
self.total_locations -= 6
|
||||
|
||||
@@ -398,8 +400,6 @@ class KH2World(World):
|
||||
# plando goofy get bonuses
|
||||
goofy_get_bonus_location_pool = [self.multiworld.get_location(location, self.player) for location in
|
||||
Goofy_Checks.keys() if Goofy_Checks[location].yml != "Keyblade"]
|
||||
if len(goofy_get_bonus_location_pool) > len(self.goofy_get_bonus_abilities):
|
||||
raise Exception(f"Too little abilities to fill goofy get bonus locations for player {self.player_name}.")
|
||||
for location in goofy_get_bonus_location_pool:
|
||||
self.random.choice(self.goofy_get_bonus_abilities)
|
||||
random_ability = self.random.choice(self.goofy_get_bonus_abilities)
|
||||
@@ -416,12 +416,11 @@ class KH2World(World):
|
||||
random_ability = self.random.choice(self.donald_weapon_abilities)
|
||||
location.place_locked_item(random_ability)
|
||||
self.donald_weapon_abilities.remove(random_ability)
|
||||
# if option is turned off
|
||||
|
||||
if not self.options.DonaldGoofyStatsanity:
|
||||
# plando goofy get bonuses
|
||||
donald_get_bonus_location_pool = [self.multiworld.get_location(location, self.player) for location in
|
||||
Donald_Checks.keys() if Donald_Checks[location].yml != "Keyblade"]
|
||||
if len(donald_get_bonus_location_pool) > len(self.donald_get_bonus_abilities):
|
||||
raise Exception(f"Too little abilities to fill donald get bonus locations for player {self.player_name}.")
|
||||
for location in donald_get_bonus_location_pool:
|
||||
random_ability = self.random.choice(self.donald_get_bonus_abilities)
|
||||
location.place_locked_item(random_ability)
|
||||
|
||||
@@ -220,6 +220,7 @@ To this day I still don't know if we inconvenienced the Mad Batter or not.
|
||||
Oh, hi #####
|
||||
People forgot I was playable in Hyrule Warriors
|
||||
Join our Discord. Or else.
|
||||
Also try Minecraft!
|
||||
I see you're finally awake...
|
||||
OwO
|
||||
This is Todd Howard, and today I'm pleased to announce... The Elder Scrolls V: Skyrim for the Nintendo Game Boy Color!
|
||||
@@ -280,6 +281,7 @@ Try Mario & Luigi Superstar Saga!
|
||||
Try MegaMan Battle Network 3!
|
||||
Try Meritous!
|
||||
Try The Messenger!
|
||||
Try Minecraft!
|
||||
Try Muse Dash!
|
||||
Try Noita!
|
||||
Try Ocarina of Time!
|
||||
|
||||
@@ -335,9 +335,7 @@ class LinksAwakeningWorld(World):
|
||||
start_item = next((item for item in start_items if opens_new_regions(item)), None)
|
||||
|
||||
if start_item:
|
||||
# Make sure we're removing the same copy of the item that we're placing
|
||||
# (.remove checks __eq__, which could be a different copy, so we find the first index and use .pop)
|
||||
start_item = itempool.pop(itempool.index(start_item))
|
||||
itempool.remove(start_item)
|
||||
start_loc.place_locked_item(start_item)
|
||||
else:
|
||||
logging.getLogger("Link's Awakening Logger").warning(f"No {self.options.tarins_gift.current_option_name} available for Tarin's Gift.")
|
||||
|
||||
@@ -4956,16 +4956,10 @@
|
||||
Outside The Initiated:
|
||||
room: Art Gallery
|
||||
door: Exit
|
||||
The Bearer (East):
|
||||
static_painting: True
|
||||
The Bearer (North):
|
||||
static_painting: True
|
||||
The Bearer (South):
|
||||
static_painting: True
|
||||
The Bearer (West):
|
||||
- static_painting: True
|
||||
- room: The Bearer (West)
|
||||
door: Side Area Shortcut
|
||||
The Bearer (East): True
|
||||
The Bearer (North): True
|
||||
The Bearer (South): True
|
||||
The Bearer (West): True
|
||||
Roof: True
|
||||
panels:
|
||||
Achievement:
|
||||
@@ -5059,8 +5053,7 @@
|
||||
- MIDDLE
|
||||
The Bearer (East):
|
||||
entrances:
|
||||
Cross Tower (East):
|
||||
static_painting: True
|
||||
Cross Tower (East): True
|
||||
Bearer Side Area:
|
||||
door: Side Area Access
|
||||
Roof: True
|
||||
@@ -5091,8 +5084,7 @@
|
||||
panel: SPACE
|
||||
The Bearer (North):
|
||||
entrances:
|
||||
Cross Tower (North):
|
||||
static_painting: True
|
||||
Cross Tower (East): True
|
||||
Roof: True
|
||||
panels:
|
||||
SILENT (1):
|
||||
@@ -5136,8 +5128,7 @@
|
||||
panel: POTS
|
||||
The Bearer (South):
|
||||
entrances:
|
||||
Cross Tower (South):
|
||||
static_painting: True
|
||||
Cross Tower (North): True
|
||||
Bearer Side Area:
|
||||
door: Side Area Shortcut
|
||||
Roof: True
|
||||
@@ -5171,10 +5162,7 @@
|
||||
panel: SILENT (1)
|
||||
The Bearer (West):
|
||||
entrances:
|
||||
Cross Tower (West):
|
||||
static_painting: True
|
||||
The Bearer:
|
||||
door: Side Area Shortcut
|
||||
Cross Tower (West): True
|
||||
Bearer Side Area:
|
||||
door: Side Area Shortcut
|
||||
Roof: True
|
||||
@@ -5247,7 +5235,6 @@
|
||||
The Bearer:
|
||||
room: The Bearer
|
||||
door: East Entrance
|
||||
static_painting: True
|
||||
Roof: True
|
||||
panels:
|
||||
WINTER:
|
||||
@@ -5263,7 +5250,6 @@
|
||||
The Bearer (East):
|
||||
room: The Bearer (East)
|
||||
door: North Entrance
|
||||
static_painting: True
|
||||
Roof: True
|
||||
panels:
|
||||
NORTH:
|
||||
@@ -5284,7 +5270,6 @@
|
||||
The Bearer (North):
|
||||
room: The Bearer (North)
|
||||
door: South Entrance
|
||||
static_painting: True
|
||||
panels:
|
||||
FIRE:
|
||||
id: Cross Room/Panel_fire_fire
|
||||
@@ -5299,7 +5284,6 @@
|
||||
Bearer Side Area:
|
||||
room: Bearer Side Area
|
||||
door: West Entrance
|
||||
static_painting: True
|
||||
Roof: True
|
||||
panels:
|
||||
DIAMONDS:
|
||||
@@ -7124,8 +7108,6 @@
|
||||
entrances:
|
||||
Orange Tower Third Floor:
|
||||
warp: True
|
||||
Art Gallery (First Floor):
|
||||
warp: True
|
||||
Art Gallery (Second Floor):
|
||||
warp: True
|
||||
Art Gallery (Third Floor):
|
||||
@@ -7143,6 +7125,22 @@
|
||||
required_door:
|
||||
room: Number Hunt
|
||||
door: Eights
|
||||
EON:
|
||||
id: Painting Room/Panel_eon_one
|
||||
colors: yellow
|
||||
tag: midyellow
|
||||
TRUSTWORTHY:
|
||||
id: Painting Room/Panel_to_two
|
||||
colors: red
|
||||
tag: midred
|
||||
FREE:
|
||||
id: Painting Room/Panel_free_three
|
||||
colors: purple
|
||||
tag: midpurp
|
||||
OUR:
|
||||
id: Painting Room/Panel_our_four
|
||||
colors: blue
|
||||
tag: midblue
|
||||
ORDER:
|
||||
id: Painting Room/Panel_order_onepathmanyturns
|
||||
tag: forbid
|
||||
@@ -7161,8 +7159,15 @@
|
||||
- scenery_painting_2c
|
||||
skip_location: True
|
||||
panels:
|
||||
- room: Art Gallery (First Floor)
|
||||
panel: EON
|
||||
- EON
|
||||
First Floor Puzzles:
|
||||
skip_item: True
|
||||
location_name: Art Gallery - First Floor Puzzles
|
||||
panels:
|
||||
- EON
|
||||
- TRUSTWORTHY
|
||||
- FREE
|
||||
- OUR
|
||||
Third Floor:
|
||||
painting_id:
|
||||
- scenery_painting_3b
|
||||
@@ -7222,42 +7227,11 @@
|
||||
- Third Floor
|
||||
- Fourth Floor
|
||||
- Fifth Floor
|
||||
Art Gallery (First Floor):
|
||||
entrances:
|
||||
Art Gallery:
|
||||
static_painting: True
|
||||
panels:
|
||||
EON:
|
||||
id: Painting Room/Panel_eon_one
|
||||
colors: yellow
|
||||
tag: midyellow
|
||||
TRUSTWORTHY:
|
||||
id: Painting Room/Panel_to_two
|
||||
colors: red
|
||||
tag: midred
|
||||
FREE:
|
||||
id: Painting Room/Panel_free_three
|
||||
colors: purple
|
||||
tag: midpurp
|
||||
OUR:
|
||||
id: Painting Room/Panel_our_four
|
||||
colors: blue
|
||||
tag: midblue
|
||||
doors:
|
||||
Puzzles:
|
||||
skip_item: True
|
||||
location_name: Art Gallery - First Floor Puzzles
|
||||
panels:
|
||||
- EON
|
||||
- TRUSTWORTHY
|
||||
- FREE
|
||||
- OUR
|
||||
Art Gallery (Second Floor):
|
||||
entrances:
|
||||
Art Gallery:
|
||||
room: Art Gallery
|
||||
door: Second Floor
|
||||
static_painting: True
|
||||
panels:
|
||||
HOUSE:
|
||||
id: Painting Room/Panel_house_neighborhood
|
||||
@@ -7289,7 +7263,6 @@
|
||||
Art Gallery:
|
||||
room: Art Gallery
|
||||
door: Third Floor
|
||||
static_painting: True
|
||||
panels:
|
||||
AN:
|
||||
id: Painting Room/Panel_an_many
|
||||
@@ -7321,7 +7294,6 @@
|
||||
Art Gallery:
|
||||
room: Art Gallery
|
||||
door: Fourth Floor
|
||||
static_painting: True
|
||||
panels:
|
||||
URNS:
|
||||
id: Painting Room/Panel_urns_turns
|
||||
|
||||
Binary file not shown.
@@ -727,12 +727,11 @@ panels:
|
||||
WANDER: 444975
|
||||
Art Gallery:
|
||||
EIGHT: 444976
|
||||
ORDER: 444981
|
||||
Art Gallery (First Floor):
|
||||
EON: 444977
|
||||
TRUSTWORTHY: 444978
|
||||
FREE: 444979
|
||||
OUR: 444980
|
||||
ORDER: 444981
|
||||
Art Gallery (Second Floor):
|
||||
HOUSE: 444982
|
||||
PATH: 444983
|
||||
@@ -1383,6 +1382,8 @@ doors:
|
||||
Art Gallery:
|
||||
Second Floor:
|
||||
item: 444558
|
||||
First Floor Puzzles:
|
||||
location: 445256
|
||||
Third Floor:
|
||||
item: 444559
|
||||
Fourth Floor:
|
||||
@@ -1392,9 +1393,6 @@ doors:
|
||||
Exit:
|
||||
item: 444562
|
||||
location: 444981
|
||||
Art Gallery (First Floor):
|
||||
Puzzles:
|
||||
location: 445256
|
||||
Art Gallery (Second Floor):
|
||||
Puzzles:
|
||||
location: 445257
|
||||
|
||||
@@ -23,7 +23,6 @@ class EntranceType(Flag):
|
||||
SUNWARP = auto()
|
||||
WARP = auto()
|
||||
CROSSROADS_ROOF_ACCESS = auto()
|
||||
STATIC_PAINTING = auto()
|
||||
|
||||
|
||||
class RoomEntrance(NamedTuple):
|
||||
|
||||
@@ -30,7 +30,7 @@ def is_acceptable_pilgrimage_entrance(entrance_type: EntranceType, world: "Lingo
|
||||
allowed_entrance_types = EntranceType.NORMAL
|
||||
|
||||
if world.options.pilgrimage_allows_paintings:
|
||||
allowed_entrance_types |= EntranceType.PAINTING | EntranceType.STATIC_PAINTING
|
||||
allowed_entrance_types |= EntranceType.PAINTING
|
||||
|
||||
if world.options.pilgrimage_allows_roof_access:
|
||||
allowed_entrance_types |= EntranceType.CROSSROADS_ROOF_ACCESS
|
||||
@@ -105,8 +105,7 @@ def create_regions(world: "LingoWorld") -> None:
|
||||
regions[pilgrimage_region_name] = Region(pilgrimage_region_name, world.player, world.multiworld)
|
||||
|
||||
# Connect all created regions now that they exist.
|
||||
allowed_entrance_types = EntranceType.NORMAL | EntranceType.WARP | EntranceType.CROSSROADS_ROOF_ACCESS | \
|
||||
EntranceType.STATIC_PAINTING
|
||||
allowed_entrance_types = EntranceType.NORMAL | EntranceType.WARP | EntranceType.CROSSROADS_ROOF_ACCESS
|
||||
|
||||
if not painting_shuffle:
|
||||
# Don't use the vanilla painting connections if we are shuffling paintings.
|
||||
@@ -157,11 +156,11 @@ def create_regions(world: "LingoWorld") -> None:
|
||||
regions[from_room].connect(regions[to_room], f"Pilgrimage Part {i+1}")
|
||||
else:
|
||||
connect_entrance(regions, regions["Starting Room"], regions["Pilgrim Antechamber"], "Sun Painting",
|
||||
RoomAndDoor("Pilgrim Antechamber", "Sun Painting"), EntranceType.STATIC_PAINTING, False, world)
|
||||
RoomAndDoor("Pilgrim Antechamber", "Sun Painting"), EntranceType.PAINTING, False, world)
|
||||
|
||||
if early_color_hallways:
|
||||
connect_entrance(regions, regions["Starting Room"], regions["Color Hallways"], "Early Color Hallways",
|
||||
None, EntranceType.STATIC_PAINTING, False, world)
|
||||
None, EntranceType.PAINTING, False, world)
|
||||
|
||||
if painting_shuffle:
|
||||
for warp_enter, warp_exit in world.player_logic.painting_mapping.items():
|
||||
|
||||
@@ -138,8 +138,6 @@ def process_single_entrance(source_room: str, room_name: str, door_obj) -> RoomE
|
||||
entrance_type = EntranceType.WARP
|
||||
elif source_room == "Crossroads" and room_name == "Roof":
|
||||
entrance_type = EntranceType.CROSSROADS_ROOF_ACCESS
|
||||
elif "static_painting" in door_obj and door_obj["static_painting"]:
|
||||
entrance_type = EntranceType.STATIC_PAINTING
|
||||
|
||||
if "painting" in door_obj and door_obj["painting"]:
|
||||
PAINTING_EXIT_ROOMS.add(room_name)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# the file are consistent. It also checks that the panel and door IDs mentioned
|
||||
# all exist in the map file.
|
||||
#
|
||||
# Usage: validate_config.rb [config file] [ids path] [map file]
|
||||
# Usage: validate_config.rb [config file] [map file]
|
||||
|
||||
require 'set'
|
||||
require 'yaml'
|
||||
|
||||
@@ -281,7 +281,7 @@ class MessengerWorld(World):
|
||||
disconnect_entrances(self)
|
||||
add_closed_portal_reqs(self)
|
||||
# i need portal shuffle to happen after rules exist so i can validate it
|
||||
attempts = 20
|
||||
attempts = 5
|
||||
if self.options.shuffle_portals:
|
||||
self.portal_mapping = []
|
||||
self.spoiler_portal_mapping = {}
|
||||
|
||||
26
worlds/minecraft/Constants.py
Normal file
26
worlds/minecraft/Constants.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
import json
|
||||
import pkgutil
|
||||
|
||||
def load_data_file(*args) -> dict:
|
||||
fname = "/".join(["data", *args])
|
||||
return json.loads(pkgutil.get_data(__name__, fname).decode())
|
||||
|
||||
# For historical reasons, these values are different.
|
||||
# They remain different to ensure datapackage consistency.
|
||||
# Do not separate other games' location and item IDs like this.
|
||||
item_id_offset: int = 45000
|
||||
location_id_offset: int = 42000
|
||||
|
||||
item_info = load_data_file("items.json")
|
||||
item_name_to_id = {name: item_id_offset + index \
|
||||
for index, name in enumerate(item_info["all_items"])}
|
||||
item_name_to_id["Bee Trap"] = item_id_offset + 100 # historical reasons
|
||||
|
||||
location_info = load_data_file("locations.json")
|
||||
location_name_to_id = {name: location_id_offset + index \
|
||||
for index, name in enumerate(location_info["all_locations"])}
|
||||
|
||||
exclusion_info = load_data_file("excluded_locations.json")
|
||||
|
||||
region_info = load_data_file("regions.json")
|
||||
55
worlds/minecraft/ItemPool.py
Normal file
55
worlds/minecraft/ItemPool.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from math import ceil
|
||||
from typing import List
|
||||
|
||||
from BaseClasses import Item
|
||||
|
||||
from . import Constants
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import MinecraftWorld
|
||||
|
||||
|
||||
def get_junk_item_names(rand, k: int) -> str:
|
||||
junk_weights = Constants.item_info["junk_weights"]
|
||||
junk = rand.choices(
|
||||
list(junk_weights.keys()),
|
||||
weights=list(junk_weights.values()),
|
||||
k=k)
|
||||
return junk
|
||||
|
||||
def build_item_pool(world: "MinecraftWorld") -> List[Item]:
|
||||
multiworld = world.multiworld
|
||||
player = world.player
|
||||
|
||||
itempool = []
|
||||
total_location_count = len(multiworld.get_unfilled_locations(player))
|
||||
|
||||
required_pool = Constants.item_info["required_pool"]
|
||||
|
||||
# Add required progression items
|
||||
for item_name, num in required_pool.items():
|
||||
itempool += [world.create_item(item_name) for _ in range(num)]
|
||||
|
||||
# Add structure compasses
|
||||
if world.options.structure_compasses:
|
||||
compasses = [name for name in world.item_name_to_id if "Structure Compass" in name]
|
||||
for item_name in compasses:
|
||||
itempool.append(world.create_item(item_name))
|
||||
|
||||
# Dragon egg shards
|
||||
if world.options.egg_shards_required > 0:
|
||||
num = world.options.egg_shards_available
|
||||
itempool += [world.create_item("Dragon Egg Shard") for _ in range(num)]
|
||||
|
||||
# Bee traps
|
||||
bee_trap_percentage = world.options.bee_traps * 0.01
|
||||
if bee_trap_percentage > 0:
|
||||
bee_trap_qty = ceil(bee_trap_percentage * (total_location_count - len(itempool)))
|
||||
itempool += [world.create_item("Bee Trap") for _ in range(bee_trap_qty)]
|
||||
|
||||
# Fill remaining itempool with randomly generated junk
|
||||
junk = get_junk_item_names(world.random, total_location_count - len(itempool))
|
||||
itempool += [world.create_item(name) for name in junk]
|
||||
|
||||
return itempool
|
||||
143
worlds/minecraft/Options.py
Normal file
143
worlds/minecraft/Options.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from Options import Choice, Toggle, DefaultOnToggle, Range, OptionList, DeathLink, PlandoConnections, \
|
||||
PerGameCommonOptions
|
||||
from .Constants import region_info
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class AdvancementGoal(Range):
|
||||
"""Number of advancements required to spawn bosses."""
|
||||
display_name = "Advancement Goal"
|
||||
range_start = 0
|
||||
range_end = 114
|
||||
default = 40
|
||||
|
||||
|
||||
class EggShardsRequired(Range):
|
||||
"""Number of dragon egg shards to collect to spawn bosses."""
|
||||
display_name = "Egg Shards Required"
|
||||
range_start = 0
|
||||
range_end = 50
|
||||
default = 0
|
||||
|
||||
|
||||
class EggShardsAvailable(Range):
|
||||
"""Number of dragon egg shards available to collect."""
|
||||
display_name = "Egg Shards Available"
|
||||
range_start = 0
|
||||
range_end = 50
|
||||
default = 0
|
||||
|
||||
|
||||
class BossGoal(Choice):
|
||||
"""Bosses which must be defeated to finish the game."""
|
||||
display_name = "Required Bosses"
|
||||
option_none = 0
|
||||
option_ender_dragon = 1
|
||||
option_wither = 2
|
||||
option_both = 3
|
||||
default = 1
|
||||
|
||||
@property
|
||||
def dragon(self):
|
||||
return self.value % 2 == 1
|
||||
|
||||
@property
|
||||
def wither(self):
|
||||
return self.value > 1
|
||||
|
||||
|
||||
class ShuffleStructures(DefaultOnToggle):
|
||||
"""Enables shuffling of villages, outposts, fortresses, bastions, and end cities."""
|
||||
display_name = "Shuffle Structures"
|
||||
|
||||
|
||||
class StructureCompasses(DefaultOnToggle):
|
||||
"""Adds structure compasses to the item pool, which point to the nearest indicated structure."""
|
||||
display_name = "Structure Compasses"
|
||||
|
||||
|
||||
class BeeTraps(Range):
|
||||
"""Replaces a percentage of junk items with bee traps, which spawn multiple angered bees around every player when
|
||||
received."""
|
||||
display_name = "Bee Trap Percentage"
|
||||
range_start = 0
|
||||
range_end = 100
|
||||
default = 0
|
||||
|
||||
|
||||
class CombatDifficulty(Choice):
|
||||
"""Modifies the level of items logically required for exploring dangerous areas and fighting bosses."""
|
||||
display_name = "Combat Difficulty"
|
||||
option_easy = 0
|
||||
option_normal = 1
|
||||
option_hard = 2
|
||||
default = 1
|
||||
|
||||
|
||||
class HardAdvancements(Toggle):
|
||||
"""Enables certain RNG-reliant or tedious advancements."""
|
||||
display_name = "Include Hard Advancements"
|
||||
|
||||
|
||||
class UnreasonableAdvancements(Toggle):
|
||||
"""Enables the extremely difficult advancements "How Did We Get Here?" and "Adventuring Time.\""""
|
||||
display_name = "Include Unreasonable Advancements"
|
||||
|
||||
|
||||
class PostgameAdvancements(Toggle):
|
||||
"""Enables advancements that require spawning and defeating the required bosses."""
|
||||
display_name = "Include Postgame Advancements"
|
||||
|
||||
|
||||
class SendDefeatedMobs(Toggle):
|
||||
"""Send killed mobs to other Minecraft worlds which have this option enabled."""
|
||||
display_name = "Send Defeated Mobs"
|
||||
|
||||
|
||||
class StartingItems(OptionList):
|
||||
"""Start with these items. Each entry should be of this format: {item: "item_name", amount: #}
|
||||
`item` can include components, and should be in an identical format to a `/give` command with
|
||||
`"` escaped for json reasons.
|
||||
|
||||
`amount` is optional and will default to 1 if omitted.
|
||||
|
||||
example:
|
||||
```
|
||||
starting_items: [
|
||||
{ "item": "minecraft:stick[minecraft:custom_name=\"{'text':'pointy stick'}\"]" },
|
||||
{ "item": "minecraft:arrow[minecraft:rarity=epic]", amount: 64 }
|
||||
]
|
||||
```
|
||||
"""
|
||||
display_name = "Starting Items"
|
||||
|
||||
|
||||
class MCPlandoConnections(PlandoConnections):
|
||||
entrances = set(connection[0] for connection in region_info["default_connections"])
|
||||
exits = set(connection[1] for connection in region_info["default_connections"])
|
||||
|
||||
@classmethod
|
||||
def can_connect(cls, entrance, exit):
|
||||
if exit in region_info["illegal_connections"] and entrance in region_info["illegal_connections"][exit]:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class MinecraftOptions(PerGameCommonOptions):
|
||||
plando_connections: MCPlandoConnections
|
||||
advancement_goal: AdvancementGoal
|
||||
egg_shards_required: EggShardsRequired
|
||||
egg_shards_available: EggShardsAvailable
|
||||
required_bosses: BossGoal
|
||||
shuffle_structures: ShuffleStructures
|
||||
structure_compasses: StructureCompasses
|
||||
|
||||
combat_difficulty: CombatDifficulty
|
||||
include_hard_advancements: HardAdvancements
|
||||
include_unreasonable_advancements: UnreasonableAdvancements
|
||||
include_postgame_advancements: PostgameAdvancements
|
||||
bee_traps: BeeTraps
|
||||
send_defeated_mobs: SendDefeatedMobs
|
||||
death_link: DeathLink
|
||||
starting_items: StartingItems
|
||||
508
worlds/minecraft/Rules.py
Normal file
508
worlds/minecraft/Rules.py
Normal file
@@ -0,0 +1,508 @@
|
||||
from BaseClasses import CollectionState
|
||||
from worlds.generic.Rules import exclusion_rules
|
||||
|
||||
from . import Constants
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import MinecraftWorld
|
||||
|
||||
|
||||
# Helper functions
|
||||
# moved from logicmixin
|
||||
|
||||
def has_iron_ingots(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
return state.has('Progressive Tools', player) and state.has('Progressive Resource Crafting', player)
|
||||
|
||||
|
||||
def has_copper_ingots(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
return state.has('Progressive Tools', player) and state.has('Progressive Resource Crafting', player)
|
||||
|
||||
|
||||
def has_gold_ingots(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
return (state.has('Progressive Resource Crafting', player)
|
||||
and (
|
||||
state.has('Progressive Tools', player, 2)
|
||||
or state.can_reach_region('The Nether', player)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def has_diamond_pickaxe(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
return state.has('Progressive Tools', player, 3) and has_iron_ingots(world, state, player)
|
||||
|
||||
|
||||
def craft_crossbow(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
return state.has('Archery', player) and has_iron_ingots(world, state, player)
|
||||
|
||||
|
||||
def has_bottle(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
return state.has('Bottles', player) and state.has('Progressive Resource Crafting', player)
|
||||
|
||||
|
||||
def has_spyglass(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
return (has_copper_ingots(world, state, player)
|
||||
and state.has('Spyglass', player)
|
||||
and can_adventure(world, state, player)
|
||||
)
|
||||
|
||||
|
||||
def can_enchant(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
return state.has('Enchanting', player) and has_diamond_pickaxe(world, state, player) # mine obsidian and lapis
|
||||
|
||||
|
||||
def can_use_anvil(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
return (state.has('Enchanting', player)
|
||||
and state.has('Progressive Resource Crafting', player,2)
|
||||
and has_iron_ingots(world, state, player)
|
||||
)
|
||||
|
||||
|
||||
def fortress_loot(world: "MinecraftWorld", state: CollectionState, player: int) -> bool: # saddles, blaze rods, wither skulls
|
||||
return state.can_reach_region('Nether Fortress', player) and basic_combat(world, state, player)
|
||||
|
||||
|
||||
def can_brew_potions(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
return state.has('Blaze Rods', player) and state.has('Brewing', player) and has_bottle(world, state, player)
|
||||
|
||||
|
||||
def can_piglin_trade(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
return (has_gold_ingots(world, state, player)
|
||||
and (
|
||||
state.can_reach_region('The Nether', player)
|
||||
or state.can_reach_region('Bastion Remnant', player)
|
||||
))
|
||||
|
||||
|
||||
def overworld_villager(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
village_region = state.multiworld.get_region('Village', player).entrances[0].parent_region.name
|
||||
if village_region == 'The Nether': # 2 options: cure zombie villager or build portal in village
|
||||
return (state.can_reach_location('Zombie Doctor', player)
|
||||
or (
|
||||
has_diamond_pickaxe(world, state, player)
|
||||
and state.can_reach_region('Village', player)
|
||||
))
|
||||
elif village_region == 'The End':
|
||||
return state.can_reach_location('Zombie Doctor', player)
|
||||
return state.can_reach_region('Village', player)
|
||||
|
||||
|
||||
def enter_stronghold(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
return state.has('Blaze Rods', player) and state.has('Brewing', player) and state.has('3 Ender Pearls', player)
|
||||
|
||||
|
||||
# Difficulty-dependent functions
|
||||
def combat_difficulty(world: "MinecraftWorld", state: CollectionState, player: int) -> str:
|
||||
return world.options.combat_difficulty.current_key
|
||||
|
||||
|
||||
def can_adventure(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
death_link_check = not world.options.death_link or state.has('Bed', player)
|
||||
if combat_difficulty(world, state, player) == 'easy':
|
||||
return state.has('Progressive Weapons', player, 2) and has_iron_ingots(world, state, player) and death_link_check
|
||||
elif combat_difficulty(world, state, player) == 'hard':
|
||||
return True
|
||||
return (state.has('Progressive Weapons', player) and death_link_check and
|
||||
(state.has('Progressive Resource Crafting', player) or state.has('Campfire', player)))
|
||||
|
||||
|
||||
def basic_combat(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
if combat_difficulty(world, state, player) == 'easy':
|
||||
return (state.has('Progressive Weapons', player, 2)
|
||||
and state.has('Progressive Armor', player)
|
||||
and state.has('Shield', player)
|
||||
and has_iron_ingots(world, state, player)
|
||||
)
|
||||
elif combat_difficulty(world, state, player) == 'hard':
|
||||
return True
|
||||
return (state.has('Progressive Weapons', player)
|
||||
and (
|
||||
state.has('Progressive Armor', player)
|
||||
or state.has('Shield', player)
|
||||
)
|
||||
and has_iron_ingots(world, state, player)
|
||||
)
|
||||
|
||||
|
||||
def complete_raid(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
reach_regions = (state.can_reach_region('Village', player)
|
||||
and state.can_reach_region('Pillager Outpost', player))
|
||||
if combat_difficulty(world, state, player) == 'easy':
|
||||
return (reach_regions
|
||||
and state.has('Progressive Weapons', player, 3)
|
||||
and state.has('Progressive Armor', player, 2)
|
||||
and state.has('Shield', player)
|
||||
and state.has('Archery', player)
|
||||
and state.has('Progressive Tools', player, 2)
|
||||
and has_iron_ingots(world, state, player)
|
||||
)
|
||||
elif combat_difficulty(world, state, player) == 'hard': # might be too hard?
|
||||
return (reach_regions
|
||||
and state.has('Progressive Weapons', player, 2)
|
||||
and has_iron_ingots(world, state, player)
|
||||
and (
|
||||
state.has('Progressive Armor', player)
|
||||
or state.has('Shield', player)
|
||||
)
|
||||
)
|
||||
return (reach_regions
|
||||
and state.has('Progressive Weapons', player, 2)
|
||||
and has_iron_ingots(world, state, player)
|
||||
and state.has('Progressive Armor', player)
|
||||
and state.has('Shield', player)
|
||||
)
|
||||
|
||||
|
||||
def can_kill_wither(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
normal_kill = (state.has("Progressive Weapons", player, 3)
|
||||
and state.has("Progressive Armor", player, 2)
|
||||
and can_brew_potions(world, state, player)
|
||||
and can_enchant(world, state, player)
|
||||
)
|
||||
if combat_difficulty(world, state, player) == 'easy':
|
||||
return (fortress_loot(world, state, player)
|
||||
and normal_kill
|
||||
and state.has('Archery', player)
|
||||
)
|
||||
elif combat_difficulty(world, state, player) == 'hard': # cheese kill using bedrock ceilings
|
||||
return (fortress_loot(world, state, player)
|
||||
and (
|
||||
normal_kill
|
||||
or state.can_reach_region('The Nether', player)
|
||||
or state.can_reach_region('The End', player)
|
||||
)
|
||||
)
|
||||
|
||||
return fortress_loot(world, state, player) and normal_kill
|
||||
|
||||
|
||||
def can_respawn_ender_dragon(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
return (state.can_reach_region('The Nether', player)
|
||||
and state.can_reach_region('The End', player)
|
||||
and state.has('Progressive Resource Crafting', player) # smelt sand into glass
|
||||
)
|
||||
|
||||
|
||||
def can_kill_ender_dragon(world: "MinecraftWorld", state: CollectionState, player: int) -> bool:
|
||||
if combat_difficulty(world, state, player) == 'easy':
|
||||
return (state.has("Progressive Weapons", player, 3)
|
||||
and state.has("Progressive Armor", player, 2)
|
||||
and state.has('Archery', player)
|
||||
and can_brew_potions(world, state, player)
|
||||
and can_enchant(world, state, player)
|
||||
)
|
||||
if combat_difficulty(world, state, player) == 'hard':
|
||||
return (
|
||||
(
|
||||
state.has('Progressive Weapons', player, 2)
|
||||
and state.has('Progressive Armor', player)
|
||||
) or (
|
||||
state.has('Progressive Weapons', player, 1)
|
||||
and state.has('Bed', player) # who needs armor when you can respawn right outside the chamber
|
||||
)
|
||||
)
|
||||
return (state.has('Progressive Weapons', player, 2)
|
||||
and state.has('Progressive Armor', player)
|
||||
and state.has('Archery', player)
|
||||
)
|
||||
|
||||
|
||||
def has_structure_compass(world: "MinecraftWorld", state: CollectionState, entrance_name: str, player: int) -> bool:
|
||||
if not world.options.structure_compasses:
|
||||
return True
|
||||
return state.has(f"Structure Compass ({state.multiworld.get_entrance(entrance_name, player).connected_region.name})", player)
|
||||
|
||||
|
||||
def get_rules_lookup(world, player: int):
|
||||
rules_lookup = {
|
||||
"entrances": {
|
||||
"Nether Portal": lambda state: state.has('Flint and Steel', player)
|
||||
and (
|
||||
state.has('Bucket', player)
|
||||
or state.has('Progressive Tools', player, 3)
|
||||
)
|
||||
and has_iron_ingots(world, state, player),
|
||||
"End Portal": lambda state: enter_stronghold(world, state, player)
|
||||
and state.has('3 Ender Pearls', player, 4),
|
||||
"Overworld Structure 1": lambda state: can_adventure(world, state, player)
|
||||
and has_structure_compass(world, state, "Overworld Structure 1", player),
|
||||
"Overworld Structure 2": lambda state: can_adventure(world, state, player)
|
||||
and has_structure_compass(world, state, "Overworld Structure 2", player),
|
||||
"Nether Structure 1": lambda state: can_adventure(world, state, player)
|
||||
and has_structure_compass(world, state, "Nether Structure 1", player),
|
||||
"Nether Structure 2": lambda state: can_adventure(world, state, player)
|
||||
and has_structure_compass(world, state, "Nether Structure 2", player),
|
||||
"The End Structure": lambda state: can_adventure(world, state, player)
|
||||
and has_structure_compass(world, state, "The End Structure", player),
|
||||
},
|
||||
"locations": {
|
||||
"Ender Dragon": lambda state: can_respawn_ender_dragon(world, state, player)
|
||||
and can_kill_ender_dragon(world, state, player),
|
||||
"Wither": lambda state: can_kill_wither(world, state, player),
|
||||
"Blaze Rods": lambda state: fortress_loot(world, state, player),
|
||||
"Who is Cutting Onions?": lambda state: can_piglin_trade(world, state, player),
|
||||
"Oh Shiny": lambda state: can_piglin_trade(world, state, player),
|
||||
"Suit Up": lambda state: state.has("Progressive Armor", player)
|
||||
and has_iron_ingots(world, state, player),
|
||||
"Very Very Frightening": lambda state: state.has("Channeling Book", player)
|
||||
and can_use_anvil(world, state, player)
|
||||
and can_enchant(world, state, player)
|
||||
and overworld_villager(world, state, player),
|
||||
"Hot Stuff": lambda state: state.has("Bucket", player)
|
||||
and has_iron_ingots(world, state, player),
|
||||
"Free the End": lambda state: can_respawn_ender_dragon(world, state, player)
|
||||
and can_kill_ender_dragon(world, state, player),
|
||||
"A Furious Cocktail": lambda state: (can_brew_potions(world, state, player)
|
||||
and state.has("Fishing Rod", player) # Water Breathing
|
||||
and state.can_reach_region("The Nether", player) # Regeneration, Fire Resistance, gold nuggets
|
||||
and state.can_reach_region("Village", player) # Night Vision, Invisibility
|
||||
and state.can_reach_location("Bring Home the Beacon", player)),
|
||||
# Resistance
|
||||
"Bring Home the Beacon": lambda state: can_kill_wither(world, state, player)
|
||||
and has_diamond_pickaxe(world, state, player)
|
||||
and state.has("Progressive Resource Crafting", player, 2),
|
||||
"Not Today, Thank You": lambda state: state.has("Shield", player)
|
||||
and has_iron_ingots(world, state, player),
|
||||
"Isn't It Iron Pick": lambda state: state.has("Progressive Tools", player, 2)
|
||||
and has_iron_ingots(world, state, player),
|
||||
"Local Brewery": lambda state: can_brew_potions(world, state, player),
|
||||
"The Next Generation": lambda state: can_respawn_ender_dragon(world, state, player)
|
||||
and can_kill_ender_dragon(world, state, player),
|
||||
"Fishy Business": lambda state: state.has("Fishing Rod", player),
|
||||
"This Boat Has Legs": lambda state: (
|
||||
fortress_loot(world, state, player)
|
||||
or complete_raid(world, state, player)
|
||||
)
|
||||
and state.has("Saddle", player)
|
||||
and state.has("Fishing Rod", player),
|
||||
"Sniper Duel": lambda state: state.has("Archery", player),
|
||||
"Great View From Up Here": lambda state: basic_combat(world, state, player),
|
||||
"How Did We Get Here?": lambda state: (can_brew_potions(world, state, player)
|
||||
and has_gold_ingots(world, state, player) # Absorption
|
||||
and state.can_reach_region('End City', player) # Levitation
|
||||
and state.can_reach_region('The Nether', player) # potion ingredients
|
||||
and state.has("Fishing Rod", player) # Pufferfish, Nautilus Shells; spectral arrows
|
||||
and state.has("Archery", player)
|
||||
and state.can_reach_location("Bring Home the Beacon", player) # Haste
|
||||
and state.can_reach_location("Hero of the Village", player)), # Bad Omen, Hero of the Village
|
||||
"Bullseye": lambda state: state.has("Archery", player)
|
||||
and state.has("Progressive Tools", player, 2)
|
||||
and has_iron_ingots(world, state, player),
|
||||
"Spooky Scary Skeleton": lambda state: basic_combat(world, state, player),
|
||||
"Two by Two": lambda state: has_iron_ingots(world, state, player)
|
||||
and state.has("Bucket", player)
|
||||
and can_adventure(world, state, player),
|
||||
"Two Birds, One Arrow": lambda state: craft_crossbow(world, state, player)
|
||||
and can_enchant(world, state, player),
|
||||
"Who's the Pillager Now?": lambda state: craft_crossbow(world, state, player),
|
||||
"Getting an Upgrade": lambda state: state.has("Progressive Tools", player),
|
||||
"Tactical Fishing": lambda state: state.has("Bucket", player)
|
||||
and has_iron_ingots(world, state, player),
|
||||
"Zombie Doctor": lambda state: can_brew_potions(world, state, player)
|
||||
and has_gold_ingots(world, state, player),
|
||||
"Ice Bucket Challenge": lambda state: has_diamond_pickaxe(world, state, player),
|
||||
"Into Fire": lambda state: basic_combat(world, state, player),
|
||||
"War Pigs": lambda state: basic_combat(world, state, player),
|
||||
"Take Aim": lambda state: state.has("Archery", player),
|
||||
"Total Beelocation": lambda state: state.has("Silk Touch Book", player)
|
||||
and can_use_anvil(world, state, player)
|
||||
and can_enchant(world, state, player),
|
||||
"Arbalistic": lambda state: (craft_crossbow(world, state, player)
|
||||
and state.has("Piercing IV Book", player)
|
||||
and can_use_anvil(world, state, player)
|
||||
and can_enchant(world, state, player)
|
||||
),
|
||||
"The End... Again...": lambda state: can_respawn_ender_dragon(world, state, player)
|
||||
and can_kill_ender_dragon(world, state, player),
|
||||
"Acquire Hardware": lambda state: has_iron_ingots(world, state, player),
|
||||
"Not Quite \"Nine\" Lives": lambda state: can_piglin_trade(world, state, player)
|
||||
and state.has("Progressive Resource Crafting", player, 2),
|
||||
"Cover Me With Diamonds": lambda state: state.has("Progressive Armor", player, 2)
|
||||
and state.has("Progressive Tools", player, 2)
|
||||
and has_iron_ingots(world, state, player),
|
||||
"Sky's the Limit": lambda state: basic_combat(world, state, player),
|
||||
"Hired Help": lambda state: state.has("Progressive Resource Crafting", player, 2)
|
||||
and has_iron_ingots(world, state, player),
|
||||
"Sweet Dreams": lambda state: state.has("Bed", player)
|
||||
or state.can_reach_region('Village', player),
|
||||
"You Need a Mint": lambda state: can_respawn_ender_dragon(world, state, player)
|
||||
and has_bottle(world, state, player),
|
||||
"Monsters Hunted": lambda state: (can_respawn_ender_dragon(world, state, player)
|
||||
and can_kill_ender_dragon(world, state, player)
|
||||
and can_kill_wither(world, state, player)
|
||||
and state.has("Fishing Rod", player)),
|
||||
"Enchanter": lambda state: can_enchant(world, state, player),
|
||||
"Voluntary Exile": lambda state: basic_combat(world, state, player),
|
||||
"Eye Spy": lambda state: enter_stronghold(world, state, player),
|
||||
"Serious Dedication": lambda state: (can_brew_potions(world, state, player)
|
||||
and state.has("Bed", player)
|
||||
and has_diamond_pickaxe(world, state, player)
|
||||
and has_gold_ingots(world, state, player)),
|
||||
"Postmortal": lambda state: complete_raid(world, state, player),
|
||||
"Adventuring Time": lambda state: can_adventure(world, state, player),
|
||||
"Hero of the Village": lambda state: complete_raid(world, state, player),
|
||||
"Hidden in the Depths": lambda state: can_brew_potions(world, state, player)
|
||||
and state.has("Bed", player)
|
||||
and has_diamond_pickaxe(world, state, player),
|
||||
"Beaconator": lambda state: (can_kill_wither(world, state, player)
|
||||
and has_diamond_pickaxe(world, state, player)
|
||||
and state.has("Progressive Resource Crafting", player, 2)),
|
||||
"Withering Heights": lambda state: can_kill_wither(world, state, player),
|
||||
"A Balanced Diet": lambda state: (has_bottle(world, state, player)
|
||||
and has_gold_ingots(world, state, player)
|
||||
and state.has("Progressive Resource Crafting", player, 2)
|
||||
and state.can_reach_region('The End', player)),
|
||||
# notch apple, chorus fruit
|
||||
"Subspace Bubble": lambda state: has_diamond_pickaxe(world, state, player),
|
||||
"Country Lode, Take Me Home": lambda state: state.can_reach_location("Hidden in the Depths", player)
|
||||
and has_gold_ingots(world, state, player),
|
||||
"Bee Our Guest": lambda state: state.has("Campfire", player)
|
||||
and has_bottle(world, state, player),
|
||||
"Uneasy Alliance": lambda state: has_diamond_pickaxe(world, state, player)
|
||||
and state.has('Fishing Rod', player),
|
||||
"Diamonds!": lambda state: state.has("Progressive Tools", player, 2)
|
||||
and has_iron_ingots(world, state, player),
|
||||
"A Throwaway Joke": lambda state: can_adventure(world, state, player),
|
||||
"Sticky Situation": lambda state: state.has("Campfire", player)
|
||||
and has_bottle(world, state, player),
|
||||
"Ol' Betsy": lambda state: craft_crossbow(world, state, player),
|
||||
"Cover Me in Debris": lambda state: state.has("Progressive Armor", player, 2)
|
||||
and state.has("8 Netherite Scrap", player, 2)
|
||||
and state.has("Progressive Resource Crafting", player)
|
||||
and has_diamond_pickaxe(world, state, player)
|
||||
and has_iron_ingots(world, state, player)
|
||||
and can_brew_potions(world, state, player)
|
||||
and state.has("Bed", player),
|
||||
"Hot Topic": lambda state: state.has("Progressive Resource Crafting", player),
|
||||
"The Lie": lambda state: has_iron_ingots(world, state, player)
|
||||
and state.has("Bucket", player),
|
||||
"On a Rail": lambda state: has_iron_ingots(world, state, player)
|
||||
and state.has('Progressive Tools', player, 2),
|
||||
"When Pigs Fly": lambda state: (
|
||||
fortress_loot(world, state, player)
|
||||
or complete_raid(world, state, player)
|
||||
)
|
||||
and state.has("Saddle", player)
|
||||
and state.has("Fishing Rod", player)
|
||||
and can_adventure(world, state, player),
|
||||
"Overkill": lambda state: can_brew_potions(world, state, player)
|
||||
and (
|
||||
state.has("Progressive Weapons", player)
|
||||
or state.can_reach_region('The Nether', player)
|
||||
),
|
||||
"Librarian": lambda state: state.has("Enchanting", player),
|
||||
"Overpowered": lambda state: has_iron_ingots(world, state, player)
|
||||
and state.has('Progressive Tools', player, 2)
|
||||
and basic_combat(world, state, player),
|
||||
"Wax On": lambda state: has_copper_ingots(world, state, player)
|
||||
and state.has('Campfire', player)
|
||||
and state.has('Progressive Resource Crafting', player, 2),
|
||||
"Wax Off": lambda state: has_copper_ingots(world, state, player)
|
||||
and state.has('Campfire', player)
|
||||
and state.has('Progressive Resource Crafting', player, 2),
|
||||
"The Cutest Predator": lambda state: has_iron_ingots(world, state, player)
|
||||
and state.has('Bucket', player),
|
||||
"The Healing Power of Friendship": lambda state: has_iron_ingots(world, state, player)
|
||||
and state.has('Bucket', player),
|
||||
"Is It a Bird?": lambda state: has_spyglass(world, state, player)
|
||||
and can_adventure(world, state, player),
|
||||
"Is It a Balloon?": lambda state: has_spyglass(world, state, player),
|
||||
"Is It a Plane?": lambda state: has_spyglass(world, state, player)
|
||||
and can_respawn_ender_dragon(world, state, player),
|
||||
"Surge Protector": lambda state: state.has("Channeling Book", player)
|
||||
and can_use_anvil(world, state, player)
|
||||
and can_enchant(world, state, player)
|
||||
and overworld_villager(world, state, player),
|
||||
"Light as a Rabbit": lambda state: can_adventure(world, state, player)
|
||||
and has_iron_ingots(world, state, player)
|
||||
and state.has('Bucket', player),
|
||||
"Glow and Behold!": lambda state: can_adventure(world, state, player),
|
||||
"Whatever Floats Your Goat!": lambda state: can_adventure(world, state, player),
|
||||
"Caves & Cliffs": lambda state: has_iron_ingots(world, state, player)
|
||||
and state.has('Bucket', player)
|
||||
and state.has('Progressive Tools', player, 2),
|
||||
"Feels like home": lambda state: has_iron_ingots(world, state, player)
|
||||
and state.has('Bucket', player)
|
||||
and state.has('Fishing Rod', player)
|
||||
and (
|
||||
fortress_loot(world, state, player)
|
||||
or complete_raid(world, state, player)
|
||||
)
|
||||
and state.has("Saddle", player),
|
||||
"Sound of Music": lambda state: state.has("Progressive Tools", player, 2)
|
||||
and has_iron_ingots(world, state, player)
|
||||
and basic_combat(world, state, player),
|
||||
"Star Trader": lambda state: has_iron_ingots(world, state, player)
|
||||
and state.has('Bucket', player)
|
||||
and (
|
||||
state.can_reach_region("The Nether", player) # soul sand in nether
|
||||
or state.can_reach_region("Nether Fortress", player) # soul sand in fortress if not in nether for water elevator
|
||||
or can_piglin_trade(world, state, player) # piglins give soul sand
|
||||
)
|
||||
and overworld_villager(world, state, player),
|
||||
"Birthday Song": lambda state: state.can_reach_location("The Lie", player)
|
||||
and state.has("Progressive Tools", player, 2)
|
||||
and has_iron_ingots(world, state, player),
|
||||
"Bukkit Bukkit": lambda state: state.has("Bucket", player)
|
||||
and has_iron_ingots(world, state, player)
|
||||
and can_adventure(world, state, player),
|
||||
"It Spreads": lambda state: can_adventure(world, state, player)
|
||||
and has_iron_ingots(world, state, player)
|
||||
and state.has("Progressive Tools", player, 2),
|
||||
"Sneak 100": lambda state: can_adventure(world, state, player)
|
||||
and has_iron_ingots(world, state, player)
|
||||
and state.has("Progressive Tools", player, 2),
|
||||
"When the Squad Hops into Town": lambda state: can_adventure(world, state, player)
|
||||
and state.has("Lead", player),
|
||||
"With Our Powers Combined!": lambda state: can_adventure(world, state, player)
|
||||
and state.has("Lead", player),
|
||||
}
|
||||
}
|
||||
return rules_lookup
|
||||
|
||||
|
||||
def set_rules(self: "MinecraftWorld") -> None:
|
||||
multiworld = self.multiworld
|
||||
player = self.player
|
||||
|
||||
rules_lookup = get_rules_lookup(self, player)
|
||||
|
||||
# Set entrance rules
|
||||
for entrance_name, rule in rules_lookup["entrances"].items():
|
||||
multiworld.get_entrance(entrance_name, player).access_rule = rule
|
||||
|
||||
# Set location rules
|
||||
for location_name, rule in rules_lookup["locations"].items():
|
||||
multiworld.get_location(location_name, player).access_rule = rule
|
||||
|
||||
# Set rules surrounding completion
|
||||
bosses = self.options.required_bosses
|
||||
postgame_advancements = set()
|
||||
if bosses.dragon:
|
||||
postgame_advancements.update(Constants.exclusion_info["ender_dragon"])
|
||||
if bosses.wither:
|
||||
postgame_advancements.update(Constants.exclusion_info["wither"])
|
||||
|
||||
def location_count(state: CollectionState) -> int:
|
||||
return len([location for location in multiworld.get_locations(player) if
|
||||
location.address is not None and
|
||||
location.can_reach(state)])
|
||||
|
||||
def defeated_bosses(state: CollectionState) -> bool:
|
||||
return ((not bosses.dragon or state.has("Ender Dragon", player))
|
||||
and (not bosses.wither or state.has("Wither", player)))
|
||||
|
||||
egg_shards = min(self.options.egg_shards_required.value, self.options.egg_shards_available.value)
|
||||
completion_requirements = lambda state: (location_count(state) >= self.options.advancement_goal
|
||||
and state.has("Dragon Egg Shard", player, egg_shards))
|
||||
multiworld.completion_condition[player] = lambda state: completion_requirements(state) and defeated_bosses(state)
|
||||
|
||||
# Set exclusions on hard/unreasonable/postgame
|
||||
excluded_advancements = set()
|
||||
if not self.options.include_hard_advancements:
|
||||
excluded_advancements.update(Constants.exclusion_info["hard"])
|
||||
if not self.options.include_unreasonable_advancements:
|
||||
excluded_advancements.update(Constants.exclusion_info["unreasonable"])
|
||||
if not self.options.include_postgame_advancements:
|
||||
excluded_advancements.update(postgame_advancements)
|
||||
exclusion_rules(multiworld, player, excluded_advancements)
|
||||
59
worlds/minecraft/Structures.py
Normal file
59
worlds/minecraft/Structures.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from . import Constants
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from . import MinecraftWorld
|
||||
|
||||
|
||||
def shuffle_structures(self: "MinecraftWorld") -> None:
|
||||
multiworld = self.multiworld
|
||||
player = self.player
|
||||
|
||||
default_connections = Constants.region_info["default_connections"]
|
||||
illegal_connections = Constants.region_info["illegal_connections"]
|
||||
|
||||
# Get all unpaired exits and all regions without entrances (except the Menu)
|
||||
# This function is destructive on these lists.
|
||||
exits = [exit.name for r in multiworld.regions if r.player == player for exit in r.exits if exit.connected_region is None]
|
||||
structs = [r.name for r in multiworld.regions if r.player == player and r.entrances == [] and r.name != 'Menu']
|
||||
exits_spoiler = exits[:] # copy the original order for the spoiler log
|
||||
|
||||
pairs = {}
|
||||
|
||||
def set_pair(exit, struct):
|
||||
if (exit in exits) and (struct in structs) and (exit not in illegal_connections.get(struct, [])):
|
||||
pairs[exit] = struct
|
||||
exits.remove(exit)
|
||||
structs.remove(struct)
|
||||
else:
|
||||
raise Exception(f"Invalid connection: {exit} => {struct} for player {player} ({multiworld.player_name[player]})")
|
||||
|
||||
# Connect plando structures first
|
||||
if self.options.plando_connections:
|
||||
for conn in self.options.plando_connections:
|
||||
set_pair(conn.entrance, conn.exit)
|
||||
|
||||
# The algorithm tries to place the most restrictive structures first. This algorithm always works on the
|
||||
# relatively small set of restrictions here, but does not work on all possible inputs with valid configurations.
|
||||
if self.options.shuffle_structures:
|
||||
structs.sort(reverse=True, key=lambda s: len(illegal_connections.get(s, [])))
|
||||
for struct in structs[:]:
|
||||
try:
|
||||
exit = self.random.choice([e for e in exits if e not in illegal_connections.get(struct, [])])
|
||||
except IndexError:
|
||||
raise Exception(f"No valid structure placements remaining for player {player} ({self.player_name})")
|
||||
set_pair(exit, struct)
|
||||
else: # write remaining default connections
|
||||
for (exit, struct) in default_connections:
|
||||
if exit in exits:
|
||||
set_pair(exit, struct)
|
||||
|
||||
# Make sure we actually paired everything; might fail if plando
|
||||
try:
|
||||
assert len(exits) == len(structs) == 0
|
||||
except AssertionError:
|
||||
raise Exception(f"Failed to connect all Minecraft structures for player {player} ({self.player_name})")
|
||||
|
||||
for exit in exits_spoiler:
|
||||
multiworld.get_entrance(exit, player).connect(multiworld.get_region(pairs[exit], player))
|
||||
if self.options.shuffle_structures or self.options.plando_connections:
|
||||
multiworld.spoiler.set_entrance(exit, pairs[exit], 'entrance', player)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user