Merge remote-tracking branch 'Main/main' into rework_accessibility

# Conflicts:
#	BaseClasses.py
#	worlds/alttp/Rules.py
This commit is contained in:
alwaysintreble
2023-04-06 02:54:24 -05:00
519 changed files with 52417 additions and 5503 deletions

View File

@@ -2,10 +2,20 @@
name: Build
on: workflow_dispatch
on:
push:
paths:
- '.github/workflows/build.yml'
- 'setup.py'
- 'requirements.txt'
pull_request:
paths:
- '.github/workflows/build.yml'
- 'setup.py'
- 'requirements.txt'
workflow_dispatch:
env:
SNI_VERSION: v0.0.88
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
@@ -15,21 +25,18 @@ jobs:
build-win-py38: # RCs will still be built and signed by hand
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Install python
uses: actions/setup-python@v3
uses: actions/setup-python@v4
with:
python-version: '3.8'
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/${Env:SNI_VERSION}/sni-${Env:SNI_VERSION}-windows-amd64.zip -OutFile sni.zip
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
- name: Build
run: |
python -m pip install --upgrade pip setuptools
pip install -r requirements.txt
python -m pip install --upgrade pip
python setup.py build_exe --yes
$NAME="$(ls build)".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
@@ -39,24 +46,24 @@ jobs:
Rename-Item exe.$NAME Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
- name: Store 7z
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
retention-days: 7 # keep for 7 days, should be enough
build-ubuntu1804:
runs-on: ubuntu-18.04
build-ubuntu2004:
runs-on: ubuntu-20.04
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v3
uses: actions/setup-python@v4
with:
python-version: '3.9'
- name: Install build-time dependencies
@@ -69,10 +76,6 @@ jobs:
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz
rm sni-*.tar.xz
mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
@@ -81,8 +84,7 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer
pip install -r requirements.txt
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"
@@ -92,14 +94,18 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
- name: Build Again
run: |
source venv/bin/activate
python setup.py build_exe --yes
- name: Store AppImage
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
retention-days: 7
- name: Store .tar.gz
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}

View File

@@ -14,9 +14,17 @@ name: "CodeQL"
on:
push:
branches: [ main ]
paths:
- '**.py'
- '**.js'
- '.github/workflows/codeql-analysis.yml'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
paths:
- '**.py'
- '**.js'
- '.github/workflows/codeql-analysis.yml'
schedule:
- cron: '44 8 * * 1'
@@ -35,11 +43,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -50,7 +58,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -64,4 +72,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
uses: github/codeql-action/analyze@v2

View File

@@ -3,23 +3,29 @@
name: lint
on: [push, pull_request]
on:
push:
paths:
- '**.py'
pull_request:
paths:
- '**.py'
jobs:
build:
flake8:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v1
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Install dependencies
run: |
python -m pip install --upgrade pip wheel
pip install flake8 pytest pytest-subtests
pip install flake8
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |

View File

@@ -8,7 +8,6 @@ on:
- '*.*.*'
env:
SNI_VERSION: v0.0.88
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
@@ -30,20 +29,20 @@ jobs:
# build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer
build-release-ubuntu1804:
runs-on: ubuntu-18.04
build-release-ubuntu2004:
runs-on: ubuntu-20.04
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v3
uses: actions/setup-python@v4
with:
python-version: '3.9'
- name: Install build-time dependencies
@@ -56,10 +55,6 @@ jobs:
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz
rm sni-*.tar.xz
mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
@@ -68,9 +63,8 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer
pip install -r requirements.txt
python setup.py build --yes bdist_appimage --yes
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..

View File

@@ -3,7 +3,25 @@
name: unittests
on: [push, pull_request]
on:
push:
paths:
- '**'
- '!docs/**'
- '!setup.py'
- '!*.iss'
- '!.gitignore'
- '!.github/workflows/**'
- '.github/workflows/unittests.yml'
pull_request:
paths:
- '**'
- '!docs/**'
- '!setup.py'
- '!*.iss'
- '!.gitignore'
- '!.github/workflows/**'
- '.github/workflows/unittests.yml'
jobs:
build:
@@ -23,17 +41,19 @@ jobs:
os: windows-latest
- python: {version: '3.10'} # current
os: windows-latest
- python: {version: '3.10'} # current
os: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v1
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip wheel
pip install flake8 pytest pytest-subtests
python -m pip install --upgrade pip
pip install pytest pytest-subtests
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Unittests
run: |

5
.gitignore vendored
View File

@@ -8,6 +8,7 @@
*.apm3
*.apmc
*.apz5
*.aptloz
*.pyc
*.pyd
*.sfc
@@ -25,6 +26,7 @@
*multisave
*.archipelago
*.apsave
*.BIN
build
bundle/components.wxs
@@ -51,6 +53,7 @@ Output Logs/
/setup.ini
/installdelete.iss
/data/user.kv
/datapackage
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -138,6 +141,7 @@ ENV/
env.bak/
venv.bak/
.code-workspace
shell.nix
# Spyder project settings
.spyderproject
@@ -167,6 +171,7 @@ cython_debug/
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv
.python-version

516
AdventureClient.py Normal file
View File

@@ -0,0 +1,516 @@
import asyncio
import hashlib
import json
import time
import os
import bsdiff4
import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter, CancelledError
from typing import List
import Utils
from NetUtils import ClientStatus
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
from worlds.adventure import AdventureDeltaPatch
from worlds.adventure.Locations import base_location_id
from worlds.adventure.Rom import AdventureForeignItemInfo, AdventureAutoCollectLocation, BatNoTouchLocation
from worlds.adventure.Items import base_adventure_item_id, standard_item_max, item_table
from worlds.adventure.Offsets import static_item_element_size, connector_port_offset
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = \
"Connection timing out. Please restart your emulator, then restart adventure_connector.lua"
CONNECTION_REFUSED_STATUS = \
"Connection Refused. Please start your emulator and make sure adventure_connector.lua is running"
CONNECTION_RESET_STATUS = \
"Connection was reset. Please restart your emulator, then restart adventure_connector.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
SCRIPT_VERSION = 1
class AdventureCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_2600(self):
"""Check 2600 Connection State"""
if isinstance(self.ctx, AdventureContext):
logger.info(f"2600 Status: {self.ctx.atari_status}")
def _cmd_aconnect(self):
"""Discard current atari 2600 connection state"""
if isinstance(self.ctx, AdventureContext):
self.ctx.atari_sync_task.cancel()
class AdventureContext(CommonContext):
command_processor = AdventureCommandProcessor
game = 'Adventure'
lua_connector_port: int = 17242
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.freeincarnates_used: int = -1
self.freeincarnate_pending: int = 0
self.foreign_items: [AdventureForeignItemInfo] = []
self.autocollect_items: [AdventureAutoCollectLocation] = []
self.atari_streams: (StreamReader, StreamWriter) = None
self.atari_sync_task = None
self.messages = {}
self.locations_array = None
self.atari_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
self.deathlink_pending = False
self.set_deathlink = False
self.client_compatibility_mode = 0
self.items_handling = 0b111
self.checked_locations_sent: bool = False
self.port_offset = 0
self.bat_no_touch_locations: [BatNoTouchLocation] = []
self.local_item_locations = {}
self.dragon_speed_info = {}
options = Utils.get_options()
self.display_msgs = options["adventure_options"]["display_msgs"]
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(AdventureContext, self).server_auth(password_requested)
if not self.auth:
self.auth = self.player_name
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to adventure_connector to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if self.display_msgs:
self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if Utils.get_options()["adventure_options"].get("death_link", False):
self.set_deathlink = True
async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo":
self.seed_name = args['seed_name']
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "Retrieved":
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
if self.freeincarnates_used is None:
self.freeincarnates_used = 0
self.freeincarnates_used += self.freeincarnate_pending
self.send_pending_freeincarnates()
elif cmd == "SetReply":
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
self.freeincarnates_used = args["value"]
if self.freeincarnates_used is None:
self.freeincarnates_used = 0
self.freeincarnates_used += self.freeincarnate_pending
self.send_pending_freeincarnates()
def on_deathlink(self, data: dict):
self.deathlink_pending = True
super().on_deathlink(data)
def run_gui(self):
from kvui import GameManager
class AdventureManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Adventure Client"
self.ui = AdventureManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def get_freeincarnates_used(self):
if self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "SetNotify", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}])
await self.send_msgs([{"cmd": "Get", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}])
def send_pending_freeincarnates(self):
if self.freeincarnate_pending > 0:
async_start(self.send_pending_freeincarnates_impl(self.freeincarnate_pending))
self.freeincarnate_pending = 0
async def send_pending_freeincarnates_impl(self, send_val: int) -> None:
await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used",
"default": 0, "want_reply": False,
"operations": [{"operation": "add", "value": send_val}]}])
async def used_freeincarnate(self) -> None:
if self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used",
"default": 0, "want_reply": True,
"operations": [{"operation": "add", "value": 1}]}])
else:
self.freeincarnate_pending = self.freeincarnate_pending + 1
def convert_item_id(ap_item_id: int):
static_item_index = ap_item_id - base_adventure_item_id
return static_item_index * static_item_element_size
def get_payload(ctx: AdventureContext):
current_time = time.time()
items = []
dragon_speed_update = {}
diff_a_locked = ctx.diff_a_mode > 0
diff_b_locked = ctx.diff_b_mode > 0
freeincarnate_count = 0
for item in ctx.items_received:
item_id_str = str(item.item)
if base_adventure_item_id < item.item <= standard_item_max:
items.append(convert_item_id(item.item))
elif item_id_str in ctx.dragon_speed_info:
if item.item in dragon_speed_update:
last_index = len(ctx.dragon_speed_info[item_id_str]) - 1
dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][last_index]
else:
dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][0]
elif item.item == item_table["Left Difficulty Switch"].id:
diff_a_locked = False
elif item.item == item_table["Right Difficulty Switch"].id:
diff_b_locked = False
elif item.item == item_table["Freeincarnate"].id:
freeincarnate_count = freeincarnate_count + 1
freeincarnates_available = 0
if ctx.freeincarnates_used >= 0:
freeincarnates_available = freeincarnate_count - (ctx.freeincarnates_used + ctx.freeincarnate_pending)
ret = json.dumps(
{
"items": items,
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10},
"deathlink": ctx.deathlink_pending,
"dragon_speeds": dragon_speed_update,
"difficulty_a_locked": diff_a_locked,
"difficulty_b_locked": diff_b_locked,
"freeincarnates_available": freeincarnates_available,
"bat_logic": ctx.bat_logic
}
)
ctx.deathlink_pending = False
return ret
async def parse_locations(data: List, ctx: AdventureContext):
locations = data
# for loc_name, loc_data in location_table.items():
# if flags["EventFlag"][280] & 1 and not ctx.finished_game:
# await ctx.send_msgs([
# {"cmd": "StatusUpdate",
# "status": 30}
# ])
# ctx.finished_game = True
if locations == ctx.locations_array:
return
ctx.locations_array = locations
if locations is not None:
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
def send_ap_foreign_items(adventure_context):
foreign_item_json_list = []
autocollect_item_json_list = []
bat_no_touch_locations_json_list = []
for fi in adventure_context.foreign_items:
foreign_item_json_list.append(fi.get_dict())
for fi in adventure_context.autocollect_items:
autocollect_item_json_list.append(fi.get_dict())
for ntl in adventure_context.bat_no_touch_locations:
bat_no_touch_locations_json_list.append(ntl.get_dict())
payload = json.dumps(
{
"foreign_items": foreign_item_json_list,
"autocollect_items": autocollect_item_json_list,
"local_item_locations": adventure_context.local_item_locations,
"bat_no_touch_locations": bat_no_touch_locations_json_list
}
)
print("sending foreign items")
msg = payload.encode()
(reader, writer) = adventure_context.atari_streams
writer.write(msg)
writer.write(b'\n')
def send_checked_locations_if_needed(adventure_context):
if not adventure_context.checked_locations_sent and adventure_context.checked_locations is not None:
if len(adventure_context.checked_locations) == 0:
return
checked_short_ids = []
for location in adventure_context.checked_locations:
checked_short_ids.append(location - base_location_id)
print("Sending checked locations")
payload = json.dumps(
{
"checked_locations": checked_short_ids,
}
)
msg = payload.encode()
(reader, writer) = adventure_context.atari_streams
writer.write(msg)
writer.write(b'\n')
adventure_context.checked_locations_sent = True
async def atari_sync_task(ctx: AdventureContext):
logger.info("Starting Atari 2600 connector. Use /2600 for status information")
while not ctx.exit_event.is_set():
try:
error_status = None
if ctx.atari_streams:
(reader, writer) = ctx.atari_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 1+ fields
# 1. A keepalive response of the Players Name (always)
# 2. romhash field with sha256 hash of the ROM memory region
# 3. locations, messages, and deathLink
# 4. freeincarnate, to indicate a freeincarnate was used
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
msg = "You are connecting with an incompatible Lua script version. Ensure your connector " \
"Lua and AdventureClient are from the same Archipelago installation."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
if ctx.seed_name and bytes(ctx.seed_name, encoding='ASCII') != ctx.seed_name_from_data:
msg = "The server is running a different multiworld than your client is. " \
"(invalid seed_name)"
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
if 'romhash' in data_decoded:
if ctx.rom_hash.upper() != data_decoded['romhash'].upper():
msg = "The rom hash does not match the client rom hash data"
print("got " + data_decoded['romhash'])
print("expected " + str(ctx.rom_hash))
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
if ctx.auth is None:
ctx.auth = ctx.player_name
if ctx.awaiting_rom:
await ctx.server_auth(False)
if 'locations' in data_decoded and ctx.game and ctx.atari_status == CONNECTION_CONNECTED_STATUS \
and not error_status and ctx.auth:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx))
if 'deathLink' in data_decoded and data_decoded['deathLink'] > 0 and 'DeathLink' in ctx.tags:
dragon_name = "a dragon"
if data_decoded['deathLink'] == 1:
dragon_name = "Rhindle"
elif data_decoded['deathLink'] == 2:
dragon_name = "Yorgle"
elif data_decoded['deathLink'] == 3:
dragon_name = "Grundle"
print (ctx.auth + " has been eaten by " + dragon_name )
await ctx.send_death(ctx.auth + " has been eaten by " + dragon_name)
# TODO - also if player reincarnates with a dragon onscreen ' dies to avoid being eaten by '
if 'victory' in data_decoded and not ctx.finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
if 'freeincarnate' in data_decoded:
await ctx.used_freeincarnate()
if ctx.set_deathlink:
await ctx.update_death_link(True)
send_checked_locations_if_needed(ctx)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.atari_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.atari_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.atari_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.atari_streams = None
except CancelledError:
logger.debug("Connection Cancelled, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.atari_streams = None
pass
except Exception as e:
print("unknown exception " + e)
raise
if ctx.atari_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to 2600")
ctx.atari_status = CONNECTION_CONNECTED_STATUS
ctx.checked_locations_sent = False
send_ap_foreign_items(ctx)
send_checked_locations_if_needed(ctx)
else:
ctx.atari_status = f"Was tentatively connected but error occurred: {error_status}"
elif error_status:
ctx.atari_status = error_status
logger.info("Lost connection to 2600 and attempting to reconnect. Use /2600 for status updates")
else:
try:
port = ctx.lua_connector_port + ctx.port_offset
logger.debug(f"Attempting to connect to 2600 on port {port}")
print(f"Attempting to connect to 2600 on port {port}")
ctx.atari_streams = await asyncio.wait_for(
asyncio.open_connection("localhost",
port),
timeout=10)
ctx.atari_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.atari_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.atari_status = CONNECTION_REFUSED_STATUS
continue
except CancelledError:
pass
except CancelledError:
pass
print("exiting atari sync task")
async def run_game(romfile):
auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_options()["adventure_options"].get("rom_args")
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
open_args = [auto_start, romfile]
if rom_args is not None:
open_args.insert(1, rom_args)
subprocess.Popen(open_args,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def patch_and_run_game(patch_file, ctx):
base_name = os.path.splitext(patch_file)[0]
comp_path = base_name + '.a26'
try:
base_rom = AdventureDeltaPatch.get_source_data()
except Exception as msg:
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
with open(Utils.local_path("data", "adventure_basepatch.bsdiff4"), "rb") as file:
basepatch = bytes(file.read())
base_patched_rom_data = bsdiff4.patch(base_rom, basepatch)
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
if not AdventureDeltaPatch.check_version(patch_archive):
logger.error("apadvn version doesn't match this client. Make sure your generator and client are the same")
raise Exception("apadvn version doesn't match this client.")
ctx.foreign_items = AdventureDeltaPatch.read_foreign_items(patch_archive)
ctx.autocollect_items = AdventureDeltaPatch.read_autocollect_items(patch_archive)
ctx.local_item_locations = AdventureDeltaPatch.read_local_item_locations(patch_archive)
ctx.dragon_speed_info = AdventureDeltaPatch.read_dragon_speed_info(patch_archive)
ctx.seed_name_from_data, ctx.player_name = AdventureDeltaPatch.read_rom_info(patch_archive)
ctx.diff_a_mode, ctx.diff_b_mode = AdventureDeltaPatch.read_difficulty_switch_info(patch_archive)
ctx.bat_logic = AdventureDeltaPatch.read_bat_logic(patch_archive)
ctx.bat_no_touch_locations = AdventureDeltaPatch.read_bat_no_touch(patch_archive)
ctx.rom_deltas = AdventureDeltaPatch.read_rom_deltas(patch_archive)
ctx.auth = ctx.player_name
patched_rom_data = AdventureDeltaPatch.apply_rom_deltas(base_patched_rom_data, ctx.rom_deltas)
rom_hash = hashlib.sha256()
rom_hash.update(patched_rom_data)
ctx.rom_hash = rom_hash.hexdigest()
ctx.port_offset = patched_rom_data[connector_port_offset]
with open(comp_path, "wb") as patched_rom_file:
patched_rom_file.write(patched_rom_data)
async_start(run_game(comp_path))
if __name__ == '__main__':
Utils.init_logging("AdventureClient")
async def main():
parser = get_base_parser()
parser.add_argument('patch_file', default="", type=str, nargs="?",
help='Path to an ADVNTURE.BIN rom file')
parser.add_argument('port', default=17242, type=int, nargs="?",
help='port for adventure_connector connection')
args = parser.parse_args()
ctx = AdventureContext(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.atari_sync_task = asyncio.create_task(atari_sync_task(ctx), name="Adventure Sync")
if args.patch_file:
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
if ext == "apadvn":
logger.info("apadvn file supplied, beginning patching process...")
async_start(patch_and_run_game(args.patch_file, ctx))
else:
logger.warning(f"Unknown patch file extension {ext}")
if args.port is int:
ctx.lua_connector_port = args.port
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.atari_sync_task:
await ctx.atari_sync_task
print("finished atari_sync_task (main)")
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -2,13 +2,12 @@ from __future__ import annotations
import copy
import functools
import json
import logging
import random
import secrets
import typing # this can go away when Python 3.8 support is dropped
from argparse import Namespace
from collections import OrderedDict, Counter, deque
from collections import OrderedDict, Counter, deque, ChainMap
from enum import IntEnum, IntFlag
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
@@ -72,6 +71,11 @@ class MultiWorld():
completion_condition: Dict[int, Callable[[CollectionState], bool]]
indirect_connections: Dict[Region, Set[Entrance]]
exclude_locations: Dict[int, Options.ExcludeLocations]
priority_locations: Dict[int, Options.PriorityLocations]
start_inventory: Dict[int, Options.StartInventory]
start_hints: Dict[int, Options.StartHints]
start_location_hints: Dict[int, Options.StartLocationHints]
item_links: Dict[int, Options.ItemLinks]
game: Dict[int, str]
@@ -332,7 +336,7 @@ class MultiWorld():
return self.player_name[player]
def get_file_safe_player_name(self, player: int) -> str:
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
return Utils.get_file_safe_name(self.get_player_name(player))
def get_out_file_name_base(self, player: int) -> str:
""" the base name (without file extension) for each player's output file for a seed """
@@ -761,169 +765,9 @@ class CollectionState():
found += self.prog_items[item_name, player]
return found
def can_buy_unlimited(self, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for
shop in self.multiworld.shops)
def can_buy(self, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for
shop in self.multiworld.shops)
def item_count(self, item: str, player: int) -> int:
return self.prog_items[item, player]
def has_triforce_pieces(self, count: int, player: int) -> bool:
return self.item_count('Triforce Piece', player) + self.item_count('Power Star', player) >= count
def has_crystals(self, count: int, player: int) -> bool:
found: int = 0
for crystalnumber in range(1, 8):
found += self.prog_items[f"Crystal {crystalnumber}", player]
if found >= count:
return True
return False
def can_lift_rocks(self, player: int):
return self.has('Power Glove', player) or self.has('Titans Mitts', player)
def bottle_count(self, player: int) -> int:
return min(self.multiworld.difficulty_requirements[player].progressive_bottle_limit,
self.count_group("Bottles", player))
def has_hearts(self, player: int, count: int) -> int:
# Warning: This only considers items that are marked as advancement items
return self.heart_count(player) >= count
def heart_count(self, player: int) -> int:
# Warning: This only considers items that are marked as advancement items
diff = self.multiworld.difficulty_requirements[player]
return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \
+ self.item_count('Sanctuary Heart Container', player) \
+ min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
+ 3 # starting hearts
def can_lift_heavy_rocks(self, player: int) -> bool:
return self.has('Titans Mitts', player)
def can_extend_magic(self, player: int, smallmagic: int = 16,
fullrefill: bool = False): # This reflects the total magic Link has, not the total extra he has.
basemagic = 8
if self.has('Magic Upgrade (1/4)', player):
basemagic = 32
elif self.has('Magic Upgrade (1/2)', player):
basemagic = 16
if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player):
if self.multiworld.item_functionality[player] == 'hard' and not fullrefill:
basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player))
elif self.multiworld.item_functionality[player] == 'expert' and not fullrefill:
basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player))
else:
basemagic = basemagic + basemagic * self.bottle_count(player)
return basemagic >= smallmagic
def can_kill_most_things(self, player: int, enemies: int = 5) -> bool:
return (self.has_melee_weapon(player)
or self.has('Cane of Somaria', player)
or (self.has('Cane of Byrna', player) and (enemies < 6 or self.can_extend_magic(player)))
or self.can_shoot_arrows(player)
or self.has('Fire Rod', player)
or (self.has('Bombs (10)', player) and enemies < 6))
def can_shoot_arrows(self, player: int) -> bool:
if self.multiworld.retro_bow[player]:
return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player)
return self.has('Bow', player) or self.has('Silver Bow', player)
def can_get_good_bee(self, player: int) -> bool:
cave = self.multiworld.get_region('Good Bee Cave', player)
return (
self.has_group("Bottles", player) and
self.has('Bug Catching Net', player) and
(self.has('Pegasus Boots', player) or (self.has_sword(player) and self.has('Quake', player))) and
cave.can_reach(self) and
self.is_not_bunny(cave, player)
)
def can_retrieve_tablet(self, player: int) -> bool:
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
(self.multiworld.swordless[player] and
self.has("Hammer", player)))
def has_sword(self, player: int) -> bool:
return self.has('Fighter Sword', player) \
or self.has('Master Sword', player) \
or self.has('Tempered Sword', player) \
or self.has('Golden Sword', player)
def has_beam_sword(self, player: int) -> bool:
return self.has('Master Sword', player) or self.has('Tempered Sword', player) or self.has('Golden Sword',
player)
def has_melee_weapon(self, player: int) -> bool:
return self.has_sword(player) or self.has('Hammer', player)
def has_fire_source(self, player: int) -> bool:
return self.has('Fire Rod', player) or self.has('Lamp', player)
def can_melt_things(self, player: int) -> bool:
return self.has('Fire Rod', player) or \
(self.has('Bombos', player) and
(self.multiworld.swordless[player] or
self.has_sword(player)))
def can_avoid_lasers(self, player: int) -> bool:
return self.has('Mirror Shield', player) or self.has('Cane of Byrna', player) or self.has('Cape', player)
def is_not_bunny(self, region: Region, player: int) -> bool:
if self.has('Moon Pearl', player):
return True
return region.is_light_world if self.multiworld.mode[player] != 'inverted' else region.is_dark_world
def can_reach_light_world(self, player: int) -> bool:
if True in [i.is_light_world for i in self.reachable_regions[player]]:
return True
return False
def can_reach_dark_world(self, player: int) -> bool:
if True in [i.is_dark_world for i in self.reachable_regions[player]]:
return True
return False
def has_misery_mire_medallion(self, player: int) -> bool:
return self.has(self.multiworld.required_medallions[player][0], player)
def has_turtle_rock_medallion(self, player: int) -> bool:
return self.has(self.multiworld.required_medallions[player][1], player)
def can_boots_clip_lw(self, player: int) -> bool:
if self.multiworld.mode[player] == 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player)
def can_boots_clip_dw(self, player: int) -> bool:
if self.multiworld.mode[player] != 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player)
def can_get_glitched_speed_lw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.multiworld.mode[player] == 'inverted':
rules.append(self.has('Moon Pearl', player))
return all(rules)
def can_superbunny_mirror_with_sword(self, player: int) -> bool:
return self.has('Magic Mirror', player) and self.has_sword(player)
def can_get_glitched_speed_dw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.multiworld.mode[player] != 'inverted':
rules.append(self.has('Moon Pearl', player))
return all(rules)
def can_bomb_clip(self, region: Region, player: int) -> bool:
return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player)
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
if location:
self.locations_checked.add(location)
@@ -959,12 +803,6 @@ class Region:
exits: List[Entrance]
locations: List[Location]
dungeon: Optional[Dungeon] = None
shop: Optional = None
# LttP specific. TODO: move to a LttPRegion
# will be set after making connections.
is_light_world: bool = False
is_dark_world: bool = False
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
self.name = name
@@ -1129,7 +967,7 @@ class Location:
self.parent_region = parent
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return (self.always_allow(state, item)
return ((self.always_allow(state, item) and item.name not in state.multiworld.non_local_items[item.player])
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
and self.item_rule(item)
and (not check_access or self.can_reach(state))))
@@ -1371,7 +1209,7 @@ class Spoiler():
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
# we can finally output our playthrough
self.playthrough = {"0": sorted([str(item) for item in
self.playthrough = {"0": sorted([self.multiworld.get_name_string_for_object(item) for item in
chain.from_iterable(multiworld.precollected_items.values())
if item.advancement])}
@@ -1429,7 +1267,7 @@ class Spoiler():
res = getattr(self.multiworld, option_key)[player]
display_name = getattr(option_obj, "display_name", option_key)
try:
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
outfile.write(f'{display_name + ":":33}{res.current_option_name}\n')
except:
raise Exception
@@ -1446,12 +1284,11 @@ class Spoiler():
if self.multiworld.players > 1:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player])
for f_option, option in Options.per_game_common_options.items():
options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions)
for f_option, option in options.items():
write_option(f_option, option)
options = self.multiworld.worlds[player].option_definitions
if options:
for f_option, option in options.items():
write_option(f_option, option)
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)
if self.entrances:
@@ -1465,7 +1302,7 @@ class Spoiler():
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
locations = [(str(location), str(location.item) if location.item is not None else "Nothing")
for location in self.multiworld.get_locations()]
for location in self.multiworld.get_locations() if location.show_in_spoiler]
outfile.write('\n\nLocations:\n\n')
outfile.write('\n'.join(
['%s: %s' % (location, item) for location, item in locations]))

View File

@@ -63,19 +63,22 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_received(self) -> bool:
"""List all received items"""
logger.info(f'{len(self.ctx.items_received)} received items:')
self.output(f'{len(self.ctx.items_received)} received items:')
for index, item in enumerate(self.ctx.items_received, 1):
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
return True
def _cmd_missing(self) -> bool:
"""List all missing location checks, from your local game state"""
def _cmd_missing(self, filter_text = "") -> bool:
"""List all missing location checks, from your local game state.
Can be given text, which will be used as filter."""
if not self.ctx.game:
self.output("No game set, cannot determine missing checks.")
return False
count = 0
checked_count = 0
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
if filter_text and filter_text not in location:
continue
if location_id < 0:
continue
if location_id not in self.ctx.locations_checked:
@@ -136,7 +139,7 @@ class CommonContext:
items_handling: typing.Optional[int] = None
want_slot_data: bool = True # should slot_data be retrieved via Connect
# datapackage
# data package
# Contents in flux until connection to server is made, to download correct data for this multiworld.
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
@@ -223,7 +226,7 @@ class CommonContext:
self.watcher_event = asyncio.Event()
self.jsontotextparser = JSONtoTextParser(self)
self.update_datapackage(network_data_package)
self.update_data_package(network_data_package)
# execution
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@@ -399,32 +402,40 @@ class CommonContext:
self.input_task.cancel()
# DataPackage
async def prepare_datapackage(self, relevant_games: typing.Set[str],
remote_datepackage_versions: typing.Dict[str, int]):
async def prepare_data_package(self, relevant_games: typing.Set[str],
remote_date_package_versions: typing.Dict[str, int],
remote_data_package_checksums: typing.Dict[str, str]):
"""Validate that all data is present for the current multiworld.
Download, assimilate and cache missing data from the server."""
# by documentation any game can use Archipelago locations/items -> always relevant
relevant_games.add("Archipelago")
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
needed_updates: typing.Set[str] = set()
for game in relevant_games:
if game not in remote_datepackage_versions:
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
continue
remote_version: int = remote_datepackage_versions[game]
if remote_version == 0: # custom datapackage for this game
remote_version: int = remote_date_package_versions.get(game, 0)
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
needed_updates.add(game)
continue
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
# no action required if local version is new enough
if remote_version > local_version:
cache_version: int = cache_package.get(game, {}).get("version", 0)
if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
or remote_checksum != local_checksum:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
cache_version: int = cached_game.get("version", 0)
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
# download remote version if cache is not new enough
if remote_version > cache_version:
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
needed_updates.add(game)
else:
self.update_game(cache_package[game])
self.update_game(cached_game)
if needed_updates:
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
@@ -434,15 +445,17 @@ class CommonContext:
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[location_id] = location_name
def update_datapackage(self, data_package: dict):
for game, gamedata in data_package["games"].items():
self.update_game(gamedata)
def update_data_package(self, data_package: dict):
for game, game_data in data_package["games"].items():
self.update_game(game_data)
def consume_network_datapackage(self, data_package: dict):
self.update_datapackage(data_package)
def consume_network_data_package(self, data_package: dict):
self.update_data_package(data_package)
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
current_cache.update(data_package["games"])
Utils.persistent_store("datapackage", "games", current_cache)
for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data)
# DeathLink hooks
@@ -661,14 +674,16 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
# update datapackage
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
# update data package
data_package_versions = args.get("datapackage_versions", {})
data_package_checksums = args.get("datapackage_checksums", {})
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
logger.info("Got new ID/Name DataPackage")
ctx.consume_network_datapackage(args['data'])
ctx.consume_network_data_package(args['data'])
elif cmd == 'ConnectionRefused':
errors = args["errors"]

66
Fill.py
View File

@@ -1,11 +1,10 @@
import logging
import typing
import collections
import itertools
import logging
import typing
from collections import Counter, deque
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item, ItemClassification
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from worlds.AutoWorld import call_all
from worlds.generic.Rules import add_item_rule
@@ -23,15 +22,27 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False) -> None:
allow_partial: bool = False, allow_excluded: bool = False) -> None:
"""
:param world: Multiworld to be filled.
:param base_state: State assumed before fill.
:param locations: Locations to be filled with item_pool
:param item_pool: Items to fill into the locations
:param single_player_placement: if true, can speed up placement if everything belongs to a single player
:param lock: locations are set to locked as they are filled
:param swap: if true, swaps of already place items are done in the event of a dead end
:param on_place: callback that is called when a placement happens
:param allow_partial: only place what is possible. Remaining items will be in the item_pool list.
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
"""
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
for item in itempool:
for item in item_pool:
reachable_items.setdefault(item.player, deque()).append(item)
while any(reachable_items.values()) and locations:
@@ -39,9 +50,9 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
items_to_place = [items.pop()
for items in reachable_items.values() if items]
for item in items_to_place:
itempool.remove(item)
item_pool.remove(item)
maximum_exploration_state = sweep_from_pool(
base_state, itempool + unplaced_items)
base_state, item_pool + unplaced_items)
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
@@ -111,7 +122,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
reachable_items[placed_item.player].appendleft(
placed_item)
itempool.append(placed_item)
item_pool.append(placed_item)
break
@@ -133,6 +144,21 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
if on_place:
on_place(spot_to_fill)
if allow_excluded:
# check if partial fill is the result of excluded locations, in which case retry
excluded_locations = [
location for location in locations
if location.progress_type == location.progress_type.EXCLUDED and not location.item
]
if excluded_locations:
for location in excluded_locations:
location.progress_type = location.progress_type.DEFAULT
fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
swap, on_place, allow_partial, False)
for location in excluded_locations:
if not location.item:
location.progress_type = location.progress_type.EXCLUDED
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0:
# There are leftover unplaceable items and locations that won't accept them
if world.can_beat_game():
@@ -142,7 +168,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
itempool.extend(unplaced_items)
item_pool.extend(unplaced_items)
def remaining_fill(world: MultiWorld,
@@ -499,16 +525,16 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
checked_locations: typing.Set[Location] = set()
unchecked_locations: typing.Set[Location] = set(world.get_locations())
reachable_locations_count: typing.Dict[int, int] = {
player: 0
for player in world.player_ids
if len(world.get_filled_locations(player)) != 0
}
total_locations_count: typing.Counter[int] = Counter(
location.player
for location in world.get_locations()
if not location.locked
)
reachable_locations_count: typing.Dict[int, int] = {
player: 0
for player in world.player_ids
if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
}
balanceable_players = {
player: balanceable_players[player]
for player in balanceable_players
@@ -525,6 +551,10 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
def item_percentage(player: int, num: int) -> float:
return num / total_locations_count[player]
# If there are no locations that aren't locked, there's no point in attempting to balance progression.
if len(total_locations_count) == 0:
return
while True:
# Gather non-locked locations.
# This ensures that only shuffled locations get counted for progression balancing,
@@ -798,7 +828,6 @@ def distribute_planned(world: MultiWorld) -> None:
for player in worlds:
locations += non_early_locations[player]
block['locations'] = locations
if not block['count']:
@@ -840,8 +869,7 @@ def distribute_planned(world: MultiWorld) -> None:
maxcount = placement['count']['target']
from_pool = placement['from_pool']
candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
worlds))
candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
world.random.shuffle(candidates)
world.random.shuffle(items)
count = 0

View File

@@ -107,7 +107,7 @@ def main(args=None, callback=ERmain):
player_files = {}
for file in os.scandir(args.player_files_path):
fname = file.name
if file.is_file() and not file.name.startswith(".") and \
if file.is_file() and not fname.startswith(".") and \
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
path = os.path.join(args.player_files_path, fname)
try:

906
KH2Client.py Normal file
View File

@@ -0,0 +1,906 @@
import os
import asyncio
import ModuleUpdate
import json
import Utils
from pymem import pymem
from worlds.kh2.Items import exclusionItem_table, CheckDupingItems
from worlds.kh2 import all_locations, item_dictionary_table, exclusion_table
from worlds.kh2.WorldLocations import *
from worlds import network_data_package
if __name__ == "__main__":
Utils.init_logging("KH2Client", exception_logger="Client")
from NetUtils import ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop
ModuleUpdate.update()
kh2_loc_name_to_id = network_data_package["games"]["Kingdom Hearts 2"]["location_name_to_id"]
# class KH2CommandProcessor(ClientCommandProcessor):
class KH2Context(CommonContext):
# command_processor: int = KH2CommandProcessor
game = "Kingdom Hearts 2"
items_handling = 0b101 # Indicates you get items sent from other worlds.
def __init__(self, server_address, password):
super(KH2Context, self).__init__(server_address, password)
self.kh2LocalItems = None
self.ability = None
self.growthlevel = None
self.KH2_sync_task = None
self.syncing = False
self.kh2connected = 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.lookup_id_to_item: typing.Dict[int, str] = {data.code: item_name for item_name, data in
item_dictionary_table.items() if data.code}
self.lookup_id_to_Location: typing.Dict[int, str] = {data.code: item_name for item_name, data in
all_locations.items() if data.code}
self.location_name_to_worlddata = {name: data for name, data, in all_world_locations.items()}
self.location_table = {}
self.collectible_table = {}
self.collectible_override_flags_address = 0
self.collectible_offsets = {}
self.sending = []
# flag for if the player has gotten their starting inventory from the server
self.hasStartingInvo = False
# list used to keep track of locations+items player has. Used for disoneccting
self.kh2seedsave = {"checked_locations": {"0": []},
"starting_inventory": self.hasStartingInvo,
# Character: [back of invo, front of invo]
"SoraInvo": [0x25CC, 0x2546],
"DonaldInvo": [0x2678, 0x2658],
"GoofyInvo": [0x278E, 0x276C],
"AmountInvo": {
"ServerItems": {
"Ability": {},
"Amount": {},
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, "Aerial Dodge": 0,
"Glide": 0},
"Bitmask": [],
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
"Equipment": [],
"Magic": {},
"StatIncrease": {},
"Boost": {},
},
"LocalItems": {
"Ability": {},
"Amount": {},
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
"Aerial Dodge": 0, "Glide": 0},
"Bitmask": [],
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
"Equipment": [],
"Magic": {},
"StatIncrease": {},
"Boost": {},
}},
# 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked
"worldIdChecks": {
"1": [], # world of darkness (story cutscenes)
"2": [],
"3": [], # destiny island doesn't have checks to ima put tt checks here
"4": [],
"5": [],
"6": [],
"7": [],
"8": [],
"9": [],
"10": [],
"11": [],
# atlantica isn't a supported world. if you go in atlantica it will check dc
"12": [],
"13": [],
"14": [],
"15": [],
# world map, but you only go to the world map while on the way to goa so checking hb
"16": [],
"17": [],
"18": [],
"255": [], # starting screen
},
"Levels": {
"SoraLevel": 0,
"ValorLevel": 0,
"WisdomLevel": 0,
"LimitLevel": 0,
"MasterLevel": 0,
"FinalLevel": 0,
},
"SoldEquipment": [],
"SoldBoosts": {"Power Boost": 0,
"Magic Boost": 0,
"Defense Boost": 0,
"AP Boost": 0}
}
self.slotDataProgressionNames = {}
self.kh2seedname = None
self.kh2slotdata = None
self.itemamount = {}
# sora equipped, valor equipped, master equipped, final equipped
self.keybladeAnchorList = (0x24F0, 0x32F4, 0x339C, 0x33D4)
if "localappdata" in os.environ:
self.game_communication_path = os.path.expandvars(r"%localappdata%\KH2AP")
self.amountOfPieces = 0
# hooked object
self.kh2 = None
self.ItemIsSafe = False
self.game_connected = False
self.finalxemnas = False
self.worldid = {
# 1: {}, # world of darkness (story cutscenes)
2: TT_Checks,
# 3: {}, # destiny island doesn't have checks to ima put tt checks here
4: HB_Checks,
5: BC_Checks,
6: Oc_Checks,
7: AG_Checks,
8: LoD_Checks,
9: HundredAcreChecks,
10: PL_Checks,
11: DC_Checks, # atlantica isn't a supported world. if you go in atlantica it will check dc
12: DC_Checks,
13: TR_Checks,
14: HT_Checks,
15: HB_Checks, # world map, but you only go to the world map while on the way to goa so checking hb
16: PR_Checks,
17: SP_Checks,
18: TWTNW_Checks,
# 255: {}, # starting screen
}
# 0x2A09C00+0x40 is the sve anchor. +1 is the last saved room
self.sveroom = 0x2A09C00 + 0x41
# 0 not in battle 1 in yellow battle 2 red battle #short
self.inBattle = 0x2A0EAC4 + 0x40
self.onDeath = 0xAB9078
# PC Address anchors
self.Now = 0x0714DB8
self.Save = 0x09A70B0
self.Sys3 = 0x2A59DF0
self.Bt10 = 0x2A74880
self.BtlEnd = 0x2A0D3E0
self.Slot1 = 0x2A20C98
self.chest_set = set(exclusion_table["Chests"])
self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"])
self.staff_set = set(CheckDupingItems["Weapons"]["Staffs"])
self.shield_set = set(CheckDupingItems["Weapons"]["Shields"])
self.all_weapons = self.keyblade_set.union(self.staff_set).union(self.shield_set)
self.equipment_categories = CheckDupingItems["Equipment"]
self.armor_set = set(self.equipment_categories["Armor"])
self.accessories_set = set(self.equipment_categories["Accessories"])
self.all_equipment = self.armor_set.union(self.accessories_set)
self.Equipment_Anchor_Dict = {
"Armor": [0x2504, 0x2506, 0x2508, 0x250A],
"Accessories": [0x2514, 0x2516, 0x2518, 0x251A]}
self.AbilityQuantityDict = {}
self.ability_categories = CheckDupingItems["Abilities"]
self.sora_ability_set = set(self.ability_categories["Sora"])
self.donald_ability_set = set(self.ability_categories["Donald"])
self.goofy_ability_set = set(self.ability_categories["Goofy"])
self.all_abilities = self.sora_ability_set.union(self.donald_ability_set).union(self.goofy_ability_set)
self.boost_set = set(CheckDupingItems["Boosts"])
self.stat_increase_set = set(CheckDupingItems["Stat Increases"])
self.AbilityQuantityDict = {item: self.item_name_to_data[item].quantity for item in self.all_abilities}
# Growth:[level 1,level 4,slot]
self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25CE],
"Quick Run": [0x62, 0x65, 0x25D0],
"Dodge Roll": [0x234, 0x237, 0x25D2],
"Aerial Dodge": [0x066, 0x069, 0x25D4],
"Glide": [0x6A, 0x6D, 0x25D6]}
self.boost_to_anchor_dict = {
"Power Boost": 0x24F9,
"Magic Boost": 0x24FA,
"Defense Boost": 0x24FB,
"AP Boost": 0x24F8}
self.AbilityCodeList = [self.item_name_to_data[item].code for item in exclusionItem_table["Ability"]]
self.master_growth = {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}
self.bitmask_item_code = [
0x130000, 0x130001, 0x130002, 0x130003, 0x130004, 0x130005, 0x130006, 0x130007
, 0x130008, 0x130009, 0x13000A, 0x13000B, 0x13000C
, 0x13001F, 0x130020, 0x130021, 0x130022, 0x130023
, 0x13002A, 0x13002B, 0x13002C, 0x13002D]
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(KH2Context, self).server_auth(password_requested)
await self.get_username()
await self.send_connect()
async def connection_closed(self):
self.kh2connected = False
self.serverconneced = False
if self.kh2seedname is not None and self.auth is not None:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).connection_closed()
async def disconnect(self, allow_autoreconnect: bool = False):
self.kh2connected = False
self.serverconneced = False
if self.kh2seedname not in {None} and self.auth not in {None}:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).disconnect()
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
async def shutdown(self):
if self.kh2seedname not in {None} and self.auth not in {None}:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).shutdown()
def on_package(self, cmd: str, args: dict):
if cmd in {"RoomInfo"}:
self.kh2seedname = args['seed_name']
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
if not os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'wt') as f:
pass
elif os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
with open(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json", 'r') as f:
self.kh2seedsave = json.load(f)
if cmd in {"Connected"}:
for player in args['players']:
if str(player.slot) not in self.kh2seedsave["checked_locations"]:
self.kh2seedsave["checked_locations"].update({str(player.slot): []})
self.kh2slotdata = args['slot_data']
self.serverconneced = True
self.kh2LocalItems = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()}
try:
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
logger.info("You are now auto-tracking")
self.kh2connected = True
except Exception as e:
logger.info("Line 247")
if self.kh2connected:
logger.info("Connection Lost")
self.kh2connected = False
logger.info(e)
if cmd in {"ReceivedItems"}:
start_index = args["index"]
if start_index != len(self.items_received):
for item in args['items']:
# starting invo from server
if item.location in {-2}:
if not self.kh2seedsave["starting_inventory"]:
asyncio.create_task(self.give_item(item.item))
# if location is not already given or is !getitem
elif item.location not in self.kh2seedsave["checked_locations"][str(item.player)] \
or item.location in {-1}:
asyncio.create_task(self.give_item(item.item))
if item.location not in self.kh2seedsave["checked_locations"][str(item.player)] \
and item.location not in {-1, -2}:
self.kh2seedsave["checked_locations"][str(item.player)].append(item.location)
if not self.kh2seedsave["starting_inventory"]:
self.kh2seedsave["starting_inventory"] = True
if cmd in {"RoomUpdate"}:
if "checked_locations" in args:
new_locations = set(args["checked_locations"])
# TODO: make this take locations from other players on the same slot so proper coop happens
# items_to_give = [self.kh2slotdata["LocalItems"][str(location_id)] for location_id in new_locations if
# location_id in self.kh2LocalItems.keys()]
self.checked_locations |= new_locations
async def checkWorldLocations(self):
try:
currentworldint = int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big")
if currentworldint in self.worldid:
curworldid = self.worldid[currentworldint]
for location, data in curworldid.items():
if location not in self.locations_checked \
and (int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex) > 0:
self.locations_checked.add(location)
self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
except Exception as e:
logger.info("Line 285")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def checkLevels(self):
try:
for location, data in SoraLevels.items():
currentLevel = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), "big")
if location not in self.locations_checked \
and currentLevel >= data.bitIndex:
if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel:
self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel
self.locations_checked.add(location)
self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
formDict = {
0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels],
3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels]}
for i in range(5):
for location, data in formDict[i][1].items():
formlevel = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big")
if location not in self.locations_checked \
and formlevel >= data.bitIndex:
if formlevel > self.kh2seedsave["Levels"][formDict[i][0]]:
self.kh2seedsave["Levels"][formDict[i][0]] = formlevel
self.locations_checked.add(location)
self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
except Exception as e:
logger.info("Line 312")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def checkSlots(self):
try:
for location, data in weaponSlots.items():
if location not in self.locations_checked:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") > 0:
self.locations_checked.add(location)
self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
for location, data in formSlots.items():
if location not in self.locations_checked:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex > 0:
self.locations_checked.add(location)
self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
except Exception as e:
if self.kh2connected:
logger.info("Line 333")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def verifyChests(self):
try:
currentworld = str(int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big"))
for location in self.kh2seedsave["worldIdChecks"][currentworld]:
locationName = self.lookup_id_to_Location[location]
if locationName in self.chest_set:
if locationName in self.location_name_to_worlddata.keys():
locationData = self.location_name_to_worlddata[locationName]
if int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
"big") & 0x1 << locationData.bitIndex == 0:
roomData = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
1), "big")
self.kh2.write_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
(roomData | 0x01 << locationData.bitIndex).to_bytes(1, 'big'), 1)
except Exception as e:
if self.kh2connected:
logger.info("Line 350")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def verifyLevel(self):
for leveltype, anchor in {"SoraLevel": 0x24FF,
"ValorLevel": 0x32F6,
"WisdomLevel": 0x332E,
"LimitLevel": 0x3366,
"MasterLevel": 0x339E,
"FinalLevel": 0x33D6}.items():
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + anchor, 1), "big") < \
self.kh2seedsave["Levels"][leveltype]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + anchor,
(self.kh2seedsave["Levels"][leveltype]).to_bytes(1, 'big'), 1)
def verifyLocation(self, location):
locationData = self.location_name_to_worlddata[location]
locationName = self.lookup_id_to_Location[location]
isChecked = True
if locationName not in levels_locations:
if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
"big") & 0x1 << locationData.bitIndex) == 0:
isChecked = False
elif locationName in SoraLevels:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1),
"big") < locationData.bitIndex:
isChecked = False
elif int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
"big") < locationData.bitIndex:
isChecked = False
return isChecked
async def give_item(self, item, ItemType="ServerItems"):
try:
itemname = self.lookup_id_to_item[item]
itemcode = self.item_name_to_data[itemname]
if itemcode.ability:
abilityInvoType = 0
TwilightZone = 2
if ItemType == "LocalItems":
abilityInvoType = 1
TwilightZone = -2
if itemname in {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}:
self.kh2seedsave["AmountInvo"][ItemType]["Growth"][itemname] += 1
return
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Ability"]:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname] = []
# appending the slot that the ability should be in
if len(self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname]) < \
self.AbilityQuantityDict[itemname]:
if itemname in self.sora_ability_set:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["SoraInvo"][abilityInvoType])
self.kh2seedsave["SoraInvo"][abilityInvoType] -= TwilightZone
elif itemname in self.donald_ability_set:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["DonaldInvo"][abilityInvoType])
self.kh2seedsave["DonaldInvo"][abilityInvoType] -= TwilightZone
else:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["GoofyInvo"][abilityInvoType])
self.kh2seedsave["GoofyInvo"][abilityInvoType] -= TwilightZone
elif itemcode.code in self.bitmask_item_code:
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"]:
self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"].append(itemname)
elif itemcode.memaddr in {0x3594, 0x3595, 0x3596, 0x3597, 0x35CF, 0x35D0}:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Magic"]:
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] = 1
elif itemname in self.all_equipment:
self.kh2seedsave["AmountInvo"][ItemType]["Equipment"].append(itemname)
elif itemname in self.all_weapons:
if itemname in self.keyblade_set:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Sora"].append(itemname)
elif itemname in self.staff_set:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Donald"].append(itemname)
else:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Goofy"].append(itemname)
elif itemname in self.boost_set:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Boost"]:
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] = 1
elif itemname in self.stat_increase_set:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"]:
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] = 1
else:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Amount"]:
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] = 1
except Exception as e:
if self.kh2connected:
logger.info("Line 398")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager
class KH2Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago KH2 Client"
self.ui = KH2Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def IsInShop(self, sellable, master_boost):
# journal = 0x741230 shop = 0x741320
# if journal=-1 and shop = 5 then in shop
# if journam !=-1 and shop = 10 then journal
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
if (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
# print("your in the shop")
sellable_dict = {}
for itemName in sellable:
itemdata = self.item_name_to_data[itemName]
amount = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
sellable_dict[itemName] = amount
while (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
await asyncio.sleep(0.5)
for item, amount in sellable_dict.items():
itemdata = self.item_name_to_data[item]
afterShop = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
if afterShop < amount:
if item in master_boost:
self.kh2seedsave["SoldBoosts"][item] += (amount - afterShop)
else:
self.kh2seedsave["SoldEquipment"].append(item)
async def verifyItems(self):
try:
local_amount = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"].keys())
server_amount = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"].keys())
master_amount = local_amount | server_amount
local_ability = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"].keys())
server_ability = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"].keys())
master_ability = local_ability | server_ability
local_bitmask = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Bitmask"])
server_bitmask = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Bitmask"])
master_bitmask = local_bitmask | server_bitmask
local_keyblade = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Sora"])
local_staff = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Donald"])
local_shield = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Goofy"])
server_keyblade = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Sora"])
server_staff = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Donald"])
server_shield = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Goofy"])
master_keyblade = local_keyblade | server_keyblade
master_staff = local_staff | server_staff
master_shield = local_shield | server_shield
local_equipment = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Equipment"])
server_equipment = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Equipment"])
master_equipment = local_equipment | server_equipment
local_magic = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"].keys())
server_magic = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"].keys())
master_magic = local_magic | server_magic
local_stat = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"].keys())
server_stat = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"].keys())
master_stat = local_stat | server_stat
local_boost = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"].keys())
server_boost = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"].keys())
master_boost = local_boost | server_boost
master_sell = master_equipment | master_staff | master_shield | master_boost
await asyncio.create_task(self.IsInShop(master_sell, master_boost))
for itemName in master_amount:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_amount:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"][itemName]
if itemName in server_amount:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"][itemName]
if itemName == "Torn Page":
# Torn Pages are handled differently because they can be consumed.
# Will check the progression in 100 acre and - the amount of visits
# amountofitems-amount of visits done
for location, data in tornPageLocks.items():
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex > 0:
amountOfItems -= 1
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems and amountOfItems >= 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_keyblade:
itemData = self.item_name_to_data[itemName]
# if the inventory slot for that keyblade is less than the amount they should have
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x1CFF, 1),
"big") != 13:
# Checking form anchors for the keyblade
if self.kh2.read_short(self.kh2.base_address + self.Save + 0x24F0) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x32F4) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x339C) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x33D4) == itemData.kh2id:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(0).to_bytes(1, 'big'), 1)
else:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_staff:
itemData = self.item_name_to_data[itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 \
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2604) != itemData.kh2id \
and itemName not in self.kh2seedsave["SoldEquipment"]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_shield:
itemData = self.item_name_to_data[itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 \
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2718) != itemData.kh2id \
and itemName not in self.kh2seedsave["SoldEquipment"]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_ability:
itemData = self.item_name_to_data[itemName]
ability_slot = []
if itemName in local_ability:
ability_slot += self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"][itemName]
if itemName in server_ability:
ability_slot += self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"][itemName]
for slot in ability_slot:
current = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
ability = current & 0x0FFF
if ability | 0x8000 != (0x8000 + itemData.memaddr):
self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr)
for itemName in self.master_growth:
growthLevel = self.kh2seedsave["AmountInvo"]["ServerItems"]["Growth"][itemName] \
+ self.kh2seedsave["AmountInvo"]["LocalItems"]["Growth"][itemName]
if growthLevel > 0:
slot = self.growth_values_dict[itemName][2]
min_growth = self.growth_values_dict[itemName][0]
max_growth = self.growth_values_dict[itemName][1]
if growthLevel > 4:
growthLevel = 4
current_growth_level = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
ability = current_growth_level & 0x0FFF
# if the player should be getting a growth ability
if ability | 0x8000 != 0x8000 + min_growth - 1 + growthLevel:
# if it should be level one of that growth
if 0x8000 + min_growth - 1 + growthLevel <= 0x8000 + min_growth or ability < min_growth:
self.kh2.write_short(self.kh2.base_address + self.Save + slot, min_growth)
# if it is already in the inventory
elif ability | 0x8000 < (0x8000 + max_growth):
self.kh2.write_short(self.kh2.base_address + self.Save + slot, current_growth_level + 1)
for itemName in master_bitmask:
itemData = self.item_name_to_data[itemName]
itemMemory = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big")
if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") & 0x1 << itemData.bitmask) == 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(itemMemory | 0x01 << itemData.bitmask).to_bytes(1, 'big'), 1)
for itemName in master_equipment:
itemData = self.item_name_to_data[itemName]
isThere = False
if itemName in self.accessories_set:
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Accessories"]
else:
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Armor"]
# Checking form anchors for the equipment
for slot in Equipment_Anchor_List:
if self.kh2.read_short(self.kh2.base_address + self.Save + slot) == itemData.kh2id:
isThere = True
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(0).to_bytes(1, 'big'), 1)
break
if not isThere and itemName not in self.kh2seedsave["SoldEquipment"]:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_magic:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_magic:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"][itemName]
if itemName in server_magic:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"][itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems \
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x741320, 1), "big") in {10, 8}:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_stat:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_stat:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"][itemName]
if itemName in server_stat:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"][itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems \
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Slot1 + 0x1B2, 1),
"big") >= 5:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_boost:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_boost:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"][itemName]
if itemName in server_boost:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"][itemName]
amountOfBoostsInInvo = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big")
amountOfUsedBoosts = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + self.boost_to_anchor_dict[itemName], 1),
"big")
# Ap Boots start at +50 for some reason
if itemName == "AP Boost":
amountOfUsedBoosts -= 50
totalBoosts = (amountOfBoostsInInvo + amountOfUsedBoosts)
if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][itemName] and amountOfBoostsInInvo < 255:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(amountOfBoostsInInvo + 1).to_bytes(1, 'big'), 1)
except Exception as e:
logger.info("Line 573")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
def finishedGame(ctx: KH2Context, message):
if ctx.kh2slotdata['FinalXemnas'] == 1:
if 0x1301ED in message[0]["locations"]:
ctx.finalxemnas = True
# three proofs
if ctx.kh2slotdata['Goal'] == 0:
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, 1), "big") > 0 \
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, 1), "big") > 0 \
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, 1), "big") > 0:
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
elif ctx.kh2slotdata['Goal'] == 1:
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x3641, 1), "big") >= \
ctx.kh2slotdata['LuckyEmblemsRequired']:
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
elif ctx.kh2slotdata['Goal'] == 2:
for boss in ctx.kh2slotdata["hitlist"]:
if boss in message[0]["locations"]:
ctx.amountOfPieces += 1
if ctx.amountOfPieces >= ctx.kh2slotdata["BountyRequired"]:
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
async def kh2_watcher(ctx: KH2Context):
while not ctx.exit_event.is_set():
try:
if ctx.kh2connected and ctx.serverconneced:
ctx.sending = []
await asyncio.create_task(ctx.checkWorldLocations())
await asyncio.create_task(ctx.checkLevels())
await asyncio.create_task(ctx.checkSlots())
await asyncio.create_task(ctx.verifyChests())
await asyncio.create_task(ctx.verifyItems())
await asyncio.create_task(ctx.verifyLevel())
message = [{"cmd": 'LocationChecks', "locations": ctx.sending}]
if finishedGame(ctx, message):
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
location_ids = []
location_ids = [location for location in message[0]["locations"] if location not in location_ids]
for location in location_ids:
currentWorld = int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + 0x0714DB8, 1), "big")
if location not in ctx.kh2seedsave["worldIdChecks"][str(currentWorld)]:
ctx.kh2seedsave["worldIdChecks"][str(currentWorld)].append(location)
if location in ctx.kh2LocalItems:
item = ctx.kh2slotdata["LocalItems"][str(location)]
await asyncio.create_task(ctx.give_item(item, "LocalItems"))
await ctx.send_msgs(message)
elif not ctx.kh2connected and ctx.serverconneced:
logger.info("Game is not open. Disconnecting from Server.")
await ctx.disconnect()
except Exception as e:
logger.info("Line 661")
if ctx.kh2connected:
logger.info("Connection Lost.")
ctx.kh2connected = False
logger.info(e)
await asyncio.sleep(0.5)
if __name__ == '__main__':
async def main(args):
ctx = KH2Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
progression_watcher = asyncio.create_task(
kh2_watcher(ctx), name="KH2ProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
await progression_watcher
await ctx.shutdown()
import colorama
parser = get_base_parser(description="KH2 Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -14,10 +14,11 @@ import itertools
import shlex
import subprocess
import sys
from enum import Enum, auto
from os.path import isfile
from shutil import which
from typing import Iterable, Sequence, Callable, Union, Optional
from typing import Sequence, Union, Optional
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier
if __name__ == "__main__":
import ModuleUpdate
@@ -70,101 +71,12 @@ def browse_files():
webbrowser.open(file)
# noinspection PyArgumentList
class Type(Enum):
TOOL = auto()
FUNC = auto() # not a real component
CLIENT = auto()
ADJUSTER = auto()
class SuffixIdentifier:
suffixes: Iterable[str]
def __init__(self, *args: str):
self.suffixes = args
def __call__(self, path: str):
if isinstance(path, str):
for suffix in self.suffixes:
if path.endswith(suffix):
return True
return False
class Component:
display_name: str
type: Optional[Type]
script_name: Optional[str]
frozen_name: Optional[str]
icon: str # just the name, no suffix
cli: bool
func: Optional[Callable]
file_identifier: Optional[Callable[[str], bool]]
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None,
file_identifier: Optional[Callable[[str], bool]] = None):
self.display_name = display_name
self.script_name = script_name
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
self.icon = icon
self.cli = cli
self.type = component_type or \
None if not display_name else \
Type.FUNC if func else \
Type.CLIENT if 'Client' in display_name else \
Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL
self.func = func
self.file_identifier = file_identifier
def handles_file(self, path: str):
return self.file_identifier(path) if self.file_identifier else False
components: Iterable[Component] = (
# Launcher
Component('', 'Launcher'),
# Core
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
Component('Generate', 'Generate', cli=True),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
# SNI
Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw')),
Component('LttP Adjuster', 'LttPAdjuster'),
# Factorio
Component('Factorio Client', 'FactorioClient'),
# 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'),
# Pokémon
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# Wargroove
Component('Wargroove Client', 'WargrooveClient'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
components.extend([
# Functions
Component('Open host.yaml', func=open_host_yaml),
Component('Open Patch', func=open_patch),
Component('Browse Files', func=browse_files),
)
icon_paths = {
'icon': local_path('data', 'icon.ico' if is_windows else 'icon.png'),
'mcicon': local_path('data', 'mcicon.ico')
}
])
def identify(path: Union[None, str]):

609
LinksAwakeningClient.py Normal file
View File

@@ -0,0 +1,609 @@
import ModuleUpdate
ModuleUpdate.update()
import Utils
if __name__ == "__main__":
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
import asyncio
import base64
import binascii
import io
import logging
import select
import socket
import time
import typing
import urllib
import colorama
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop)
from NetUtils import ClientStatus
from worlds.ladx.Common import BASE_ID as LABaseID
from worlds.ladx.GpsTracker import GpsTracker
from worlds.ladx.ItemTracker import ItemTracker
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
class GameboyException(Exception):
pass
class RetroArchDisconnectError(GameboyException):
pass
class InvalidEmulatorStateError(GameboyException):
pass
class BadRetroArchResponse(GameboyException):
pass
def magpie_logo():
from kivy.uix.image import CoreImage
binary_data = """
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
binary_data = base64.b64decode(binary_data)
data = io.BytesIO(binary_data)
return CoreImage(data, ext="png").texture
class LAClientConstants:
# Connector version
VERSION = 0x01
#
# Memory locations of LADXR
ROMGameID = 0x0051 # 4 bytes
SlotName = 0x0134
# Unused
# ROMWorldID = 0x0055
# ROMConnectorVersion = 0x0056
# RO: We should only act if this is higher then 6, as it indicates that the game is running normally
wGameplayType = 0xDB95
# RO: Starts at 0, increases every time an item is received from the server and processed
wLinkSyncSequenceNumber = 0xDDF6
wLinkStatusBits = 0xDDF7 # RW:
# Bit0: wLinkGive* contains valid data, set from script cleared from ROM.
wLinkHealth = 0xDB5A
wLinkGiveItem = 0xDDF8 # RW
wLinkGiveItemFrom = 0xDDF9 # RW
# All of these six bytes are unused, we can repurpose
# wLinkSendItemRoomHigh = 0xDDFA # RO
# wLinkSendItemRoomLow = 0xDDFB # RO
# wLinkSendItemTarget = 0xDDFC # RO
# wLinkSendItemItem = 0xDDFD # RO
# wLinkSendShopItem = 0xDDFE # RO, which item to send (1 based, order of the shop items)
# RO, which player to send to, but it's just the X position of the NPC used, so 0x18 is player 0
# wLinkSendShopTarget = 0xDDFF
wRecvIndex = 0xDDFE # 0xDB58
wCheckAddress = 0xC0FF - 0x4
WRamCheckSize = 0x4
WRamSafetyValue = bytearray([0]*WRamCheckSize)
MinGameplayValue = 0x06
MaxGameplayValue = 0x1A
VictoryGameplayAndSub = 0x0102
class RAGameboy():
cache = []
cache_start = 0
cache_size = 0
last_cache_read = None
socket = None
def __init__(self, address, port) -> None:
self.address = address
self.port = port
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
assert (self.socket)
self.socket.setblocking(False)
def get_retroarch_version(self):
self.send(b'VERSION\n')
select.select([self.socket], [], [])
response_str, addr = self.socket.recvfrom(16)
return response_str.rstrip()
def get_retroarch_status(self, timeout):
self.send(b'GET_STATUS\n')
select.select([self.socket], [], [], timeout)
response_str, addr = self.socket.recvfrom(1000, )
return response_str.rstrip()
def set_cache_limits(self, cache_start, cache_size):
self.cache_start = cache_start
self.cache_size = cache_size
def send(self, b):
if type(b) is str:
b = b.encode('ascii')
self.socket.sendto(b, (self.address, self.port))
def recv(self):
select.select([self.socket], [], [])
response, _ = self.socket.recvfrom(4096)
return response
async def async_recv(self):
response = await asyncio.get_event_loop().sock_recv(self.socket, 4096)
return response
async def check_safe_gameplay(self, throw=True):
async def check_wram():
check_values = await self.async_read_memory(LAClientConstants.wCheckAddress, LAClientConstants.WRamCheckSize)
if check_values != LAClientConstants.WRamSafetyValue:
if throw:
raise InvalidEmulatorStateError()
return False
return True
if not await check_wram():
if throw:
raise InvalidEmulatorStateError()
return False
gameplay_value = await self.async_read_memory(LAClientConstants.wGameplayType)
gameplay_value = gameplay_value[0]
# In gameplay or credits
if not (LAClientConstants.MinGameplayValue <= gameplay_value <= LAClientConstants.MaxGameplayValue) and gameplay_value != 0x1:
if throw:
logger.info("invalid emu state")
raise InvalidEmulatorStateError()
return False
if not await check_wram():
return False
return True
# We're sadly unable to update the whole cache at once
# as RetroArch only gives back some number of bytes at a time
# So instead read as big as chunks at a time as we can manage
async def update_cache(self):
# First read the safety address - if it's invalid, bail
self.cache = []
if not await self.check_safe_gameplay():
return
cache = []
remaining_size = self.cache_size
while remaining_size:
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
remaining_size -= len(block)
cache += block
if not await self.check_safe_gameplay():
return
self.cache = cache
self.last_cache_read = time.time()
async def read_memory_cache(self, addresses):
# TODO: can we just update once per frame?
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
await self.update_cache()
if not self.cache:
return None
assert (len(self.cache) == self.cache_size)
for address in addresses:
assert self.cache_start <= address <= self.cache_start + self.cache_size
r = {address: self.cache[address - self.cache_start]
for address in addresses}
return r
async def async_read_memory_safe(self, address, size=1):
# whenever we do a read for a check, we need to make sure that we aren't reading
# garbage memory values - we also need to protect against reading a value, then the emulator resetting
#
# ...actually, we probably _only_ need the post check
# Check before read
if not await self.check_safe_gameplay():
return None
# Do read
r = await self.async_read_memory(address, size)
# Check after read
if not await self.check_safe_gameplay():
return None
return r
def read_memory(self, address, size=1):
command = "READ_CORE_MEMORY"
self.send(f'{command} {hex(address)} {size}\n')
response = self.recv()
splits = response.decode().split(" ", 2)
assert (splits[0] == command)
# Ignore the address for now
# TODO: transform to bytes
if splits[2][:2] == "-1" or splits[0] != "READ_CORE_MEMORY":
raise BadRetroArchResponse()
return bytearray.fromhex(splits[2])
async def async_read_memory(self, address, size=1):
command = "READ_CORE_MEMORY"
self.send(f'{command} {hex(address)} {size}\n')
response = await self.async_recv()
response = response[:-1]
splits = response.decode().split(" ", 2)
assert (splits[0] == command)
# Ignore the address for now
# TODO: transform to bytes
return bytearray.fromhex(splits[2])
def write_memory(self, address, bytes):
command = "WRITE_CORE_MEMORY"
self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}')
select.select([self.socket], [], [])
response, _ = self.socket.recvfrom(4096)
splits = response.decode().split(" ", 3)
assert (splits[0] == command)
if splits[2] == "-1":
logger.info(splits[3])
class LinksAwakeningClient():
socket = None
gameboy = None
tracker = None
auth = None
game_crc = None
pending_deathlink = False
deathlink_debounce = True
recvd_checks = {}
def msg(self, m):
logger.info(m)
s = f"SHOW_MSG {m}\n"
self.gameboy.send(s)
def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355):
self.gameboy = RAGameboy(retroarch_address, retroarch_port)
async def wait_for_retroarch_connection(self):
logger.info("Waiting on connection to Retroarch...")
while True:
try:
version = self.gameboy.get_retroarch_version()
NO_CONTENT = b"GET_STATUS CONTENTLESS"
status = NO_CONTENT
core_type = None
GAME_BOY = b"game_boy"
while status == NO_CONTENT or core_type != GAME_BOY:
try:
status = self.gameboy.get_retroarch_status(0.1)
if status.count(b" ") < 2:
await asyncio.sleep(1.0)
continue
GET_STATUS, PLAYING, info = status.split(b" ", 2)
if status.count(b",") < 2:
await asyncio.sleep(1.0)
continue
core_type, rom_name, self.game_crc = info.split(b",", 2)
if core_type != GAME_BOY:
logger.info(
f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?")
await asyncio.sleep(1.0)
continue
except (BlockingIOError, TimeoutError) as e:
await asyncio.sleep(0.1)
pass
logger.info(f"Connected to Retroarch {version} {info}")
self.gameboy.read_memory(0x1000)
return
except ConnectionResetError:
await asyncio.sleep(1.0)
pass
def reset_auth(self):
auth = binascii.hexlify(self.gameboy.read_memory(0x0134, 12)).decode()
if self.auth:
assert (auth == self.auth)
self.auth = auth
async def wait_and_init_tracker(self):
await self.wait_for_game_ready()
self.tracker = LocationTracker(self.gameboy)
self.item_tracker = ItemTracker(self.gameboy)
self.gps_tracker = GpsTracker(self.gameboy)
async def recved_item_from_ap(self, item_id, from_player, next_index):
# Don't allow getting an item until you've got your first check
if not self.tracker.has_start_item():
return
# Spin until we either:
# get an exception from a bad read (emu shut down or reset)
# beat the game
# the client handles the last pending item
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
while not (await self.is_victory()) and status & 1 == 1:
time.sleep(0.1)
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
item_id -= LABaseID
# The player name table only goes up to 100, so don't go past that
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
if from_player > 100:
from_player = 100
next_index += 1
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
item_id, from_player])
status |= 1
status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status])
self.gameboy.write_memory(LAClientConstants.wRecvIndex, [next_index])
async def wait_for_game_ready(self):
logger.info("Waiting on game to be in valid state...")
while not await self.gameboy.check_safe_gameplay(throw=False):
pass
logger.info("Ready!")
last_index = 0
async def is_victory(self):
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
await self.tracker.readChecks(item_get_cb)
await self.item_tracker.readItems()
await self.gps_tracker.read_location()
next_index = self.gameboy.read_memory(LAClientConstants.wRecvIndex)[0]
if next_index != self.last_index:
self.last_index = next_index
# logger.info(f"Got new index {next_index}")
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
if self.deathlink_debounce and current_health != 0:
self.deathlink_debounce = False
elif not self.deathlink_debounce and current_health == 0:
# logger.info("YOU DIED.")
await deathlink_cb()
self.deathlink_debounce = True
if self.pending_deathlink:
logger.info("Got a deathlink")
self.gameboy.write_memory(LAClientConstants.wLinkHealth, [0])
self.pending_deathlink = False
self.deathlink_debounce = True
if await self.is_victory():
await win_cb()
recv_index = (await self.gameboy.async_read_memory_safe(LAClientConstants.wRecvIndex))[0]
# Play back one at a time
if recv_index in self.recvd_checks:
item = self.recvd_checks[recv_index]
await self.recved_item_from_ap(item.item, item.player, recv_index)
all_tasks = set()
def create_task_log_exception(awaitable) -> asyncio.Task:
async def _log_exception(awaitable):
try:
return await awaitable
except Exception as e:
logger.exception(e)
pass
finally:
all_tasks.remove(task)
task = asyncio.create_task(_log_exception(awaitable))
all_tasks.add(task)
class LinksAwakeningContext(CommonContext):
tags = {"AP"}
game = "Links Awakening DX"
items_handling = 0b101
want_slot_data = True
la_task = None
client = None
# TODO: does this need to re-read on reset?
found_checks = []
last_resend = time.time()
magpie = MagpieBridge()
magpie_task = None
won = False
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
self.client = LinksAwakeningClient()
super().__init__(server_address, password)
def run_gui(self) -> None:
import webbrowser
import kvui
from kvui import Button, GameManager
from kivy.uix.image import Image
class LADXManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("Tracker", "Tracker"),
]
base_title = "Archipelago Links Awakening DX Client"
def build(self):
b = super().build()
button = Button(text="", size=(30, 30), size_hint_x=None,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
image = Image(size=(16, 16), texture=magpie_logo())
button.add_widget(image)
def set_center(_, center):
image.center = center
button.bind(center=set_center)
self.connect_layout.add_widget(button)
return b
self.ui = LADXManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def send_checks(self):
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
await self.send_msgs(message)
ENABLE_DEATHLINK = False
async def send_deathlink(self):
if self.ENABLE_DEATHLINK:
message = [{"cmd": 'Deathlink',
'time': time.time(),
'cause': 'Had a nightmare',
# 'source': self.slot_info[self.slot].name,
}]
await self.send_msgs(message)
async def send_victory(self):
if not self.won:
message = [{"cmd": "StatusUpdate",
"status": ClientStatus.CLIENT_GOAL}]
logger.info("victory!")
await self.send_msgs(message)
self.won = True
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
if self.ENABLE_DEATHLINK:
self.client.pending_deathlink = True
def new_checks(self, item_ids, ladxr_ids):
self.found_checks += item_ids
create_task_log_exception(self.send_checks())
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(LinksAwakeningContext, self).server_auth(password_requested)
self.auth = self.client.auth
await self.get_username()
await self.send_connect()
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
# TODO - use watcher_event
if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], args["index"]):
self.client.recvd_checks[index] = item
item_id_lookup = get_locations_to_id()
async def run_game_loop(self):
def on_item_get(ladxr_checks):
checks = [self.item_id_lookup[meta_to_name(
checkMetadataTable[check.id])] for check in ladxr_checks]
self.new_checks(checks, [check.id for check in ladxr_checks])
async def victory():
await self.send_victory()
async def deathlink():
await self.send_deathlink()
self.magpie_task = asyncio.create_task(self.magpie.serve())
# yield to allow UI to start
await asyncio.sleep(0)
while True:
try:
# TODO: cancel all client tasks
logger.info("(Re)Starting game loop")
self.found_checks.clear()
await self.client.wait_for_retroarch_connection()
self.client.reset_auth()
await self.client.wait_and_init_tracker()
while True:
await self.client.main_tick(on_item_get, victory, deathlink)
await asyncio.sleep(0.1)
now = time.time()
if self.last_resend + 5.0 < now:
self.last_resend = now
await self.send_checks()
self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker)
except GameboyException:
time.sleep(1.0)
pass
async def main():
parser = get_base_parser(description="Link's Awakening Client.")
parser.add_argument("--url", help="Archipelago connection url")
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apladx Archipelago Binary Patch file')
args = parser.parse_args()
logger.info(args)
if args.diff_file:
import Patch
logger.info("patch file was supplied - creating rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta:
args.url = meta["server"]
logger.info(f"wrote rom file to {rom_file}")
if args.url:
url = urllib.parse.urlparse(args.url)
args.connect = url.netloc
if url.password:
args.password = urllib.parse.unquote(url.password)
ctx = LinksAwakeningContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
# TODO: nothing about the lambda about has to be in a lambda
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
await ctx.exit_event.wait()
await ctx.shutdown()
if __name__ == '__main__':
colorama.init()
asyncio.run(main())
colorama.deinit()

18
Main.py
View File

@@ -38,7 +38,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world = MultiWorld(args.multi)
logger = logging.getLogger()
world.set_seed(seed, args.race, str(args.outputname if args.outputname else world.seed))
world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
world.plando_options = args.plando_options
world.shuffle = args.shuffle.copy()
@@ -53,7 +53,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.enemy_damage = args.enemy_damage.copy()
world.beemizer_total_chance = args.beemizer_total_chance.copy()
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
world.timer = args.timer.copy()
world.countdown_start_time = args.countdown_start_time.copy()
world.red_clock_time = args.red_clock_time.copy()
world.blue_clock_time = args.blue_clock_time.copy()
@@ -79,7 +78,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.state = CollectionState(world)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
logger.info("Found World Types:")
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
max_item = 0
@@ -356,12 +355,11 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in world.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location)
# custom datapackage
datapackage = {}
for game_world in world.worlds.values():
if game_world.data_version == 0 and game_world.game not in datapackage:
datapackage[game_world.game] = worlds.network_data_package["games"][game_world.game]
datapackage[game_world.game]["item_name_groups"] = game_world.item_name_groups
# embedded data package
data_package = {
game_world.game: worlds.network_data_package["games"][game_world.game]
for game_world in world.worlds.values()
}
multidata = {
"slot_data": slot_data,
@@ -378,7 +376,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": world.seed_name,
"datapackage": datapackage,
"datapackage": data_package,
}
AutoWorld.call_all(world, "modify_multidata", multidata)

View File

@@ -77,49 +77,34 @@ def read_apmc_file(apmc_file):
return json.loads(b64decode(f.read()))
def update_mod(forge_dir, minecraft_version: str, get_prereleases=False):
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)
client_releases_endpoint = "https://api.github.com/repos/KonoTyran/Minecraft_AP_Randomizer/releases"
resp = requests.get(client_releases_endpoint)
if resp.status_code == 200: # OK
try:
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
(minecraft_version in release['assets'][0]['name']),
resp.json()))
if ap_randomizer != latest_release['assets'][0]['name']:
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
f"{latest_release['assets'][0]['name']}")
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 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', latest_release['assets'][0]['name'])
logging.info("Downloading AP randomizer mod. This may take a moment...")
apmod_resp = requests.get(latest_release['assets'][0]['browser_download_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)
except StopIteration:
logging.warning(f"No compatible mod version found for {minecraft_version}.")
if not prompt_yes_no("Run server anyway?"):
sys.exit(0)
os.path.basename(url)
if ap_randomizer is not None:
logging.info(f"Your current mod is {ap_randomizer}.")
else:
logging.error(f"Error checking for randomizer mod updates (status code {resp.status_code}).")
logging.error(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)
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):
@@ -264,8 +249,13 @@ def get_minecraft_versions(version, release_channel="release"):
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
else:
return resp.json()[release_channel][0]
except StopIteration:
logging.error(f"No compatible mod version found for client version {version}.")
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:
@@ -286,6 +276,8 @@ if __name__ == '__main__':
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
@@ -296,12 +288,12 @@ if __name__ == '__main__':
options = Utils.get_options()
channel = args.channel or options["minecraft_options"]["release_channel"]
apmc_data = None
data_version = 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:
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', '')
@@ -311,6 +303,7 @@ if __name__ == '__main__':
max_heap = options["minecraft_options"]["max_heap_size"]
forge_version = args.forge or versions["forge"]
java_version = args.java or versions["java"]
mod_url = versions["url"]
java_dir = find_jdk_dir(java_version)
if args.install:
@@ -344,7 +337,7 @@ if __name__ == '__main__':
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, f"MC{forge_version.split('-')[0]}", channel != "release")
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)

View File

@@ -1,7 +1,6 @@
import os
import sys
import subprocess
import pkg_resources
import warnings
local_dir = os.path.dirname(__file__)
@@ -22,18 +21,50 @@ if not update_ran:
requirements_files.add(req_file)
def check_pip():
# detect if pip is available
try:
import pip # noqa: F401
except ImportError:
raise RuntimeError("pip not available. Please install pip.")
def confirm(msg: str):
try:
input(f"\n{msg}")
except KeyboardInterrupt:
print("\nAborting")
sys.exit(1)
def update_command():
check_pip()
for file in requirements_files:
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade'])
subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--upgrade"])
def install_pkg_resources(yes=False):
try:
import pkg_resources # noqa: F401
except ImportError:
check_pip()
if not yes:
confirm("pkg_resources not found, press enter to install it")
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
def update(yes=False, force=False):
global update_ran
if not update_ran:
update_ran = True
if force:
update_command()
return
install_pkg_resources(yes=yes)
import pkg_resources
for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
if not os.path.exists(path):
@@ -52,7 +83,7 @@ def update(yes=False, force=False):
egg = egg.split(";", 1)[0].rstrip()
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
warnings.warn(f"Specifying version as #egg={egg} will become unavailable in pip 25.0. "
"Use name @ url#version instead.", DeprecationWarning)
"Use name @ url#version instead.", DeprecationWarning)
line = egg
else:
egg = ""
@@ -79,11 +110,7 @@ def update(yes=False, force=False):
if not yes:
import traceback
traceback.print_exc()
try:
input(f"\nRequirement {requirement} is not satisfied, press enter to install it")
except KeyboardInterrupt:
print("\nAborting")
sys.exit(1)
confirm(f"Requirement {requirement} is not satisfied, press enter to install it")
update_command()
return

View File

@@ -7,17 +7,20 @@ import functools
import logging
import zlib
import collections
import typing
import inspect
import weakref
import datetime
import threading
import random
import pickle
import itertools
import time
import operator
import functools
import hashlib
import inspect
import itertools
import logging
import operator
import pickle
import random
import threading
import time
import typing
import weakref
import zlib
import ModuleUpdate
@@ -160,10 +163,13 @@ class Context:
stored_data_notification_clients: typing.Dict[str, typing.Set[Client]]
slot_info: typing.Dict[int, NetworkSlot]
checksums: typing.Dict[str, str]
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
non_hintable_names: typing.Dict[str, typing.Set[str]]
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
@@ -231,30 +237,39 @@ class Context:
# init empty to satisfy linter, I suppose
self.gamespackage = {}
self.checksums = {}
self.item_name_groups = {}
self.location_name_groups = {}
self.all_item_and_group_names = {}
self.all_location_and_group_names = {}
self.non_hintable_names = collections.defaultdict(frozenset)
self._load_game_data()
# Datapackage retrieval
# Data package retrieval
def _load_game_data(self):
import worlds
self.gamespackage = worlds.network_data_package["games"]
self.item_name_groups = {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()}
self.location_name_groups = {world_name: world.location_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
self.non_hintable_names[world_name] = world.hint_blacklist
def _init_game_data(self):
for game_name, game_package in self.gamespackage.items():
if "checksum" in game_package:
self.checksums[game_name] = game_package["checksum"]
for item_name, item_id in game_package["item_name_to_id"].items():
self.item_names[item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[location_id] = location_name
self.all_item_and_group_names[game_name] = \
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
self.all_location_and_group_names[game_name] = \
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
@@ -343,7 +358,6 @@ class Context:
for text in texts]))
# loading
def load(self, multidatapath: str, use_embedded_server_options: bool = False):
if multidatapath.lower().endswith(".zip"):
import zipfile
@@ -358,7 +372,7 @@ class Context:
with open(multidatapath, 'rb') as f:
data = f.read()
self._load(self.decompress(data), use_embedded_server_options)
self._load(self.decompress(data), {}, use_embedded_server_options)
self.data_filename = multidatapath
@staticmethod
@@ -368,7 +382,8 @@ class Context:
raise Utils.VersionException("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: dict, use_embedded_server_options: bool):
def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
use_embedded_server_options: bool):
self.read_data = {}
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > Utils.version_tuple:
@@ -423,15 +438,22 @@ class Context:
server_options = decoded_obj.get("server_options", {})
self._set_options(server_options)
# custom datapackage
# embedded data package
for game_name, data in decoded_obj.get("datapackage", {}).items():
logging.info(f"Loading custom datapackage for game {game_name}")
if game_name in game_data_packages:
data = game_data_packages[game_name]
logging.info(f"Loading embedded data package for game {game_name}")
self.gamespackage[game_name] = data
self.item_name_groups[game_name] = data["item_name_groups"]
del data["item_name_groups"] # remove from datapackage, but keep in self.item_name_groups
if "location_name_groups" in data:
self.location_name_groups[game_name] = data["location_name_groups"]
del data["location_name_groups"]
del data["item_name_groups"] # remove from data package, but keep in self.item_name_groups
self._init_game_data()
for game_name, data in self.item_name_groups.items():
self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame]
for game_name, data in self.location_name_groups.items():
self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame]
# saving
@@ -575,7 +597,7 @@ class Context:
def get_hint_cost(self, slot):
if self.hint_cost:
return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None):
@@ -723,10 +745,12 @@ async def on_client_connected(ctx: Context, client: Client):
NetworkPlayer(team, slot,
ctx.name_aliases.get((team, slot), name), name)
)
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
games.add("Archipelago")
await ctx.send_msgs(client, [{
'cmd': 'RoomInfo',
'password': bool(ctx.password),
'games': {ctx.games[x] for x in range(1, len(ctx.games) + 1)},
'games': games,
# tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate.
'tags': ctx.tags,
@@ -735,7 +759,9 @@ async def on_client_connected(ctx: Context, client: Client):
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points,
'datapackage_versions': {game: game_data["version"] for game, game_data
in ctx.gamespackage.items()},
in ctx.gamespackage.items() if game in games},
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
'seed_name': ctx.seed_name,
'time': time.time(),
}])
@@ -756,7 +782,8 @@ async def on_client_disconnected(ctx: Context, client: Client):
async def on_client_joined(ctx: Context, client: Client):
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
if ctx.client_game_state[client.team, client.slot] == ClientStatus.CLIENT_UNKNOWN:
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
version_str = '.'.join(str(x) for x in client.version)
verb = "tracking" if "Tracker" in client.tags else "playing"
ctx.broadcast_text_all(
@@ -773,11 +800,12 @@ async def on_client_joined(ctx: Context, client: Client):
async def on_client_left(ctx: Context, client: Client):
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
if len(ctx.clients[client.team][client.slot]) < 1:
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
ctx.broadcast_text_all(
"%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1),
{"type": "Part", "team": client.team, "slot": client.slot})
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
async def countdown(ctx: Context, timer: int):
@@ -1299,27 +1327,41 @@ class ClientMessageProcessor(CommonCommandProcessor):
"Sorry, !remaining requires you to have beaten the game on this server")
return False
def _cmd_missing(self) -> bool:
"""List all missing location checks from the server's perspective"""
def _cmd_missing(self, filter_text="") -> bool:
"""List all missing location checks from the server's perspective.
Can be given text, which will be used as filter."""
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
if locations:
texts = [f'Missing: {self.ctx.location_names[location]}' for location in locations]
texts.append(f"Found {len(locations)} missing location checks")
names = [self.ctx.location_names[location] for location in locations]
if filter_text:
names = [name for name in names if filter_text in name]
texts = [f'Missing: {name}' for name in names]
if filter_text:
texts.append(f"Found {len(locations)} missing location checks, displaying {len(names)} of them.")
else:
texts.append(f"Found {len(locations)} missing location checks")
self.output_multiple(texts)
else:
self.output("No missing location checks found.")
return True
def _cmd_checked(self) -> bool:
"""List all done location checks from the server's perspective"""
def _cmd_checked(self, filter_text="") -> bool:
"""List all done location checks from the server's perspective.
Can be given text, which will be used as filter."""
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
if locations:
texts = [f'Checked: {self.ctx.location_names[location]}' for location in locations]
texts.append(f"Found {len(locations)} done location checks")
names = [self.ctx.location_names[location] for location in locations]
if filter_text:
names = [name for name in names if filter_text in name]
texts = [f'Checked: {name}' for name in names]
if filter_text:
texts.append(f"Found {len(locations)} done location checks, displaying {len(names)} of them.")
else:
texts.append(f"Found {len(locations)} done location checks")
self.output_multiple(texts)
else:
self.output("No done location checks found.")
@@ -1403,7 +1445,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if game not in self.ctx.all_item_and_group_names:
self.output("Can't look up item/location for unknown game. Hint for ID instead.")
return False
names = self.ctx.location_names_for_game(game) \
names = self.ctx.all_location_and_group_names[game] \
if for_location else \
self.ctx.all_item_and_group_names[game]
hint_name, usable, response = get_intended_text(input_text, names)
@@ -1419,6 +1461,11 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
elif hint_name in self.ctx.location_name_groups[game]: # location group name
hints = []
for loc_name in self.ctx.location_name_groups[game][hint_name]:
if loc_name in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name))
else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)

View File

@@ -35,7 +35,7 @@ class SlotType(enum.IntFlag):
@property
def always_goal(self) -> bool:
"""Mark this slot has having reached its goal instantly."""
"""Mark this slot as having reached its goal instantly."""
return self.value != 0b01

View File

@@ -289,7 +289,10 @@ async def patch_and_run_game(apz5_file):
decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
if not os.path.exists(rom_file_name):
rom_file_name = Utils.user_path(rom_file_name)
rom = Rom(rom_file_name)
apply_patch_file(rom, apz5_file,
sub_file=(os.path.basename(base_name) + '.zpf'
if zipfile.is_zipfile(apz5_file)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import abc
import logging
from copy import deepcopy
import math
import numbers
@@ -9,6 +10,10 @@ import random
from schema import Schema, And, Or, Optional
from Utils import get_fuzzy_results
if typing.TYPE_CHECKING:
from BaseClasses import PlandoOptions
from worlds.AutoWorld import World
class AssembleOptions(abc.ABCMeta):
def __new__(mcs, name, bases, attrs):
@@ -95,11 +100,11 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
supports_weighting = True
# filled by AssembleOptions:
name_lookup: typing.Dict[int, str]
name_lookup: typing.Dict[T, str]
options: typing.Dict[str, int]
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.get_current_option_name()})"
return f"{self.__class__.__name__}({self.current_option_name})"
def __hash__(self) -> int:
return hash(self.value)
@@ -109,7 +114,14 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
return self.name_lookup[self.value]
def get_current_option_name(self) -> str:
"""For display purposes."""
"""Deprecated. use current_option_name instead. TODO remove around 0.4"""
logging.warning(DeprecationWarning(f"get_current_option_name for {self.__class__.__name__} is deprecated."
f" use current_option_name instead. Worlds should use {self}.current_key"))
return self.current_option_name
@property
def current_option_name(self) -> str:
"""For display purposes. Worlds should be using current_key."""
return self.get_option_name(self.value)
@classmethod
@@ -131,17 +143,14 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
...
if typing.TYPE_CHECKING:
from Generate import PlandoOptions
from worlds.AutoWorld import World
def verify(self, world: World, player_name: str, plando_options: PlandoOptions) -> None:
def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
pass
else:
def verify(self, *args, **kwargs) -> None:
pass
class FreeText(Option):
class FreeText(Option[str]):
"""Text option that allows users to enter strings.
Needs to be validated by the world or option definition."""
@@ -162,7 +171,7 @@ class FreeText(Option):
return cls.from_text(str(data))
@classmethod
def get_option_name(cls, value: T) -> str:
def get_option_name(cls, value: str) -> str:
return value
@@ -424,6 +433,7 @@ class Choice(NumericOption):
class TextChoice(Choice):
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
value: typing.Union[str, int]
def __init__(self, value: typing.Union[str, int]):
assert isinstance(value, str) or isinstance(value, int), \
@@ -434,8 +444,7 @@ class TextChoice(Choice):
def current_key(self) -> str:
if isinstance(self.value, str):
return self.value
else:
return self.name_lookup[self.value]
return super().current_key
@classmethod
def from_text(cls, text: str) -> TextChoice:
@@ -450,7 +459,7 @@ class TextChoice(Choice):
def get_option_name(cls, value: T) -> str:
if isinstance(value, str):
return value
return cls.name_lookup[value]
return super().get_option_name(value)
def __eq__(self, other: typing.Any):
if isinstance(other, self.__class__):
@@ -573,12 +582,11 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
def valid_location_name(cls, value: str) -> bool:
return value in cls.locations
def verify(self, world, player_name: str, plando_options) -> None:
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if isinstance(self.value, int):
return
from Generate import PlandoOptions
from BaseClasses import PlandoOptions
if not(PlandoOptions.bosses & plando_options):
import logging
# plando is disabled but plando options were given so pull the option and change it to an int
option = self.value.split(";")[-1]
self.value = self.options[option]
@@ -716,7 +724,7 @@ class VerifyKeys:
value: typing.Any
@classmethod
def verify_keys(cls, data):
def verify_keys(cls, data: typing.List[str]):
if cls.valid_keys:
data = set(data)
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
@@ -725,12 +733,17 @@ class VerifyKeys:
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls.valid_keys}.")
def verify(self, world, player_name: str, plando_options) -> None:
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value:
new_value |= world.item_name_groups.get(item_name, {item_name})
self.value = new_value
elif self.convert_name_groups and self.verify_location_name:
new_value = type(self.value)()
for loc_name in self.value:
new_value |= world.location_name_groups.get(loc_name, {loc_name})
self.value = new_value
if self.verify_item_name:
for item_name in self.value:
if item_name not in world.item_names:
@@ -830,7 +843,9 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
return item in self.value
local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
class ItemSet(OptionSet):
verify_item_name = True
convert_name_groups = True
class Accessibility(Choice):
@@ -873,11 +888,6 @@ common_options = {
}
class ItemSet(OptionSet):
verify_item_name = True
convert_name_groups = True
class LocalItems(ItemSet):
"""Forces these items to be in their native world."""
display_name = "Local Items"
@@ -899,22 +909,24 @@ class StartHints(ItemSet):
display_name = "Start Hints"
class StartLocationHints(OptionSet):
class LocationSet(OptionSet):
verify_location_name = True
convert_name_groups = True
class StartLocationHints(LocationSet):
"""Start with these locations and their item prefilled into the !hint command"""
display_name = "Start Location Hints"
verify_location_name = True
class ExcludeLocations(OptionSet):
class ExcludeLocations(LocationSet):
"""Prevent these locations from having an important item"""
display_name = "Excluded Locations"
verify_location_name = True
class PriorityLocations(OptionSet):
class PriorityLocations(LocationSet):
"""Prevent these locations from having an unimportant item"""
display_name = "Priority Locations"
verify_location_name = True
class DeathLink(Toggle):
@@ -955,7 +967,7 @@ class ItemLinks(OptionList):
pool |= {item_name}
return pool
def verify(self, world, player_name: str, plando_options) -> None:
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
link: dict
super(ItemLinks, self).verify(world, player_name, plando_options)
existing_links = set()

View File

@@ -17,7 +17,7 @@ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandP
from worlds.pokemon_rb.locations import location_data
from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}}
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}}
location_bytes_bits = {}
for location in location_data:
if location.ram_address is not None:
@@ -40,7 +40,7 @@ CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
SCRIPT_VERSION = 1
SCRIPT_VERSION = 3
class GBCommandProcessor(ClientCommandProcessor):
@@ -70,6 +70,8 @@ class GBContext(CommonContext):
self.set_deathlink = False
self.client_compatibility_mode = 0
self.items_handling = 0b001
self.sent_release = False
self.sent_collect = False
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -124,7 +126,8 @@ def get_payload(ctx: GBContext):
"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},
"deathlink": ctx.deathlink_pending
"deathlink": ctx.deathlink_pending,
"options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled'))
}
)
ctx.deathlink_pending = False
@@ -134,10 +137,13 @@ def get_payload(ctx: GBContext):
async def parse_locations(data: List, ctx: GBContext):
locations = []
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], "Rod": data[0x140 + 0x20 + 0x0E:]}
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E],
"Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]}
if len(flags['Rod']) > 1:
return
if len(data) > 0x140 + 0x20 + 0x0E + 0x01:
flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:]
else:
flags["DexSanityFlag"] = [0] * 19
for flag_type, loc_map in location_map.items():
for flag, loc_id in loc_map.items():
@@ -207,6 +213,16 @@ async def gb_sync_task(ctx: GBContext):
async_start(parse_locations(data_decoded['locations'], ctx))
if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags:
await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!")
if 'options' in data_decoded:
msgs = []
if data_decoded['options'] & 4 and not ctx.sent_release:
ctx.sent_release = True
msgs.append({"cmd": "Say", "text": "!release"})
if data_decoded['options'] & 8 and not ctx.sent_collect:
ctx.sent_collect = True
msgs.append({"cmd": "Say", "text": "!collect"})
if msgs:
await ctx.send_msgs(msgs)
if ctx.set_deathlink:
await ctx.update_death_link(True)
except asyncio.TimeoutError:

View File

@@ -34,6 +34,15 @@ Currently, the following games are supported:
* Overcooked! 2
* Zillion
* Lufia II Ancient Cave
* Blasphemous
* Wargroove
* Stardew Valley
* The Legend of Zelda
* The Messenger
* Kingdom Hearts 2
* The Legend of Zelda: Link's Awakening DX
* Clique
* Adventure
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

View File

@@ -115,8 +115,8 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
class SNIContext(CommonContext):
command_processor: typing.Type[SNIClientCommandProcessor] = SNIClientCommandProcessor
game = None # set in validate_rom
items_handling = None # set in game_watcher
game: typing.Optional[str] = None # set in validate_rom
items_handling: typing.Optional[int] = None # set in game_watcher
snes_connect_task: "typing.Optional[asyncio.Task[None]]" = None
snes_autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None

View File

@@ -52,9 +52,9 @@ class StarcraftClientProcessor(ClientCommandProcessor):
"""Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal"""
options = difficulty.split()
num_options = len(options)
difficulty_choice = options[0].lower()
if num_options > 0:
difficulty_choice = options[0].lower()
if difficulty_choice == "casual":
self.ctx.difficulty_override = 0
elif difficulty_choice == "normal":
@@ -71,7 +71,11 @@ class StarcraftClientProcessor(ClientCommandProcessor):
return True
else:
self.output("Difficulty needs to be specified in the command.")
if self.ctx.difficulty == -1:
self.output("Please connect to a seed before checking difficulty.")
else:
self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][self.ctx.difficulty])
self.output("To change the difficulty, add the name of the difficulty after the command.")
return False
def _cmd_disable_mission_check(self) -> bool:

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import json
import typing
import builtins
import os
@@ -38,7 +39,7 @@ class Version(typing.NamedTuple):
build: int
__version__ = "0.3.9"
__version__ = "0.4.0"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -87,7 +88,10 @@ def is_frozen() -> bool:
def local_path(*path: str) -> str:
"""Returns path to a file in the local Archipelago installation or source."""
"""
Returns path to a file in the local Archipelago installation or source.
This might be read-only and user_path should be used instead for ROMs, configuration, etc.
"""
if hasattr(local_path, 'cached_path'):
pass
elif is_frozen():
@@ -142,6 +146,17 @@ def user_path(*path: str) -> str:
return os.path.join(user_path.cached_path, *path)
def cache_path(*path: str) -> str:
"""Returns path to a file in the user's Archipelago cache directory."""
if hasattr(cache_path, "cached_path"):
pass
else:
import platformdirs
cache_path.cached_path = platformdirs.user_cache_dir("Archipelago", False)
return os.path.join(cache_path.cached_path, *path)
def output_path(*path: str) -> str:
if hasattr(output_path, 'cached_path'):
return os.path.join(output_path.cached_path, *path)
@@ -195,11 +210,11 @@ def get_public_ipv4() -> str:
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx).read().decode("utf8").strip()
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip()
except Exception as e:
# noinspection PyBroadException
try:
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip()
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
except Exception:
logging.exception(e)
pass # we could be offline, in a local game, so no point in erroring out
@@ -213,7 +228,7 @@ def get_public_ipv6() -> str:
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx).read().decode("utf8").strip()
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
except Exception as e:
logging.exception(e)
pass # we could be offline, in a local game, or ipv6 may not be available
@@ -248,6 +263,9 @@ def get_default_options() -> OptionsType:
"lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
},
"ladx_options": {
"rom_file": "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc",
},
"server_options": {
"host": None,
"port": 38281,
@@ -310,9 +328,20 @@ def get_default_options() -> OptionsType:
"lufia2ac_options": {
"rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc",
},
"tloz_options": {
"rom_file": "Legend of Zelda, The (U) (PRG0) [!].nes",
"rom_start": True,
"display_msgs": True,
},
"wargroove_options": {
"root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove"
}
},
"adventure_options": {
"rom_file": "ADVNTURE.BIN",
"display_msgs": True,
"rom_start": True,
"rom_args": ""
},
}
return options
@@ -380,6 +409,45 @@ def persistent_load() -> typing.Dict[str, dict]:
return storage
def get_file_safe_name(name: str) -> str:
return "".join(c for c in name if c not in '<>:"/\\|?*')
def load_data_package_for_checksum(game: str, checksum: typing.Optional[str]) -> Dict[str, Any]:
if checksum and game:
if checksum != get_file_safe_name(checksum):
raise ValueError(f"Bad symbols in checksum: {checksum}")
path = cache_path("datapackage", get_file_safe_name(game), f"{checksum}.json")
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8-sig") as f:
return json.load(f)
except Exception as e:
logging.debug(f"Could not load data package: {e}")
# fall back to old cache
cache = persistent_load().get("datapackage", {}).get("games", {}).get(game, {})
if cache.get("checksum") == checksum:
return cache
# cache does not match
return {}
def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> None:
checksum = data.get("checksum")
if checksum and game:
if checksum != get_file_safe_name(checksum):
raise ValueError(f"Bad symbols in checksum: {checksum}")
game_folder = cache_path("datapackage", get_file_safe_name(game))
os.makedirs(game_folder, exist_ok=True)
try:
with open(os.path.join(game_folder, f"{checksum}.json"), "w", encoding="utf-8-sig") as f:
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
except Exception as e:
logging.debug(f"Could not store data package: {e}")
def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]:
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
return adjuster_settings

View File

@@ -1,4 +1,6 @@
from __future__ import annotations
import atexit
import os
import sys
import asyncio
@@ -78,17 +80,18 @@ class WargrooveContext(CommonContext):
# self.game_communication_path: files go in this path to pass data between us and the actual game
if "appdata" in os.environ:
options = Utils.get_options()
root_directory = options["wargroove_options"]["root_directory"].replace("/", "\\")
data_directory = "lib\\worlds\\wargroove\\data\\"
dev_data_directory = "worlds\\wargroove\\data\\"
appdata_wargroove = os.path.expandvars("%APPDATA%\\Chucklefish\\Wargroove\\")
if not os.path.isfile(root_directory + "\\win64_bin\\wargroove64.exe"):
root_directory = os.path.join(options["wargroove_options"]["root_directory"])
data_directory = os.path.join("lib", "worlds", "wargroove", "data")
dev_data_directory = os.path.join("worlds", "wargroove", "data")
appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
"Unable to infer required game_communication_path")
self.game_communication_path = root_directory + "\\AP"
self.game_communication_path = os.path.join(root_directory, "AP")
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
self.remove_communication_files()
atexit.register(self.remove_communication_files)
if not os.path.isdir(appdata_wargroove):
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
"Boot Wargroove and then close it to attempt to fix this error")
@@ -109,10 +112,7 @@ class WargrooveContext(CommonContext):
async def connection_closed(self):
await super(WargrooveContext, self).connection_closed()
for root, dirs, files in os.walk(self.game_communication_path):
for file in files:
if file.find("obtain") <= -1:
os.remove(root + "/" + file)
self.remove_communication_files()
@property
def endpoints(self):
@@ -123,10 +123,12 @@ class WargrooveContext(CommonContext):
async def shutdown(self):
await super(WargrooveContext, self).shutdown()
self.remove_communication_files()
def remove_communication_files(self):
for root, dirs, files in os.walk(self.game_communication_path):
for file in files:
if file.find("obtain") <= -1:
os.remove(root+"/"+file)
os.remove(root + "/" + file)
def on_package(self, cmd: str, args: dict):
if cmd in {"Connected"}:

View File

@@ -33,6 +33,11 @@ def get_app():
import yaml
app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}")
if not app.config["HOST_ADDRESS"]:
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)
return app

View File

@@ -51,7 +51,7 @@ app.config["PONY"] = {
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
app.config["JSON_AS_ASCII"] = False
app.config["PATCH_TARGET"] = "archipelago.gg"
app.config["HOST_ADDRESS"] = ""
cache = Cache(app)
Compress(app)

View File

@@ -39,12 +39,21 @@ def get_datapackage():
@api_endpoints.route('/datapackage_version')
@cache.cached()
def get_datapackage_versions():
from worlds import network_data_package, AutoWorldRegister
from worlds import AutoWorldRegister
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
return version_package
@api_endpoints.route('/datapackage_checksum')
@cache.cached()
def get_datapackage_checksums():
from worlds import network_data_package
version_package = {
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
}
return version_package
from . import generate, user # trigger registration

View File

@@ -179,6 +179,7 @@ class MultiworldInstance():
self.ponyconfig = config["PONY"]
self.cert = config["SELFLAUNCHCERT"]
self.key = config["SELFLAUNCHKEY"]
self.host = config["HOST_ADDRESS"]
def start(self):
if self.process and self.process.is_alive():
@@ -187,7 +188,7 @@ class MultiworldInstance():
logging.info(f"Spinning up {self.room_id}")
process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.room_id, self.ponyconfig, get_static_server_data(),
self.cert, self.key),
self.cert, self.key, self.host),
name="MultiHost")
process.start()
# bind after start to prevent thread sync issues with guardian.

View File

@@ -19,7 +19,7 @@ import Utils
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
from .models import Room, Command, db
from .models import Command, GameDataPackage, Room, db
class CustomClientMessageProcessor(ClientMessageProcessor):
@@ -92,7 +92,21 @@ class WebHostContext(Context):
else:
self.port = get_random_port()
return self._load(self.decompress(room.seed.multidata), True)
multidata = self.decompress(room.seed.multidata)
game_data_packages = {}
for game in list(multidata.get("datapackage", {})):
game_data = multidata["datapackage"][game]
if "checksum" in game_data:
if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
# non-custom. remove from multidata
# games package could be dropped from static data once all rooms embed data package
del multidata["datapackage"][game]
else:
row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
game_data_packages[game] = Utils.restricted_loads(row.data)
return self._load(multidata, game_data_packages, True)
@db_session
def init_save(self, enabled: bool = True):
@@ -131,6 +145,8 @@ def get_static_server_data() -> dict:
"gamespackage": worlds.network_data_package["games"],
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
@@ -140,7 +156,8 @@ def get_static_server_data() -> dict:
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str]):
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str):
# establish DB connection for multidata and multisave
db.bind(**ponyconfig)
db.generate_mapping(check_tables=False)
@@ -165,17 +182,18 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6:
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
# Prefer IPv4, as most users seem to not have working ipv6 support
if not port:
port = socketname[1]
elif wssocket.family == socket.AF_INET:
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
port = socketname[1]
if port:
logging.info(f'Hosting game at {host}:{port}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
else:
logging.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
@@ -186,6 +204,11 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
with Locker(room_id):
try:
asyncio.run(main())
except KeyboardInterrupt:
with db_session:
room = Room.get(id=room_id)
# ensure the Room does not spin up again on its own, minute of safety buffer
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
except:
with db_session:
room = Room.get(id=room_id)

View File

@@ -26,7 +26,7 @@ def download_patch(room_id, patch_id):
with zipfile.ZipFile(filelike, "a") as zf:
with zf.open("archipelago.json", "r") as f:
manifest = json.load(f)
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None
manifest["server"] = f"{app.config['HOST_ADDRESS']}:{last_port}" if last_port else None
with zipfile.ZipFile(new_file, "w") as new_zip:
for file in zf.infolist():
if file.filename == "archipelago.json":
@@ -64,7 +64,7 @@ def download_slot_file(room_id, player_id: int):
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['PATCH_TARGET'], port=room.last_port)
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:
@@ -88,6 +88,8 @@ def download_slot_file(room_id, player_id: int):
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
elif slot_data.game == "Dark Souls III":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
elif slot_data.game == "Kingdom Hearts 2":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
else:
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)

View File

@@ -56,3 +56,8 @@ class Generation(db.Entity):
options = Required(buffer, lazy=True)
meta = Required(LongStr, default=lambda: "{\"race\": false}")
state = Required(int, default=0, index=True)
class GameDataPackage(db.Entity):
checksum = PrimaryKey(str)
data = Required(bytes)

View File

@@ -11,7 +11,7 @@ from Utils import __version__, local_path
from worlds.AutoWorld import AutoWorldRegister
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
"exclude_locations"}
"exclude_locations", "priority_locations"}
def create():
@@ -88,7 +88,7 @@ def create():
if option_name in handled_in_js:
pass
elif option.options:
elif issubclass(option, Options.Choice) or issubclass(option, Options.Toggle):
game_options[option_name] = this_option = {
"type": "select",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
@@ -98,15 +98,15 @@ def create():
}
for sub_option_id, sub_option_name in option.name_lookup.items():
this_option["options"].append({
"name": option.get_option_name(sub_option_id),
"value": sub_option_name,
})
if sub_option_name != "random":
this_option["options"].append({
"name": option.get_option_name(sub_option_id),
"value": sub_option_name,
})
if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name
if option.default == "random":
if not this_option["defaultValue"]:
this_option["defaultValue"] = "random"
elif issubclass(option, Options.Range):
@@ -126,21 +126,21 @@ def create():
for key, val in option.special_range_names.items():
game_options[option_name]["value_names"][key] = val
elif getattr(option, "verify_item_name", False):
elif issubclass(option, Options.ItemSet):
game_options[option_name] = {
"type": "items-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
}
elif getattr(option, "verify_location_name", False):
elif issubclass(option, Options.LocationSet):
game_options[option_name] = {
"type": "locations-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
}
elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet):
elif issubclass(option, Options.VerifyKeys):
if option.valid_keys:
game_options[option_name] = {
"type": "custom-list",
@@ -160,6 +160,14 @@ def create():
json.dump(player_settings, f, indent=2, separators=(',', ': '))
if not world.hidden and world.web.settings_page is True:
# Add the random option to Choice, TextChoice, and Toggle settings
for option in game_options.values():
if option["type"] == "select":
option["options"].append({"name": "Random", "value": "random"})
if not option["defaultValue"]:
option["defaultValue"] = "random"
weighted_settings["baseOptions"]["game"][game_name] = 0
weighted_settings["games"][game_name] = {}
weighted_settings["games"][game_name]["gameSettings"] = game_options

View File

@@ -1,7 +1,7 @@
flask>=2.2.2
flask>=2.2.3
pony>=0.7.16
waitress>=2.1.2
Flask-Caching>=2.0.1
Flask-Caching>=2.0.2
Flask-Compress>=1.13
Flask-Limiter>=2.8.1
bokeh>=3.0.2
Flask-Limiter>=3.3.0
bokeh>=3.1.0

View File

@@ -0,0 +1,40 @@
window.addEventListener('load', () => {
// Mobile menu handling
const menuButton = document.getElementById('base-header-mobile-menu-button');
const mobileMenu = document.getElementById('base-header-mobile-menu');
menuButton.addEventListener('click', (evt) => {
evt.preventDefault();
evt.stopPropagation();
if (!mobileMenu.style.display || mobileMenu.style.display === 'none') {
return mobileMenu.style.display = 'flex';
}
mobileMenu.style.display = 'none';
});
window.addEventListener('resize', () => {
mobileMenu.style.display = 'none';
});
// Popover handling
const popoverText = document.getElementById('base-header-popover-text');
const popoverMenu = document.getElementById('base-header-popover-menu');
popoverText.addEventListener('click', (evt) => {
evt.preventDefault();
evt.stopPropagation();
if (!popoverMenu.style.display || popoverMenu.style.display === 'none') {
return popoverMenu.style.display = 'flex';
}
popoverMenu.style.display = 'none';
});
document.body.addEventListener('click', () => {
mobileMenu.style.display = 'none';
popoverMenu.style.display = 'none';
});
});

View File

@@ -0,0 +1,49 @@
window.addEventListener('load', () => {
// Reload tracker every 60 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();
}, 60000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let i = 0; i < categories.length; i++) {
let hide_id = categories[i].id.split('-')[0];
if (hide_id == 'Total') {
continue;
}
categories[i].addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'-header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
});

View File

@@ -0,0 +1,6 @@
window.addEventListener('load', () => {
$(".table-wrapper").scrollsync({
y_sync: true,
x_sync: true
});
});

View File

@@ -1,5 +1,7 @@
const adjustTableHeight = () => {
const tablesContainer = document.getElementById('tables-container');
if (!tablesContainer)
return;
const upperDistance = tablesContainer.getBoundingClientRect().top;
const containerHeight = window.innerHeight - upperDistance;
@@ -108,7 +110,7 @@ window.addEventListener('load', () => {
const update = () => {
const target = $("<div></div>");
console.log("Updating Tracker...");
target.load("/tracker/" + tracker, function (response, status) {
target.load(location.href, function (response, status) {
if (status === "success") {
target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr");
@@ -135,10 +137,5 @@ window.addEventListener('load', () => {
tables.draw();
});
$(".table-wrapper").scrollsync({
y_sync: true,
x_sync: true
});
adjustTableHeight();
});

View File

@@ -78,8 +78,6 @@ const createDefaultSettings = (settingData) => {
break;
case 'range':
case 'special_range':
newSettings[game][gameSetting][setting.min] = 0;
newSettings[game][gameSetting][setting.max] = 0;
newSettings[game][gameSetting]['random'] = 0;
newSettings[game][gameSetting]['random-low'] = 0;
newSettings[game][gameSetting]['random-high'] = 0;
@@ -103,6 +101,7 @@ const createDefaultSettings = (settingData) => {
newSettings[game].start_inventory = {};
newSettings[game].exclude_locations = [];
newSettings[game].priority_locations = [];
newSettings[game].local_items = [];
newSettings[game].non_local_items = [];
newSettings[game].start_hints = [];
@@ -138,21 +137,28 @@ const buildUI = (settingData) => {
expandButton.classList.add('invisible');
gameDiv.appendChild(expandButton);
const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings);
settingData.games[game].gameItems.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0)));
settingData.games[game].gameLocations.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0)));
const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings,
settingData.games[game].gameItems, settingData.games[game].gameLocations);
gameDiv.appendChild(weightedSettingsDiv);
const itemsDiv = buildItemsDiv(game, settingData.games[game].gameItems);
gameDiv.appendChild(itemsDiv);
const itemPoolDiv = buildItemsDiv(game, settingData.games[game].gameItems);
gameDiv.appendChild(itemPoolDiv);
const hintsDiv = buildHintsDiv(game, settingData.games[game].gameItems, settingData.games[game].gameLocations);
gameDiv.appendChild(hintsDiv);
const locationsDiv = buildLocationsDiv(game, settingData.games[game].gameLocations);
gameDiv.appendChild(locationsDiv);
gamesWrapper.appendChild(gameDiv);
collapseButton.addEventListener('click', () => {
collapseButton.classList.add('invisible');
weightedSettingsDiv.classList.add('invisible');
itemsDiv.classList.add('invisible');
itemPoolDiv.classList.add('invisible');
hintsDiv.classList.add('invisible');
expandButton.classList.remove('invisible');
});
@@ -160,7 +166,7 @@ const buildUI = (settingData) => {
expandButton.addEventListener('click', () => {
collapseButton.classList.remove('invisible');
weightedSettingsDiv.classList.remove('invisible');
itemsDiv.classList.remove('invisible');
itemPoolDiv.classList.remove('invisible');
hintsDiv.classList.remove('invisible');
expandButton.classList.add('invisible');
});
@@ -228,7 +234,7 @@ const buildGameChoice = (games) => {
gameChoiceDiv.appendChild(table);
};
const buildWeightedSettingsDiv = (game, settings) => {
const buildWeightedSettingsDiv = (game, settings, gameItems, gameLocations) => {
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
const settingsWrapper = document.createElement('div');
settingsWrapper.classList.add('settings-wrapper');
@@ -270,7 +276,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
range.setAttribute('data-type', setting.type);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateGameSetting);
range.addEventListener('change', updateRangeSetting);
range.value = currentSettings[game][settingName][option.value];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
@@ -296,33 +302,33 @@ const buildWeightedSettingsDiv = (game, settings) => {
if (((setting.max - setting.min) + 1) < 11) {
for (let i=setting.min; i <= setting.max; ++i) {
const tr = document.createElement('tr');
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = i;
tr.appendChild(tdLeft);
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = i;
tr.appendChild(tdLeft);
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('id', `${game}-${settingName}-${i}-range`);
range.setAttribute('data-game', game);
range.setAttribute('data-setting', settingName);
range.setAttribute('data-option', i);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateGameSetting);
range.value = currentSettings[game][settingName][i];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('id', `${game}-${settingName}-${i}-range`);
range.setAttribute('data-game', game);
range.setAttribute('data-setting', settingName);
range.setAttribute('data-option', i);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateRangeSetting);
range.value = currentSettings[game][settingName][i] || 0;
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
tdRight.setAttribute('id', `${game}-${settingName}-${i}`)
tdRight.classList.add('td-right');
tdRight.innerText = range.value;
tr.appendChild(tdRight);
const tdRight = document.createElement('td');
tdRight.setAttribute('id', `${game}-${settingName}-${i}`)
tdRight.classList.add('td-right');
tdRight.innerText = range.value;
tr.appendChild(tdRight);
rangeTbody.appendChild(tr);
rangeTbody.appendChild(tr);
}
} else {
const hintText = document.createElement('p');
@@ -379,7 +385,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
range.setAttribute('data-option', option);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateGameSetting);
range.addEventListener('change', updateRangeSetting);
range.value = currentSettings[game][settingName][parseInt(option, 10)];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
@@ -430,7 +436,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
range.setAttribute('data-option', option);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateGameSetting);
range.addEventListener('change', updateRangeSetting);
range.value = currentSettings[game][settingName][parseInt(option, 10)];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
@@ -464,7 +470,17 @@ const buildWeightedSettingsDiv = (game, settings) => {
const tr = document.createElement('tr');
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = option;
switch(option){
case 'random':
tdLeft.innerText = 'Random';
break;
case 'random-low':
tdLeft.innerText = "Random (Low)";
break;
case 'random-high':
tdLeft.innerText = "Random (High)";
break;
}
tr.appendChild(tdLeft);
const tdMiddle = document.createElement('td');
@@ -477,7 +493,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
range.setAttribute('data-option', option);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateGameSetting);
range.addEventListener('change', updateRangeSetting);
range.value = currentSettings[game][settingName][option];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
@@ -495,15 +511,108 @@ const buildWeightedSettingsDiv = (game, settings) => {
break;
case 'items-list':
// TODO
const itemsList = document.createElement('div');
itemsList.classList.add('simple-list');
Object.values(gameItems).forEach((item) => {
const itemRow = document.createElement('div');
itemRow.classList.add('list-row');
const itemLabel = document.createElement('label');
itemLabel.setAttribute('for', `${game}-${settingName}-${item}`)
const itemCheckbox = document.createElement('input');
itemCheckbox.setAttribute('id', `${game}-${settingName}-${item}`);
itemCheckbox.setAttribute('type', 'checkbox');
itemCheckbox.setAttribute('data-game', game);
itemCheckbox.setAttribute('data-setting', settingName);
itemCheckbox.setAttribute('data-option', item.toString());
itemCheckbox.addEventListener('change', updateListSetting);
if (currentSettings[game][settingName].includes(item)) {
itemCheckbox.setAttribute('checked', '1');
}
const itemName = document.createElement('span');
itemName.innerText = item.toString();
itemLabel.appendChild(itemCheckbox);
itemLabel.appendChild(itemName);
itemRow.appendChild(itemLabel);
itemsList.appendChild((itemRow));
});
settingWrapper.appendChild(itemsList);
break;
case 'locations-list':
// TODO
const locationsList = document.createElement('div');
locationsList.classList.add('simple-list');
Object.values(gameLocations).forEach((location) => {
const locationRow = document.createElement('div');
locationRow.classList.add('list-row');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${game}-${settingName}-${location}`)
const locationCheckbox = document.createElement('input');
locationCheckbox.setAttribute('id', `${game}-${settingName}-${location}`);
locationCheckbox.setAttribute('type', 'checkbox');
locationCheckbox.setAttribute('data-game', game);
locationCheckbox.setAttribute('data-setting', settingName);
locationCheckbox.setAttribute('data-option', location.toString());
locationCheckbox.addEventListener('change', updateListSetting);
if (currentSettings[game][settingName].includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
const locationName = document.createElement('span');
locationName.innerText = location.toString();
locationLabel.appendChild(locationCheckbox);
locationLabel.appendChild(locationName);
locationRow.appendChild(locationLabel);
locationsList.appendChild((locationRow));
});
settingWrapper.appendChild(locationsList);
break;
case 'custom-list':
// TODO
const customList = document.createElement('div');
customList.classList.add('simple-list');
Object.values(settings[settingName].options).forEach((listItem) => {
const customListRow = document.createElement('div');
customListRow.classList.add('list-row');
const customItemLabel = document.createElement('label');
customItemLabel.setAttribute('for', `${game}-${settingName}-${listItem}`)
const customItemCheckbox = document.createElement('input');
customItemCheckbox.setAttribute('id', `${game}-${settingName}-${listItem}`);
customItemCheckbox.setAttribute('type', 'checkbox');
customItemCheckbox.setAttribute('data-game', game);
customItemCheckbox.setAttribute('data-setting', settingName);
customItemCheckbox.setAttribute('data-option', listItem.toString());
customItemCheckbox.addEventListener('change', updateListSetting);
if (currentSettings[game][settingName].includes(listItem)) {
customItemCheckbox.setAttribute('checked', '1');
}
const customItemName = document.createElement('span');
customItemName.innerText = listItem.toString();
customItemLabel.appendChild(customItemCheckbox);
customItemLabel.appendChild(customItemName);
customListRow.appendChild(customItemLabel);
customList.appendChild((customListRow));
});
settingWrapper.appendChild(customList);
break;
default:
@@ -729,21 +838,22 @@ const buildHintsDiv = (game, items, locations) => {
const hintsDescription = document.createElement('p');
hintsDescription.classList.add('setting-description');
hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' +
' items are, or what those locations contain. Excluded locations will not contain progression items.';
' items are, or what those locations contain.';
hintsDiv.appendChild(hintsDescription);
const itemHintsContainer = document.createElement('div');
itemHintsContainer.classList.add('hints-container');
// Item Hints
const itemHintsWrapper = document.createElement('div');
itemHintsWrapper.classList.add('hints-wrapper');
itemHintsWrapper.innerText = 'Starting Item Hints';
const itemHintsDiv = document.createElement('div');
itemHintsDiv.classList.add('item-container');
itemHintsDiv.classList.add('simple-list');
items.forEach((item) => {
const itemDiv = document.createElement('div');
itemDiv.classList.add('hint-div');
const itemRow = document.createElement('div');
itemRow.classList.add('list-row');
const itemLabel = document.createElement('label');
itemLabel.setAttribute('for', `${game}-start_hints-${item}`);
@@ -757,29 +867,30 @@ const buildHintsDiv = (game, items, locations) => {
if (currentSettings[game].start_hints.includes(item)) {
itemCheckbox.setAttribute('checked', 'true');
}
itemCheckbox.addEventListener('change', hintChangeHandler);
itemCheckbox.addEventListener('change', updateListSetting);
itemLabel.appendChild(itemCheckbox);
const itemName = document.createElement('span');
itemName.innerText = item;
itemLabel.appendChild(itemName);
itemDiv.appendChild(itemLabel);
itemHintsDiv.appendChild(itemDiv);
itemRow.appendChild(itemLabel);
itemHintsDiv.appendChild(itemRow);
});
itemHintsWrapper.appendChild(itemHintsDiv);
itemHintsContainer.appendChild(itemHintsWrapper);
// Starting Location Hints
const locationHintsWrapper = document.createElement('div');
locationHintsWrapper.classList.add('hints-wrapper');
locationHintsWrapper.innerText = 'Starting Location Hints';
const locationHintsDiv = document.createElement('div');
locationHintsDiv.classList.add('item-container');
locationHintsDiv.classList.add('simple-list');
locations.forEach((location) => {
const locationDiv = document.createElement('div');
locationDiv.classList.add('hint-div');
const locationRow = document.createElement('div');
locationRow.classList.add('list-row');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${game}-start_location_hints-${location}`);
@@ -793,29 +904,89 @@ const buildHintsDiv = (game, items, locations) => {
if (currentSettings[game].start_location_hints.includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
locationCheckbox.addEventListener('change', hintChangeHandler);
locationCheckbox.addEventListener('change', updateListSetting);
locationLabel.appendChild(locationCheckbox);
const locationName = document.createElement('span');
locationName.innerText = location;
locationLabel.appendChild(locationName);
locationDiv.appendChild(locationLabel);
locationHintsDiv.appendChild(locationDiv);
locationRow.appendChild(locationLabel);
locationHintsDiv.appendChild(locationRow);
});
locationHintsWrapper.appendChild(locationHintsDiv);
itemHintsContainer.appendChild(locationHintsWrapper);
hintsDiv.appendChild(itemHintsContainer);
return hintsDiv;
};
const buildLocationsDiv = (game, locations) => {
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
locations.sort(); // Sort alphabetical, in-place
const locationsDiv = document.createElement('div');
locationsDiv.classList.add('locations-div');
const locationsHeader = document.createElement('h3');
locationsHeader.innerText = 'Priority & Exclusion Locations';
locationsDiv.appendChild(locationsHeader);
const locationsDescription = document.createElement('p');
locationsDescription.classList.add('setting-description');
locationsDescription.innerText = 'Priority locations guarantee a progression item will be placed there while ' +
'excluded locations will not contain progression or useful items.';
locationsDiv.appendChild(locationsDescription);
const locationsContainer = document.createElement('div');
locationsContainer.classList.add('locations-container');
// Priority Locations
const priorityLocationsWrapper = document.createElement('div');
priorityLocationsWrapper.classList.add('locations-wrapper');
priorityLocationsWrapper.innerText = 'Priority Locations';
const priorityLocationsDiv = document.createElement('div');
priorityLocationsDiv.classList.add('simple-list');
locations.forEach((location) => {
const locationRow = document.createElement('div');
locationRow.classList.add('list-row');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${game}-priority_locations-${location}`);
const locationCheckbox = document.createElement('input');
locationCheckbox.setAttribute('type', 'checkbox');
locationCheckbox.setAttribute('id', `${game}-priority_locations-${location}`);
locationCheckbox.setAttribute('data-game', game);
locationCheckbox.setAttribute('data-setting', 'priority_locations');
locationCheckbox.setAttribute('data-option', location);
if (currentSettings[game].priority_locations.includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
locationCheckbox.addEventListener('change', updateListSetting);
locationLabel.appendChild(locationCheckbox);
const locationName = document.createElement('span');
locationName.innerText = location;
locationLabel.appendChild(locationName);
locationRow.appendChild(locationLabel);
priorityLocationsDiv.appendChild(locationRow);
});
priorityLocationsWrapper.appendChild(priorityLocationsDiv);
locationsContainer.appendChild(priorityLocationsWrapper);
// Exclude Locations
const excludeLocationsWrapper = document.createElement('div');
excludeLocationsWrapper.classList.add('hints-wrapper');
excludeLocationsWrapper.classList.add('locations-wrapper');
excludeLocationsWrapper.innerText = 'Exclude Locations';
const excludeLocationsDiv = document.createElement('div');
excludeLocationsDiv.classList.add('item-container');
excludeLocationsDiv.classList.add('simple-list');
locations.forEach((location) => {
const locationDiv = document.createElement('div');
locationDiv.classList.add('hint-div');
const locationRow = document.createElement('div');
locationRow.classList.add('list-row');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${game}-exclude_locations-${location}`);
@@ -829,40 +1000,22 @@ const buildHintsDiv = (game, items, locations) => {
if (currentSettings[game].exclude_locations.includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
locationCheckbox.addEventListener('change', hintChangeHandler);
locationCheckbox.addEventListener('change', updateListSetting);
locationLabel.appendChild(locationCheckbox);
const locationName = document.createElement('span');
locationName.innerText = location;
locationLabel.appendChild(locationName);
locationDiv.appendChild(locationLabel);
excludeLocationsDiv.appendChild(locationDiv);
locationRow.appendChild(locationLabel);
excludeLocationsDiv.appendChild(locationRow);
});
excludeLocationsWrapper.appendChild(excludeLocationsDiv);
itemHintsContainer.appendChild(excludeLocationsWrapper);
locationsContainer.appendChild(excludeLocationsWrapper);
hintsDiv.appendChild(itemHintsContainer);
return hintsDiv;
};
const hintChangeHandler = (evt) => {
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
const game = evt.target.getAttribute('data-game');
const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option');
if (evt.target.checked) {
if (!currentSettings[game][setting].includes(option)) {
currentSettings[game][setting].push(option);
}
} else {
if (currentSettings[game][setting].includes(option)) {
currentSettings[game][setting].splice(currentSettings[game][setting].indexOf(option), 1);
}
}
localStorage.setItem('weighted-settings', JSON.stringify(currentSettings));
locationsDiv.appendChild(locationsContainer);
return locationsDiv;
};
const updateVisibleGames = () => {
@@ -908,13 +1061,12 @@ const updateBaseSetting = (event) => {
localStorage.setItem('weighted-settings', JSON.stringify(settings));
};
const updateGameSetting = (evt) => {
const updateRangeSetting = (evt) => {
const options = JSON.parse(localStorage.getItem('weighted-settings'));
const game = evt.target.getAttribute('data-game');
const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option');
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
console.log(event);
if (evt.action && evt.action === 'rangeDelete') {
delete options[game][setting][option];
} else {
@@ -923,6 +1075,26 @@ const updateGameSetting = (evt) => {
localStorage.setItem('weighted-settings', JSON.stringify(options));
};
const updateListSetting = (evt) => {
const options = JSON.parse(localStorage.getItem('weighted-settings'));
const game = evt.target.getAttribute('data-game');
const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option');
if (evt.target.checked) {
// If the option is to be enabled and it is already enabled, do nothing
if (options[game][setting].includes(option)) { return; }
options[game][setting].push(option);
} else {
// If the option is to be disabled and it is already disabled, do nothing
if (!options[game][setting].includes(option)) { return; }
options[game][setting].splice(options[game][setting].indexOf(option), 1);
}
localStorage.setItem('weighted-settings', JSON.stringify(options));
};
const updateItemSetting = (evt) => {
const options = JSON.parse(localStorage.getItem('weighted-settings'));
const game = evt.target.getAttribute('data-game');

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@@ -0,0 +1,30 @@
#player-tracker-wrapper{
margin: 0;
}
#inventory-table{
padding: 8px 10px 2px 6px;
background-color: #42b149;
border-radius: 4px;
border: 2px solid black;
}
#inventory-table tr.column-headers td {
font-size: 1rem;
padding: 0 5rem 0 0;
}
#inventory-table td{
padding: 0 0.5rem 0.5rem;
font-family: LexendDeca-Light, monospace;
font-size: 2.5rem;
color: #ffffff;
}
#inventory-table td img{
vertical-align: middle;
}
.hide {
display: none;
}

View File

@@ -15,3 +15,33 @@
padding-left: 0.5rem;
color: #dfedc6;
}
@media all and (max-width: 900px) {
#island-footer{
font-size: 17px;
font-size: 2vw;
}
}
@media all and (max-width: 768px) {
#island-footer{
font-size: 15px;
font-size: 2vw;
}
}
@media all and (max-width: 650px) {
#island-footer{
font-size: 13px;
font-size: 2vw;
}
}
@media all and (max-width: 580px) {
#island-footer{
font-size: 11px;
font-size: 2vw;
}
}
@media all and (max-width: 512px) {
#island-footer{
font-size: 9px;
font-size: 2vw;
}
}

View File

@@ -21,7 +21,6 @@ html{
margin-right: auto;
margin-top: 10px;
height: 140px;
z-index: 10;
}
#landing-header h4{
@@ -223,7 +222,7 @@ html{
}
#landing{
width: 700px;
max-width: 700px;
min-height: 280px;
margin-left: auto;
margin-right: auto;

View File

@@ -30,6 +30,8 @@ html{
}
#base-header-right{
display: flex;
flex-direction: row;
margin-top: 4px;
}
@@ -42,7 +44,7 @@ html{
margin-top: 4px;
}
#base-header a{
#base-header a, #base-header-mobile-menu a, #base-header-popover-text{
color: #2f6b83;
text-decoration: none;
cursor: pointer;
@@ -51,3 +53,126 @@ html{
font-family: LondrinaSolid-Light, sans-serif;
text-transform: uppercase;
}
#base-header-right-mobile{
display: none;
margin-top: 2rem;
margin-right: 1rem;
}
#base-header-mobile-menu{
display: none;
flex-direction: column;
background-color: #ffffff;
text-align: center;
overflow-y: auto;
z-index: 10000;
width: 100vw;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
position: absolute;
top: 7rem;
right: 0;
}
#base-header-mobile-menu a{
padding: 3rem 1.5rem;
font-size: 4rem;
line-height: 5rem;
color: #699ca8;
border-top: 1px solid #d3d3d3;
}
#base-header-mobile-menu :first-child, #base-header-popover-menu :first-child{
border-top: none;
}
#base-header-right-mobile img{
height: 3rem;
}
#base-header-popover-menu{
display: none;
flex-direction: column;
position: absolute;
background-color: #fff;
margin-left: -108px;
margin-top: 2.25rem;
border-radius: 10px;
border-left: 2px solid #d0ebe6;
border-bottom: 2px solid #d0ebe6;
border-right: 1px solid #d0ebe6;
filter: drop-shadow(-6px 6px 2px #2e3e83);
}
#base-header-popover-menu a{
color: #699ca8;
border-top: 1px solid #d3d3d3;
text-align: center;
font-size: 1.5rem;
line-height: 3rem;
margin-right: 2px;
padding: 0.25rem 1rem;
}
#base-header-popover-icon {
width: 14px;
margin-bottom: 3px;
margin-left: 2px;
}
@media all and (max-width: 960px), only screen and (max-device-width: 768px) {
#base-header-right{
display: none;
}
#base-header-right-mobile{
display: unset;
}
}
@media all and (max-width: 960px){
#base-header-right-mobile{
margin-top: 0.5rem;
margin-right: 0;
}
#base-header-right-mobile img{
height: 1.5rem;
}
#base-header-mobile-menu{
top: 3.3rem;
width: unset;
border-left: 2px solid #d0ebe6;
border-bottom: 2px solid #d0ebe6;
filter: drop-shadow(-6px 6px 2px #2e3e83);
border-top-left-radius: 10px;
}
#base-header-mobile-menu a{
font-size: 1.5rem;
line-height: 3rem;
margin: 0;
padding: 0.25rem 1rem;
}
}
@media only screen and (max-device-width: 768px){
html{
padding-top: 260px;
scroll-padding-top: 230px;
}
#base-header{
height: 200px;
background-size: auto 200px;
}
#base-header #site-title img{
height: calc(38px * 2);
margin-top: 30px;
margin-left: 20px;
}
}

View File

@@ -119,6 +119,33 @@ img.alttp-sprite {
background-color: #d3c97d;
}
#tracker-navigation {
display: inline-flex;
background-color: #b0a77d;
margin: 0.5rem;
border-radius: 4px;
}
.tracker-navigation-button {
display: block;
margin: 4px;
padding-left: 12px;
padding-right: 12px;
border-radius: 4px;
text-align: center;
font-size: 14px;
color: #000;
font-weight: lighter;
}
.tracker-navigation-button:hover {
background-color: #e2eabb !important;
}
.tracker-navigation-button.selected {
background-color: rgb(220, 226, 189);
}
@media all and (max-width: 1700px) {
table.dataTable thead th.upper-row{
position: -webkit-sticky;

View File

@@ -157,41 +157,29 @@ html{
background-color: rgba(0, 0, 0, 0.1);
}
#weighted-settings .hints-div{
#weighted-settings .hints-div, #weighted-settings .locations-div{
margin-top: 2rem;
}
#weighted-settings .hints-div h3{
#weighted-settings .hints-div h3, #weighted-settings .locations-div h3{
margin-bottom: 0.5rem;
}
#weighted-settings .hints-div .hints-container{
#weighted-settings .hints-container, #weighted-settings .locations-container{
display: flex;
flex-direction: row;
justify-content: space-between;
}
#weighted-settings .hints-wrapper, #weighted-settings .locations-wrapper{
width: calc(50% - 0.5rem);
font-weight: bold;
}
#weighted-settings .hints-div .hints-wrapper{
width: 32.5%;
}
#weighted-settings .hints-div .hints-wrapper .hint-div{
display: flex;
flex-direction: row;
cursor: pointer;
user-select: none;
-moz-user-select: none;
}
#weighted-settings .hints-div .hints-wrapper .hint-div:hover{
background-color: rgba(0, 0, 0, 0.1);
}
#weighted-settings .hints-div .hints-wrapper .hint-div label{
flex-grow: 1;
padding: 0.125rem 0.5rem;
cursor: pointer;
#weighted-settings .hints-wrapper .simple-list, #weighted-settings .locations-wrapper .simple-list{
margin-top: 0.25rem;
height: 300px;
font-weight: normal;
}
#weighted-settings #weighted-settings-button-row{
@@ -280,6 +268,30 @@ html{
flex-direction: column;
}
#weighted-settings .simple-list{
display: flex;
flex-direction: column;
max-height: 300px;
overflow-y: auto;
border: 1px solid #ffffff;
border-radius: 4px;
}
#weighted-settings .simple-list .list-row label{
display: block;
width: calc(100% - 0.5rem);
padding: 0.0625rem 0.25rem;
}
#weighted-settings .simple-list .list-row label:hover{
background-color: rgba(0, 0, 0, 0.1);
}
#weighted-settings .simple-list .list-row label input[type=checkbox]{
margin-right: 0.5rem;
}
#weighted-settings .invisible{
display: none;
}

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/checksfinderTracker.css') }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/checksfinderTracker.js') }}"></script>
</head>
<body>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr class="column-headers">
<td colspan="2">Checks Available:</td>
<td colspan="2">Map Bombs:</td>
</tr>
<tr>
<td><img alt="Checks Available" src="{{ icons['Checks Available'] }}" /></td>
<td>{{ checks_available }}</td>
<td><img alt="Bombs Remaining" src="{{ icons['Map Bombs'] }}" /></td>
<td>{{ bombs_display }}/20</td>
</tr>
<tr class="column-headers">
<td colspan="2">Map Width:</td>
<td colspan="2">Map Height:</td>
</tr>
<tr>
<td><img alt="Map Width" src="{{ icons['Map Width'] }}" /></td>
<td>{{ width_display }}/10</td>
<td><img alt="Map Height" src="{{ icons['Map Height'] }}" /></td>
<td>{{ height_display }}/10</td>
</tr>
</table>
</div>
</body>
</html>

View File

@@ -4,7 +4,7 @@
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}

View File

@@ -1,5 +1,6 @@
{% block head %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/themes/base.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/baseHeader.js") }}"></script>
{% endblock %}
{% block header %}
@@ -10,10 +11,32 @@
</a>
</div>
<div id="base-header-right">
<div id="base-header-popover-text">
<img id="base-header-popover-icon" src="/static/static/button-images/popover.png" alt="Popover Menu" />
get started
</div>
<div id="base-header-popover-menu">
<a href="/games">supported games</a>
<a href="/tutorial">setup guides</a>
<a href="/generate">generate game</a>
<a href="/uploads">host game</a>
<a href="/user-content">user content</a>
</div>
<a href="/faq/en">f.a.q</a>
<a href="https://discord.gg/8Z65BR2" target="_blank">discord</a>
</div>
<div id="base-header-right-mobile">
<a id="base-header-mobile-menu-button" href="#">
<img src="/static/static/button-images/hamburger-menu-icon.png" alt="Menu" />
</a>
</div>
<div id="base-header-mobile-menu">
<a href="/games">supported games</a>
<a href="/tutorial">setup guides</a>
<a href="/start-playing">start playing</a>
<a href="/faq/en">f.a.q.</a>
<a href="/generate">generate game</a>
<a href="/uploads">host game</a>
<a href="/user-content">user content</a>
<a href="https://discord.gg/8Z65BR2" target="_blank">discord</a>
</div>
</header>

View File

@@ -14,7 +14,7 @@
<br />
{% endif %}
{% if room.tracker %}
This room has a <a href="{{ url_for("getTracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
<br />
{% endif %}
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
@@ -25,8 +25,8 @@
The most likely failure reason is that the multiworld is too old to be loaded now.
{% elif room.last_port %}
You can connect to this room by using <span class="interactive"
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
</span>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
{% endif %}

View File

@@ -1,14 +1,16 @@
{% extends 'tablepage.html' %}
{% block head %}
{{ super() }}
<title>Multiworld Tracker</title>
<title>ALttP Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttpMultiTracker.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
{% include 'multiTrackerNavigation.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>

View File

@@ -22,7 +22,7 @@
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
<tr>
<td>{{ patch.player_id }}</td>
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['PATCH_TARGET'] }}:{{ room.last_port }}">{{ patch.player_name }}<a/></td>
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
<td>{{ patch.game }}</td>
<td>
{% if patch.game == "Minecraft" %}
@@ -31,6 +31,9 @@
{% elif patch.game == "Factorio" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Factorio Mod...</a>
{% elif patch.game == "Kingdom Hearts 2" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Kingdom Hearts 2 Mod...</a>
{% elif patch.game == "Ocarina of Time" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APZ5 File...</a>

View File

@@ -0,0 +1,44 @@
{% extends "multiTracker.html" %}
{% block custom_table_headers %}
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Logistic_science_pack.png/32px-Logistic_science_pack.png"
alt="Logistic Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Military_science_pack.png/32px-Military_science_pack.png"
alt="Military Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Chemical_science_pack.png/32px-Chemical_science_pack.png"
alt="Chemical Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Production_science_pack.png/32px-Production_science_pack.png"
alt="Production Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Utility_science_pack.png/32px-Utility_science_pack.png"
alt="Utility Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Space_science_pack.png/32px-Space_science_pack.png"
alt="Space Science Pack">
</th>
{% endblock %}
{% block custom_table_row scoped %}
{% if games[player] == "Factorio" %}
<td class="center-column">{% if inventory[team][player][131161] or inventory[team][player][131281] %}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131172] or inventory[team][player][131281] > 1%}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131195] or inventory[team][player][131281] > 2%}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131240] or inventory[team][player][131281] > 3%}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131240] or inventory[team][player][131281] > 4%}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131220] or inventory[team][player][131281] > 5%}✔{% endif %}</td>
{% else %}
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
{% endif %}
{% endblock%}

View File

@@ -0,0 +1,98 @@
{% extends 'tablepage.html' %}
{% block head %}
{{ super() }}
<title>Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
{% include 'multiTrackerNavigation.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
<span{% if not video %} hidden{% endif %} id="multi-stream-link">
<a target="_blank" href="https://multistream.me/
{%- for platform, link in video.values()|unique(False, 1)-%}
{%- if platform == "Twitch" -%}t{%- else -%}yt{%- endif -%}:{{- link -}}/
{%- endfor -%}">
Multistream
</a>
</span>
<span class="info">Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically.</span>
</div>
<div id="tables-container">
{% for team, players in checks_done.items() %}
<div class="table-wrapper">
<table id="checks-table" class="table non-unique-item-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Game</th>
{% block custom_table_headers %}
{# implement this block in game-specific multi trackers #}
{% endblock %}
<th class="center-column">Checks</th>
<th class="center-column">&percnt;</th>
<th class="center-column">Status</th>
<th class="center-column hours">Last<br>Activity</th>
</tr>
</thead>
<tbody>
{%- for player, checks in players.items() -%}
<tr>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
<td>{{ player_names[(team, loop.index)]|e }}</td>
<td>{{ games[player] }}</td>
{% block custom_table_row scoped %}
{# implement this block in game-specific multi trackers #}
{% endblock %}
<td class="center-column">{{ checks["Total"] }}/{{ checks_in_area[player]["Total"] }}</td>
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
<td>{{ {0: "Disconnected", 5: "Connected", 10: "Ready", 20: "Playing",
30: "Goal Completed"}.get(states[team, player], "Unknown State") }}</td>
{%- if activity_timers[team, player] -%}
<td class="center-column">{{ activity_timers[team, player].total_seconds() }}</td>
{%- else -%}
<td class="center-column">None</td>
{%- endif -%}
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}
{% for team, hints in hints.items() %}
<div class="table-wrapper">
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
<thead>
<tr>
<th>Finder</th>
<th>Receiver</th>
<th>Item</th>
<th>Location</th>
<th>Entrance</th>
<th>Found</th>
</tr>
</thead>
<tbody>
{%- for hint in hints -%}
<tr>
<td>{{ long_player_names[team, hint.finding_player] }}</td>
<td>{{ long_player_names[team, hint.receiving_player] }}</td>
<td>{{ hint.item|item_name }}</td>
<td>{{ hint.location|location_name }}</td>
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
<td>{% if hint.found %}✔{% endif %}</td>
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,9 @@
{%- if enabled_multiworld_trackers|length > 1 -%}
<div id="tracker-navigation">
{% for enabled_tracker in enabled_multiworld_trackers %}
{% set tracker_url = url_for(enabled_tracker.endpoint, tracker=room.tracker) %}
<a class="tracker-navigation-button{% if enabled_tracker.current %} selected{% endif %}"
href="{{ tracker_url }}">{{ enabled_tracker.name }}</a>
{% endfor %}
</div>
{%- endif -%}

View File

@@ -11,10 +11,10 @@ from werkzeug.exceptions import abort
from MultiServer import Context, get_saving_second
from NetUtils import SlotType
from Utils import restricted_loads
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name, network_data_package
from worlds.alttp import Items
from . import app, cache
from .models import Room
from .models import GameDataPackage, Room
alttp_icons = {
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
@@ -210,14 +210,6 @@ del data
del item
def attribute_item(inventory, team, recipient, item):
target_item = links.get(item, item)
if item in levels: # non-progressive
inventory[team][recipient][target_item] = max(inventory[team][recipient][target_item], levels[item])
else:
inventory[team][recipient][target_item] += 1
def attribute_item_solo(inventory, item):
"""Adds item to inventory counter, converts everything to progressive."""
target_item = links.get(item, item)
@@ -237,14 +229,15 @@ def render_timedelta(delta: datetime.timedelta):
@pass_context
def get_location_name(context: runtime.Context, loc: int) -> str:
# once all rooms embed data package, the chain lookup can be dropped
context_locations = context.get("custom_locations", {})
return collections.ChainMap(lookup_any_location_id_to_name, context_locations).get(loc, loc)
return collections.ChainMap(context_locations, lookup_any_location_id_to_name).get(loc, loc)
@pass_context
def get_item_name(context: runtime.Context, item: int) -> str:
context_items = context.get("custom_items", {})
return collections.ChainMap(lookup_any_item_id_to_name, context_items).get(item, item)
return collections.ChainMap(context_items, lookup_any_item_id_to_name).get(item, item)
app.jinja_env.filters["location_name"] = get_location_name
@@ -282,11 +275,21 @@ def get_static_room_data(room: Room):
if slot_info.type == SlotType.group}
for game in games.values():
if game in multidata["datapackage"]:
custom_locations.update(
{id: name for name, id in multidata["datapackage"][game]["location_name_to_id"].items()})
custom_items.update(
{id: name for name, id in multidata["datapackage"][game]["item_name_to_id"].items()})
if game not in multidata["datapackage"]:
continue
game_data = multidata["datapackage"][game]
if "checksum" in game_data:
if network_data_package["games"].get(game, {}).get("checksum") == game_data["checksum"]:
# non-custom. remove from multidata
# network_data_package import could be skipped once all rooms embed data package
del multidata["datapackage"][game]
continue
else:
game_data = restricted_loads(GameDataPackage.get(checksum=game_data["checksum"]).data)
custom_locations.update(
{id_: name for name, id_ in game_data["location_name_to_id"].items()})
custom_items.update(
{id_: name for name, id_ in game_data["item_name_to_id"].items()})
elif "games" in multidata:
games = multidata["games"]
seed_checks_in_area = checks_in_area.copy()
@@ -486,7 +489,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
"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",
"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",
@@ -494,7 +497,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
}
minecraft_location_ids = {
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
"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],
@@ -656,7 +659,7 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
if base_name == "hookshot":
display_data['hookshot_length'] = {0: '', 1: 'H', 2: 'L'}.get(level)
if base_name == "wallet":
if base_name == "wallet":
display_data['wallet_size'] = {0: '99', 1: '200', 2: '500', 3: '999'}.get(level)
# Determine display for bottles. Show letter if it's obtained, determine bottle count
@@ -674,7 +677,6 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
}
for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower()
count = inventory[item_id]
display_data[base_name+"_count"] = inventory[item_id]
# Gather dungeon locations
@@ -804,7 +806,7 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
}
timespinner_location_ids = {
"Present": [
"Present": [
1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009,
1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019,
1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029,
@@ -825,20 +827,20 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
1337150, 1337151, 1337152, 1337153, 1337154, 1337155,
1337171, 1337172, 1337173, 1337174, 1337175],
"Ancient Pyramid": [
1337236,
1337236,
1337246, 1337247, 1337248, 1337249]
}
if(slot_data["DownloadableItems"]):
timespinner_location_ids["Present"] += [
1337156, 1337157, 1337159,
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
1337170]
if(slot_data["Cantoran"]):
timespinner_location_ids["Past"].append(1337176)
if(slot_data["LoreChecks"]):
timespinner_location_ids["Present"] += [
1337177, 1337178, 1337179,
1337177, 1337178, 1337179,
1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187]
timespinner_location_ids["Past"] += [
1337188, 1337189,
@@ -1219,6 +1221,84 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
**display_data)
def __renderChecksfinder(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict, saving_second: int) -> str:
icons = {
"Checks Available": "https://0rganics.org/archipelago/cf/spr_tiles_3.png",
"Map Width": "https://0rganics.org/archipelago/cf/spr_tiles_4.png",
"Map Height": "https://0rganics.org/archipelago/cf/spr_tiles_5.png",
"Map Bombs": "https://0rganics.org/archipelago/cf/spr_tiles_6.png",
"Nothing": "",
}
checksfinder_location_ids = {
"Tile 1": 81000,
"Tile 2": 81001,
"Tile 3": 81002,
"Tile 4": 81003,
"Tile 5": 81004,
"Tile 6": 81005,
"Tile 7": 81006,
"Tile 8": 81007,
"Tile 9": 81008,
"Tile 10": 81009,
"Tile 11": 81010,
"Tile 12": 81011,
"Tile 13": 81012,
"Tile 14": 81013,
"Tile 15": 81014,
"Tile 16": 81015,
"Tile 17": 81016,
"Tile 18": 81017,
"Tile 19": 81018,
"Tile 20": 81019,
"Tile 21": 81020,
"Tile 22": 81021,
"Tile 23": 81022,
"Tile 24": 81023,
"Tile 25": 81024,
}
display_data = {}
# Multi-items
multi_items = {
"Map Width": 80000,
"Map Height": 80001,
"Map Bombs": 80002
}
for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower()
count = inventory[item_id]
display_data[base_name + "_count"] = count
display_data[base_name + "_display"] = count + 5
# Get location info
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
lookup_name = lambda id: lookup_any_location_id_to_name[id]
location_info = {tile_name: {lookup_name(tile_location): (tile_location in checked_locations)} for tile_name, tile_location in checksfinder_location_ids.items() if tile_location in set(locations[player])}
checks_done = {tile_name: len([tile_location]) for tile_name, tile_location in checksfinder_location_ids.items() if tile_location in checked_locations and tile_location in set(locations[player])}
checks_done['Total'] = len(checked_locations)
checks_in_area = checks_done
# Calculate checks available
display_data["checks_unlocked"] = min(display_data["width_count"] + display_data["height_count"] + display_data["bombs_count"] + 5, 25)
display_data["checks_available"] = max(display_data["checks_unlocked"] - len(checked_locations), 0)
# Victory condition
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
display_data['game_finished'] = game_state == 30
return render_template("checksfinderTracker.html",
inventory=inventory, icons=icons,
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
id in lookup_any_item_id_to_name},
player=player, team=team, room=room, player_name=playerName,
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
**display_data)
def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str,
@@ -1245,19 +1325,33 @@ def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dic
custom_items=custom_items, custom_locations=custom_locations)
@app.route('/tracker/<suuid:tracker>')
@cache.memoize(timeout=1) # multisave is currently created at most every minute
def getTracker(tracker: UUID):
def get_enabled_multiworld_trackers(room: Room, current: str):
enabled = [
{
"name": "Generic",
"endpoint": "get_multiworld_tracker",
"current": current == "Generic"
}
]
for game_name, endpoint in multi_trackers.items():
if any(slot.game == game_name for slot in room.seed.slots) or current == game_name:
enabled.append({
"name": game_name,
"endpoint": endpoint.__name__,
"current": current == game_name}
)
return enabled
def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[str, typing.Any]]:
room: Room = Room.get(tracker=tracker)
if not room:
abort(404)
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
return None
locations, names, use_door_tracker, checks_in_area, player_location_to_area, \
precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \
get_static_room_data(room)
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations}
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
@@ -1266,7 +1360,6 @@ def getTracker(tracker: UUID):
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
hints = {team: set() for team in range(len(names))}
if room.multisave:
multisave = restricted_loads(room.multisave)
@@ -1276,6 +1369,128 @@ def getTracker(tracker: UUID):
for (team, slot), slot_hints in multisave["hints"].items():
hints[team] |= set(slot_hints)
for (team, player), locations_checked in multisave.get("location_checks", {}).items():
if player in groups:
continue
player_locations = locations[player]
checks_done[team][player]["Total"] = sum(1 for loc in locations_checked if loc in player_locations)
percent_total_checks_done[team][player] = int(checks_done[team][player]["Total"] /
checks_in_area[player]["Total"] * 100) \
if checks_in_area[player]["Total"] else 100
activity_timers = {}
now = datetime.datetime.utcnow()
for (team, player), timestamp in multisave.get("client_activity_timers", []):
activity_timers[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp)
player_names = {}
states: typing.Dict[typing.Tuple[int, int], int] = {}
for team, names in enumerate(names):
for player, name in enumerate(names, 1):
player_names[team, player] = name
states[team, player] = multisave.get("client_game_state", {}).get((team, player), 0)
long_player_names = player_names.copy()
for (team, player), alias in multisave.get("name_aliases", {}).items():
player_names[team, player] = alias
long_player_names[(team, player)] = f"{alias} ({long_player_names[team, player]})"
video = {}
for (team, player), data in multisave.get("video", []):
video[team, player] = data
return dict(player_names=player_names, room=room, checks_done=checks_done,
percent_total_checks_done=percent_total_checks_done, checks_in_area=checks_in_area,
activity_timers=activity_timers, video=video, hints=hints,
long_player_names=long_player_names,
multisave=multisave, precollected_items=precollected_items, groups=groups,
locations=locations, games=games, states=states)
def _get_inventory_data(data: typing.Dict[str, typing.Any]) -> typing.Dict[int, typing.Dict[int, int]]:
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in team_data}
for teamnumber, team_data in data["checks_done"].items()}
groups = data["groups"]
for (team, player), locations_checked in data["multisave"].get("location_checks", {}).items():
if player in data["groups"]:
continue
player_locations = data["locations"][player]
precollected = data["precollected_items"][player]
for item_id in precollected:
inventory[team][player][item_id] += 1
for location in locations_checked:
item_id, recipient, flags = player_locations[location]
recipients = groups.get(recipient, [recipient])
for recipient in recipients:
inventory[team][recipient][item_id] += 1
return inventory
@app.route('/tracker/<suuid:tracker>')
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def get_multiworld_tracker(tracker: UUID):
data = _get_multiworld_tracker_data(tracker)
if not data:
abort(404)
data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Generic")
return render_template("multiTracker.html", **data)
@app.route('/tracker/<suuid:tracker>/Factorio')
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def get_Factorio_multiworld_tracker(tracker: UUID):
data = _get_multiworld_tracker_data(tracker)
if not data:
abort(404)
data["inventory"] = _get_inventory_data(data)
data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Factorio")
return render_template("multiFactorioTracker.html", **data)
@app.route('/tracker/<suuid:tracker>/A Link to the Past')
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def get_LttP_multiworld_tracker(tracker: UUID):
room: Room = Room.get(tracker=tracker)
if not room:
abort(404)
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \
get_static_room_data(room)
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1) if
playernumber not in groups}
for teamnumber, team in enumerate(names)}
checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations}
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
percent_total_checks_done = {teamnumber: {playernumber: 0
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
hints = {team: set() for team in range(len(names))}
if room.multisave:
multisave = restricted_loads(room.multisave)
else:
multisave = {}
if "hints" in multisave:
for (team, slot), slot_hints in multisave["hints"].items():
hints[team] |= set(slot_hints)
def attribute_item(team: int, recipient: int, item: int):
nonlocal inventory
target_item = links.get(item, item)
if item in levels: # non-progressive
inventory[team][recipient][target_item] = max(inventory[team][recipient][target_item], levels[item])
else:
inventory[team][recipient][target_item] += 1
for (team, player), locations_checked in multisave.get("location_checks", {}).items():
if player in groups:
continue
@@ -1283,18 +1498,19 @@ def getTracker(tracker: UUID):
if precollected_items:
precollected = precollected_items[player]
for item_id in precollected:
attribute_item(inventory, team, player, item_id)
attribute_item(team, player, item_id)
for location in locations_checked:
if location not in player_locations or location not in player_location_to_area[player]:
continue
item, recipient, flags = player_locations[location]
if recipient in names:
attribute_item(inventory, team, recipient, item)
checks_done[team][player][player_location_to_area[player][location]] += 1
checks_done[team][player]["Total"] += 1
percent_total_checks_done[team][player] = int(checks_done[team][player]["Total"] / seed_checks_in_area[player]["Total"] * 100) if seed_checks_in_area[player]["Total"] else 100
recipients = groups.get(recipient, [recipient])
for recipient in recipients:
attribute_item(team, recipient, item)
checks_done[team][player][player_location_to_area[player][location]] += 1
checks_done[team][player]["Total"] += 1
percent_total_checks_done[team][player] = int(
checks_done[team][player]["Total"] / seed_checks_in_area[player]["Total"] * 100) if \
seed_checks_in_area[player]["Total"] else 100
for (team, player), game_state in multisave.get("client_game_state", {}).items():
if player in groups:
@@ -1336,14 +1552,19 @@ def getTracker(tracker: UUID):
for (team, player), data in multisave.get("video", []):
video[(team, player)] = data
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name,
enabled_multiworld_trackers = get_enabled_multiworld_trackers(room, "A Link to the Past")
return render_template("lttpMultiTracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name,
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons,
multi_items=multi_items, checks_done=checks_done, percent_total_checks_done=percent_total_checks_done,
ordered_areas=ordered_areas, checks_in_area=seed_checks_in_area, activity_timers=activity_timers,
multi_items=multi_items, checks_done=checks_done,
percent_total_checks_done=percent_total_checks_done,
ordered_areas=ordered_areas, checks_in_area=seed_checks_in_area,
activity_timers=activity_timers,
key_locations=group_key_locations, small_key_ids=small_key_ids, big_key_ids=big_key_ids,
video=video, big_key_locations=group_big_key_locations,
hints=hints, long_player_names=long_player_names)
hints=hints, long_player_names=long_player_names,
enabled_multiworld_trackers=enabled_multiworld_trackers)
game_specific_trackers: typing.Dict[str, typing.Callable] = {
@@ -1351,6 +1572,12 @@ game_specific_trackers: typing.Dict[str, typing.Callable] = {
"Ocarina of Time": __renderOoTTracker,
"Timespinner": __renderTimespinnerTracker,
"A Link to the Past": __renderAlttpTracker,
"ChecksFinder": __renderChecksfinder,
"Super Metroid": __renderSuperMetroidTracker,
"Starcraft 2 Wings of Liberty": __renderSC2WoLTracker
}
multi_trackers: typing.Dict[str, typing.Callable] = {
"A Link to the Past": get_LttP_multiworld_tracker,
"Factorio": get_Factorio_multiworld_tracker,
}

View File

@@ -1,19 +1,22 @@
import base64
import json
import pickle
import typing
import uuid
import zipfile
from io import BytesIO
import zlib
from io import BytesIO
from flask import request, flash, redirect, url_for, session, render_template, Markup
from pony.orm import flush, select
from pony.orm import commit, flush, select, rollback
from pony.orm.core import TransactionIntegrityError
import MultiServer
from NetUtils import NetworkSlot, SlotType
from Utils import VersionException, __version__
from worlds.Files import AutoPatchRegister
from . import app
from .models import Seed, Room, Slot
from .models import Seed, Room, Slot, GameDataPackage
banned_zip_contents = (".sfc", ".z64", ".n64", ".sms", ".gb")
@@ -78,6 +81,27 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
# Load multi data.
if multidata:
decompressed_multidata = MultiServer.Context.decompress(multidata)
recompress = False
if "datapackage" in decompressed_multidata:
# strip datapackage from multidata, leaving only the checksums
game_data_packages: typing.List[GameDataPackage] = []
for game, game_data in decompressed_multidata["datapackage"].items():
if game_data.get("checksum"):
game_data_package = GameDataPackage(checksum=game_data["checksum"],
data=pickle.dumps(game_data))
decompressed_multidata["datapackage"][game] = {
"version": game_data.get("version", 0),
"checksum": game_data["checksum"]
}
recompress = True
try:
commit() # commit game data package
game_data_packages.append(game_data_package)
except TransactionIntegrityError:
del game_data_package
rollback()
if "slot_info" in decompressed_multidata:
for slot, slot_info in decompressed_multidata["slot_info"].items():
# Ignore Player Groups (e.g. item links)
@@ -90,6 +114,9 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
flush() # commit slots
if recompress:
multidata = multidata[0:1] + zlib.compress(pickle.dumps(decompressed_multidata), 9)
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner, meta=json.dumps(meta),
id=sid if sid else uuid.uuid4())
flush() # create seed

393
Zelda1Client.py Normal file
View File

@@ -0,0 +1,393 @@
# Based (read: copied almost wholesale and edited) off the FF1 Client.
import asyncio
import copy
import json
import logging
import os
import subprocess
import time
import typing
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from worlds import lookup_any_location_id_to_name
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
get_base_parser
from worlds.tloz.Items import item_game_ids
from worlds.tloz.Locations import location_ids
from worlds.tloz import Items, Locations, Rom
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart Zelda_connector.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure Zelda_connector.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart Zelda_connector.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
item_ids = item_game_ids
location_ids = location_ids
items_by_id = {id: item for item, id in item_ids.items()}
locations_by_id = {id: location for location, id in location_ids.items()}
class ZeldaCommandProcessor(ClientCommandProcessor):
def _cmd_nes(self):
"""Check NES Connection State"""
if isinstance(self.ctx, ZeldaContext):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in bizhawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
class ZeldaContext(CommonContext):
command_processor = ZeldaCommandProcessor
items_handling = 0b101 # get sent remote and starting items
# Infinite Hyrule compatibility
overworld_item = 0x5F
armos_item = 0x24
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.bonus_items = []
self.nes_streams: (StreamReader, StreamWriter) = None
self.nes_sync_task = None
self.messages = {}
self.locations_array = None
self.nes_status = CONNECTION_INITIAL_STATUS
self.game = 'The Legend of Zelda'
self.awaiting_rom = False
self.shop_slots_left = 0
self.shop_slots_middle = 0
self.shop_slots_right = 0
self.shop_slots = [self.shop_slots_left, self.shop_slots_middle, self.shop_slots_right]
self.slot_data = dict()
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(ZeldaContext, 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':
self.slot_data = args.get("slot_data", {})
asyncio.create_task(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 ZeldaManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Zelda 1 Client"
self.ui = ZeldaManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: ZeldaContext):
current_time = time.time()
bonus_items = [item for item in ctx.bonus_items]
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},
"shops": {
"left": ctx.shop_slots_left,
"middle": ctx.shop_slots_middle,
"right": ctx.shop_slots_right
},
"bonusItems": bonus_items
}
)
def reconcile_shops(ctx: ZeldaContext):
checked_location_names = [lookup_any_location_id_to_name[location] for location in ctx.checked_locations]
shops = [location for location in checked_location_names if "Shop" in location]
left_slots = [shop for shop in shops if "Left" in shop]
middle_slots = [shop for shop in shops if "Middle" in shop]
right_slots = [shop for shop in shops if "Right" in shop]
for shop in left_slots:
ctx.shop_slots_left |= get_shop_bit_from_name(shop)
for shop in middle_slots:
ctx.shop_slots_middle |= get_shop_bit_from_name(shop)
for shop in right_slots:
ctx.shop_slots_right |= get_shop_bit_from_name(shop)
def get_shop_bit_from_name(location_name):
if "Potion" in location_name:
return Rom.potion_shop
elif "Arrow" in location_name:
return Rom.arrow_shop
elif "Shield" in location_name:
return Rom.shield_shop
elif "Ring" in location_name:
return Rom.ring_shop
elif "Candle" in location_name:
return Rom.candle_shop
elif "Take" in location_name:
return Rom.take_any
return 0 # this should never be hit
async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone="None"):
if locations_array == ctx.locations_array and not force:
return
else:
# print("New values")
ctx.locations_array = locations_array
locations_checked = []
location = None
for location in ctx.missing_locations:
location_name = lookup_any_location_id_to_name[location]
if location_name in Locations.overworld_locations and zone == "overworld":
status = locations_array[Locations.major_location_offsets[location_name]]
if location_name == "Ocean Heart Container":
status = locations_array[ctx.overworld_item]
if location_name == "Armos Knights":
status = locations_array[ctx.armos_item]
if status & 0x10:
ctx.locations_checked.add(location)
locations_checked.append(location)
elif location_name in Locations.underworld1_locations and zone == "underworld1":
status = locations_array[Locations.floor_location_game_offsets_early[location_name]]
if status & 0x10:
ctx.locations_checked.add(location)
locations_checked.append(location)
elif location_name in Locations.underworld2_locations and zone == "underworld2":
status = locations_array[Locations.floor_location_game_offsets_late[location_name]]
if status & 0x10:
ctx.locations_checked.add(location)
locations_checked.append(location)
elif (location_name in Locations.shop_locations or "Take" in location_name) and zone == "caves":
shop_bit = get_shop_bit_from_name(location_name)
slot = 0
context_slot = 0
if "Left" in location_name:
slot = "slot1"
context_slot = 0
elif "Middle" in location_name:
slot = "slot2"
context_slot = 1
elif "Right" in location_name:
slot = "slot3"
context_slot = 2
if locations_array[slot] & shop_bit > 0:
locations_checked.append(location)
ctx.shop_slots[context_slot] |= shop_bit
if locations_array["takeAnys"] and locations_array["takeAnys"] >= 4:
if "Take Any" in location_name:
short_name = None
if "Left" in location_name:
short_name = "TakeAnyLeft"
elif "Middle" in location_name:
short_name = "TakeAnyMiddle"
elif "Right" in location_name:
short_name = "TakeAnyRight"
if short_name is not None:
item_code = ctx.slot_data[short_name]
if item_code > 0:
ctx.bonus_items.append(item_code)
locations_checked.append(location)
if locations_checked:
await ctx.send_msgs([
{"cmd": "LocationChecks",
"locations": locations_checked}
])
async def nes_sync_task(ctx: ZeldaContext):
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())
if data_decoded["overworldHC"] is not None:
ctx.overworld_item = data_decoded["overworldHC"]
if data_decoded["overworldPB"] is not None:
ctx.armos_item = data_decoded["overworldPB"]
if data_decoded['gameMode'] == 19 and ctx.finished_game == False:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
if ctx.game is not None and 'overworld' in data_decoded:
# Not just a keep alive ping, parse
asyncio.create_task(parse_locations(data_decoded['overworld'], ctx, False, "overworld"))
if ctx.game is not None and 'underworld1' in data_decoded:
asyncio.create_task(parse_locations(data_decoded['underworld1'], ctx, False, "underworld1"))
if ctx.game is not None and 'underworld2' in data_decoded:
asyncio.create_task(parse_locations(data_decoded['underworld2'], ctx, False, "underworld2"))
if ctx.game is not None and 'caves' in data_decoded:
asyncio.create_task(parse_locations(data_decoded['caves'], ctx, False, "caves"))
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)
reconcile_shops(ctx)
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("ZeldaClient")
options = Utils.get_options()
DISPLAY_MSGS = options["tloz_options"]["display_msgs"]
async def run_game(romfile: str) -> None:
auto_start = typing.cast(typing.Union[bool, str],
Utils.get_options()["tloz_options"].get("rom_start", True))
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif isinstance(auto_start, str) and os.path.isfile(auto_start):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def main(args):
if args.diff_file:
import Patch
logging.info("Patch file was supplied. Creating nes rom..")
meta, romfile = Patch.create_rom_file(args.diff_file)
if "server" in meta:
args.connect = meta["server"]
logging.info(f"Wrote rom file to {romfile}")
async_start(run_game(romfile))
ctx = ZeldaContext(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()
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a Archipelago Binary Patch file')
args = parser.parse_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -0,0 +1,851 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local SCRIPT_VERSION = 1
local APItemValue = 0xA2
local APItemRam = 0xE7
local BatAPItemValue = 0xAB
local BatAPItemRam = 0xEA
local PlayerRoomAddr = 0x8A -- if in number room, we're not in play mode
local WinAddr = 0xDE -- if not 0 (I think if 0xff specifically), we won (and should update once, immediately)
-- If any of these are 2, that dragon ate the player (should send update immediately
-- once, and reset that when none of them are 2 again)
local DragonState = {0xA8, 0xAD, 0xB2}
local last_dragon_state = {0, 0, 0}
local carryAddress = 0x9D -- uses rom object table
local batRoomAddr = 0xCB
local batCarryAddress = 0xD0 -- uses ram object location
local batInvalidCarryItem = 0x78
local batItemCheckAddr = 0xf69f
local batMatrixLen = 11 -- number of pairs
local last_carry_item = 0xB4
local frames_with_no_item = 0
local ItemTableStart = 0xfe9d
local PlayerSlotAddress = 0xfff9
local itemMessages = {}
local nullObjectId = 0xB4
local ItemsReceived = nil
local sha256hash = nil
local foreign_items = nil
local foreign_items_by_room = {}
local bat_no_touch_locations_by_room = {}
local bat_no_touch_items = {}
local autocollect_items = {}
local localItemLocations = {}
local prev_bat_room = 0xff
local prev_player_room = 0
local prev_ap_room_index = nil
local pending_foreign_items_collected = {}
local pending_local_items_collected = {}
local rendering_foreign_item = nil
local skip_inventory_items = {}
local inventory = {}
local next_inventory_item = nil
local input_button_address = 0xD7
local deathlink_rec = nil
local deathlink_send = 0
local deathlink_sent = false
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local atariSocket = nil
local frame = 0
local ItemIndex = 0
local yorgle_speed_address = 0xf725
local grundle_speed_address = 0xf740
local rhindle_speed_address = 0xf70A
local read_switch_a = 0xf780
local read_switch_b = 0xf764
local yorgle_speed = nil
local grundle_speed = nil
local rhindle_speed = nil
local slow_yorgle_id = tostring(118000000 + 0x103)
local slow_grundle_id = tostring(118000000 + 0x104)
local slow_rhindle_id = tostring(118000000 + 0x105)
local yorgle_dead = false
local grundle_dead = false
local rhindle_dead = false
local diff_a_locked = false
local diff_b_locked = false
local bat_logic = 0
local is_dead = 0
local freeincarnates_available = 0
local send_freeincarnate_used = false
local current_bat_ap_item = nil
local was_in_number_room = false
local u8 = nil
local wU8 = nil
local u16
local bizhawk_version = client.getversion()
local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_version:sub(1,3)=="2.4") or (bizhawk_version:sub(1,3)=="2.5")
local is26To28 = (bizhawk_version:sub(1,3)=="2.6") or (bizhawk_version:sub(1,3)=="2.7") or (bizhawk_version:sub(1,3)=="2.8")
u8 = memory.read_u8
wU8 = memory.write_u8
u16 = memory.read_u16_le
function uRangeRam(address, bytes)
data = memory.read_bytes_as_array(address, bytes, "Main RAM")
return data
end
function uRangeRom(address, bytes)
data = memory.read_bytes_as_array(address+0xf000, bytes, "System Bus")
return data
end
function uRangeAddress(address, bytes)
data = memory.read_bytes_as_array(address, bytes, "System Bus")
return data
end
function table.empty (self)
for _, _ in pairs(self) do
return false
end
return true
end
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
local function createForeignItemsByRoom()
foreign_items_by_room = {}
if foreign_items == nil then
return
end
for _, foreign_item in pairs(foreign_items) do
if foreign_items_by_room[foreign_item.room_id] == nil then
foreign_items_by_room[foreign_item.room_id] = {}
end
new_foreign_item = {}
new_foreign_item.room_id = foreign_item.room_id
new_foreign_item.room_x = foreign_item.room_x
new_foreign_item.room_y = foreign_item.room_y
new_foreign_item.short_location_id = foreign_item.short_location_id
table.insert(foreign_items_by_room[foreign_item.room_id], new_foreign_item)
end
end
function debugPrintNoTouchLocations()
for room_id, list in pairs(bat_no_touch_locations_by_room) do
for index, notouch_location in ipairs(list) do
print("ROOM "..tostring(room_id).. "["..tostring(index).."]: "..tostring(notouch_location.short_location_id))
end
end
end
function processBlock(block)
if block == nil then
return
end
local block_identified = 0
local msgBlock = block['messages']
if msgBlock ~= nil then
block_identified = 1
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"]
if itemsBlock ~= nil then
block_identified = 1
ItemsReceived = itemsBlock
end
local apItemsBlock = block["foreign_items"]
if apItemsBlock ~= nil then
block_identified = 1
print("got foreign items block")
foreign_items = apItemsBlock
createForeignItemsByRoom()
end
local autocollectItems = block["autocollect_items"]
if autocollectItems ~= nil then
block_identified = 1
autocollect_items = {}
for _, acitem in pairs(autocollectItems) do
if autocollect_items[acitem.room_id] == nil then
autocollect_items[acitem.room_id] = {}
end
table.insert(autocollect_items[acitem.room_id], acitem)
end
end
local localLocalItemLocations = block["local_item_locations"]
if localLocalItemLocations ~= nil then
block_identified = 1
localItemLocations = localLocalItemLocations
print("got local item locations")
end
local checkedLocationsBlock = block["checked_locations"]
if checkedLocationsBlock ~= nil then
block_identified = 1
for room_id, foreign_item_list in pairs(foreign_items_by_room) do
for i, foreign_item in pairs(foreign_item_list) do
short_id = foreign_item.short_location_id
for j, checked_id in pairs(checkedLocationsBlock) do
if checked_id == short_id then
table.remove(foreign_item_list, i)
break
end
end
end
end
if foreign_items ~= nil then
for i, foreign_item in pairs(foreign_items) do
short_id = foreign_item.short_location_id
for j, checked_id in pairs(checkedLocationsBlock) do
if checked_id == short_id then
foreign_items[i] = nil
break
end
end
end
end
end
local dragon_speeds_block = block["dragon_speeds"]
if dragon_speeds_block ~= nil then
block_identified = 1
yorgle_speed = dragon_speeds_block[slow_yorgle_id]
grundle_speed = dragon_speeds_block[slow_grundle_id]
rhindle_speed = dragon_speeds_block[slow_rhindle_id]
end
local diff_a_block = block["difficulty_a_locked"]
if diff_a_block ~= nil then
block_identified = 1
diff_a_locked = diff_a_block
end
local diff_b_block = block["difficulty_b_locked"]
if diff_b_block ~= nil then
block_identified = 1
diff_b_locked = diff_b_block
end
local freeincarnates_available_block = block["freeincarnates_available"]
if freeincarnates_available_block ~= nil then
block_identified = 1
if freeincarnates_available ~= freeincarnates_available_block then
freeincarnates_available = freeincarnates_available_block
local msg = {TTL=450, message="freeincarnates: "..tostring(freeincarnates_available), color=0xFFFF0000}
itemMessages[-2] = msg
end
end
local bat_logic_block = block["bat_logic"]
if bat_logic_block ~= nil then
block_identified = 1
bat_logic = bat_logic_block
end
local bat_no_touch_locations_block = block["bat_no_touch_locations"]
if bat_no_touch_locations_block ~= nil then
block_identified = 1
for _, notouch_location in pairs(bat_no_touch_locations_block) do
local room_id = tonumber(notouch_location.room_id)
if bat_no_touch_locations_by_room[room_id] == nil then
bat_no_touch_locations_by_room[room_id] = {}
end
table.insert(bat_no_touch_locations_by_room[room_id], notouch_location)
if notouch_location.local_item ~= nil and notouch_location.local_item ~= 255 then
bat_no_touch_items[tonumber(notouch_location.local_item)] = true
-- print("no touch: "..tostring(notouch_location.local_item))
end
end
-- debugPrintNoTouchLocations()
end
deathlink_rec = deathlink_rec or block["deathlink"]
if( block_identified == 0 ) then
print("unidentified block")
print(block)
end
end
local function clearScreen()
if is23Or24Or25 then
return
elseif is26To28 then
drawText(0, 0, "", "black")
end
end
local function getMaxMessageLength()
if is23Or24Or25 then
return client.screenwidth()/11
elseif is26To28 then
return client.screenwidth()/12
end
end
function drawText(x, y, message, color)
if is23Or24Or25 then
gui.addmessage(message)
elseif is26To28 then
gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", nil, nil, nil, "client")
end
end
local function drawMessages()
if table.empty(itemMessages) then
clearScreen()
return
end
local y = 10
found = false
maxMessageLength = getMaxMessageLength()
for k, v in pairs(itemMessages) do
if v["TTL"] > 0 then
message = v["message"]
while true do
drawText(5, y, message:sub(1, maxMessageLength), v["color"])
y = y + 16
message = message:sub(maxMessageLength + 1, message:len())
if message:len() == 0 then
break
end
end
newTTL = 0
if is26To28 then
newTTL = itemMessages[k]["TTL"] - 1
end
itemMessages[k]["TTL"] = newTTL
found = true
end
end
if found == false then
clearScreen()
end
end
function difference(a, b)
local aa = {}
for k,v in pairs(a) do aa[v]=true end
for k,v in pairs(b) do aa[v]=nil end
local ret = {}
local n = 0
for k,v in pairs(a) do
if aa[v] then n=n+1 ret[n]=v end
end
return ret
end
function getAllRam()
uRangeRAM(0,128);
return data
end
local function arrayEqual(a1, a2)
if #a1 ~= #a2 then
return false
end
for i, v in ipairs(a1) do
if v ~= a2[i] then
return false
end
end
return true
end
local function alive_mode()
return (u8(PlayerRoomAddr) ~= 0x00 and u8(WinAddr) == 0x00)
end
local function generateLocationsChecked()
list_of_locations = {}
for s, f in pairs(pending_foreign_items_collected) do
table.insert(list_of_locations, f.short_location_id + 118000000)
end
for s, f in pairs(pending_local_items_collected) do
table.insert(list_of_locations, f + 118000000)
end
return list_of_locations
end
function receive()
l, e = atariSocket:receive()
if e == 'closed' then
if curstate == STATE_OK then
print("Connection closed")
end
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
if l ~= nil then
processBlock(json.decode(l))
end
-- Determine Message to send back
newSha256 = memory.hash_region(0xF000, 0x1000, "System Bus")
if (sha256hash ~= nil and sha256hash ~= newSha256) then
print("ROM changed, quitting")
curstate = STATE_UNINITIALIZED
return
end
sha256hash = newSha256
local retTable = {}
retTable["scriptVersion"] = SCRIPT_VERSION
retTable["romhash"] = sha256hash
if (alive_mode()) then
retTable["locations"] = generateLocationsChecked()
end
if (u8(WinAddr) ~= 0x00) then
retTable["victory"] = 1
end
if( deathlink_sent or deathlink_send == 0 ) then
retTable["deathLink"] = 0
else
print("Sending deathlink "..tostring(deathlink_send))
retTable["deathLink"] = deathlink_send
deathlink_sent = true
end
deathlink_send = 0
if send_freeincarnate_used == true then
print("Sending freeincarnate used")
retTable["freeincarnate"] = true
send_freeincarnate_used = false
end
msg = json.encode(retTable).."\n"
local ret, error = atariSocket: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!")
curstate = STATE_OK
end
end
function AutocollectFromRoom()
if autocollect_items ~= nil and autocollect_items[prev_player_room] ~= nil then
for _, item in pairs(autocollect_items[prev_player_room]) do
pending_foreign_items_collected[item.short_location_id] = item
end
end
end
function SetYorgleSpeed()
if yorgle_speed ~= nil then
emu.setregister("A", yorgle_speed);
end
end
function SetGrundleSpeed()
if grundle_speed ~= nil then
emu.setregister("A", grundle_speed);
end
end
function SetRhindleSpeed()
if rhindle_speed ~= nil then
emu.setregister("A", rhindle_speed);
end
end
function SetDifficultySwitchB()
if diff_b_locked then
local a = emu.getregister("A")
if a < 128 then
emu.setregister("A", a + 128)
end
end
end
function SetDifficultySwitchA()
if diff_a_locked then
local a = emu.getregister("A")
if (a > 128 and a < 128 + 64) or (a < 64) then
emu.setregister("A", a + 64)
end
end
end
function TryFreeincarnate()
if freeincarnates_available > 0 then
freeincarnates_available = freeincarnates_available - 1
for index, state_addr in pairs(DragonState) do
if last_dragon_state[index] == 1 then
send_freeincarnate_used = true
memory.write_u8(state_addr, 1, "System Bus")
local msg = {TTL=450, message="used freeincarnate", color=0xFF00FF00}
itemMessages[-1] = msg
end
end
end
end
function GetLinkedObject()
if emu.getregister("X") == batRoomAddr then
bat_interest_item = emu.getregister("A")
-- if the bat can't touch that item, we'll switch it to the number item, which should never be
-- in the same room as the bat.
if bat_no_touch_items[bat_interest_item] ~= nil then
emu.setregister("A", 0xDD )
emu.setregister("Y", 0xDD )
end
end
end
function CheckCollectAPItem(carry_item, target_item_value, target_item_ram, rendering_foreign_item)
if( carry_item == target_item_value and rendering_foreign_item ~= nil ) then
memory.write_u8(carryAddress, nullObjectId, "System Bus")
memory.write_u8(target_item_ram, 0xFF, "System Bus")
pending_foreign_items_collected[rendering_foreign_item.short_location_id] = rendering_foreign_item
for index, fi in pairs(foreign_items_by_room[rendering_foreign_item.room_id]) do
if( fi.short_location_id == rendering_foreign_item.short_location_id ) then
table.remove(foreign_items_by_room[rendering_foreign_item.room_id], index)
break
end
end
for index, fi in pairs(foreign_items) do
if( fi.short_location_id == rendering_foreign_item.short_location_id ) then
foreign_items[index] = nil
break
end
end
prev_ap_room_index = 0
return true
end
return false
end
function BatCanTouchForeign(foreign_item, bat_room)
if bat_no_touch_locations_by_room[bat_room] == nil or bat_no_touch_locations_by_room[bat_room][1] == nil then
return true
end
for index, location in ipairs(bat_no_touch_locations_by_room[bat_room]) do
if location.short_location_id == foreign_item.short_location_id then
return false
end
end
return true;
end
function main()
memory.usememorydomain("System Bus")
if (is23Or24Or25 or is26To28) == false then
print("Must use a version of bizhawk 2.3.1 or higher")
return
end
local playerSlot = memory.read_u8(PlayerSlotAddress)
local port = 17242 + playerSlot
print("Using port"..tostring(port))
server, error = socket.bind('localhost', port)
if( error ~= nil ) then
print(error)
end
event.onmemoryexecute(SetYorgleSpeed, yorgle_speed_address);
event.onmemoryexecute(SetGrundleSpeed, grundle_speed_address);
event.onmemoryexecute(SetRhindleSpeed, rhindle_speed_address);
event.onmemoryexecute(SetDifficultySwitchA, read_switch_a)
event.onmemoryexecute(SetDifficultySwitchB, read_switch_b)
event.onmemoryexecute(GetLinkedObject, batItemCheckAddr)
-- TODO: Add an onmemoryexecute event to intercept the bat reading item rooms, and don't 'see' an item in the
-- room if it is in bat_no_touch_locations_by_room. Although realistically, I may have to handle this in the rom
-- for it to be totally reliable, because it won't work before the script connects (I might have to reset them?)
-- TODO: Also remove those items from the bat_no_touch_locations_by_room if they have been collected
while true do
frame = frame + 1
drawMessages()
if not (curstate == prevstate) then
print("Current state: "..curstate)
prevstate = curstate
end
local current_player_room = u8(PlayerRoomAddr)
local bat_room = u8(batRoomAddr)
local bat_carrying_item = u8(batCarryAddress)
local bat_carrying_ap_item = (BatAPItemRam == bat_carrying_item)
if current_player_room == 0x1E then
if u8(PlayerRoomAddr + 1) > 0x4B then
memory.write_u8(PlayerRoomAddr + 1, 0x4B)
end
end
if current_player_room == 0x00 then
if not was_in_number_room then
print("reset "..tostring(bat_carrying_ap_item).." "..tostring(bat_carrying_item))
memory.write_u8(batCarryAddress, batInvalidCarryItem)
memory.write_u8(batCarryAddress+ 1, 0)
createForeignItemsByRoom()
memory.write_u8(BatAPItemRam, 0xff)
memory.write_u8(APItemRam, 0xff)
prev_ap_room_index = 0
prev_player_room = 0
rendering_foreign_item = nil
was_in_number_room = true
end
else
was_in_number_room = false
end
if bat_room ~= prev_bat_room then
if bat_carrying_ap_item then
if foreign_items_by_room[prev_bat_room] ~= nil then
for r,f in pairs(foreign_items_by_room[prev_bat_room]) do
if f.short_location_id == current_bat_ap_item.short_location_id then
-- print("removing item from "..tostring(r).." in "..tostring(prev_bat_room))
table.remove(foreign_items_by_room[prev_bat_room], r)
break
end
end
end
if foreign_items_by_room[bat_room] == nil then
foreign_items_by_room[bat_room] = {}
end
-- print("adding item to "..tostring(bat_room))
table.insert(foreign_items_by_room[bat_room], current_bat_ap_item)
else
-- set AP item room and position for new room, or to invalid room
if foreign_items_by_room[bat_room] ~= nil and foreign_items_by_room[bat_room][1] ~= nil
and BatCanTouchForeign(foreign_items_by_room[bat_room][1], bat_room) then
if current_bat_ap_item ~= foreign_items_by_room[bat_room][1] then
current_bat_ap_item = foreign_items_by_room[bat_room][1]
-- print("Changing bat item to "..tostring(current_bat_ap_item.short_location_id))
end
memory.write_u8(BatAPItemRam, bat_room)
memory.write_u8(BatAPItemRam + 1, current_bat_ap_item.room_x)
memory.write_u8(BatAPItemRam + 2, current_bat_ap_item.room_y)
else
memory.write_u8(BatAPItemRam, 0xff)
if current_bat_ap_item ~= nil then
-- print("clearing bat item")
end
current_bat_ap_item = nil
end
end
end
prev_bat_room = bat_room
-- update foreign_items_by_room position and room id for bat item if bat carrying an item
if bat_carrying_ap_item then
-- this is setting the item using the bat's position, which is somewhat wrong, but I think
-- there will be more problems with the room not matching sometimes if I use the actual item position
current_bat_ap_item.room_id = bat_room
current_bat_ap_item.room_x = u8(batRoomAddr + 1)
current_bat_ap_item.room_y = u8(batRoomAddr + 2)
end
if (alive_mode()) then
if (current_player_room ~= prev_player_room) then
memory.write_u8(APItemRam, 0xFF, "System Bus")
prev_ap_room_index = 0
prev_player_room = current_player_room
AutocollectFromRoom()
end
local carry_item = memory.read_u8(carryAddress, "System Bus")
bat_no_touch_items[carry_item] = nil
if (next_inventory_item ~= nil) then
if ( carry_item == nullObjectId and last_carry_item == nullObjectId ) then
frames_with_no_item = frames_with_no_item + 1
if (frames_with_no_item > 10) then
frames_with_no_item = 10
local input_value = memory.read_u8(input_button_address, "System Bus")
if( input_value >= 64 and input_value < 128 ) then -- high bit clear, second highest bit set
memory.write_u8(carryAddress, next_inventory_item)
local item_ram_location = memory.read_u8(ItemTableStart + next_inventory_item)
if( memory.read_u8(batCarryAddress) ~= 0x78 and
memory.read_u8(batCarryAddress) == item_ram_location) then
memory.write_u8(batCarryAddress, batInvalidCarryItem)
memory.write_u8(batCarryAddress+ 1, 0)
memory.write_u8(item_ram_location, current_player_room)
memory.write_u8(item_ram_location + 1, memory.read_u8(PlayerRoomAddr + 1))
memory.write_u8(item_ram_location + 2, memory.read_u8(PlayerRoomAddr + 2))
end
ItemIndex = ItemIndex + 1
next_inventory_item = nil
end
end
else
frames_with_no_item = 0
end
end
if( carry_item ~= last_carry_item ) then
if ( localItemLocations ~= nil and localItemLocations[tostring(carry_item)] ~= nil ) then
pending_local_items_collected[localItemLocations[tostring(carry_item)]] =
localItemLocations[tostring(carry_item)]
table.remove(localItemLocations, tostring(carry_item))
skip_inventory_items[carry_item] = carry_item
end
end
last_carry_item = carry_item
CheckCollectAPItem(carry_item, APItemValue, APItemRam, rendering_foreign_item)
if CheckCollectAPItem(carry_item, BatAPItemValue, BatAPItemRam, current_bat_ap_item) and bat_carrying_ap_item then
memory.write_u8(batCarryAddress, batInvalidCarryItem)
memory.write_u8(batCarryAddress+ 1, 0)
end
rendering_foreign_item = nil
if( foreign_items_by_room[current_player_room] ~= nil ) then
if( foreign_items_by_room[current_player_room][prev_ap_room_index] ~= nil ) and memory.read_u8(APItemRam) ~= 0xff then
foreign_items_by_room[current_player_room][prev_ap_room_index].room_x = memory.read_u8(APItemRam + 1)
foreign_items_by_room[current_player_room][prev_ap_room_index].room_y = memory.read_u8(APItemRam + 2)
end
prev_ap_room_index = prev_ap_room_index + 1
local invalid_index = -1
if( foreign_items_by_room[current_player_room][prev_ap_room_index] == nil ) then
prev_ap_room_index = 1
end
if( foreign_items_by_room[current_player_room][prev_ap_room_index] ~= nil and current_bat_ap_item ~= nil and
foreign_items_by_room[current_player_room][prev_ap_room_index].short_location_id == current_bat_ap_item.short_location_id) then
invalid_index = prev_ap_room_index
prev_ap_room_index = prev_ap_room_index + 1
if( foreign_items_by_room[current_player_room][prev_ap_room_index] == nil ) then
prev_ap_room_index = 1
end
end
if( foreign_items_by_room[current_player_room][prev_ap_room_index] ~= nil and prev_ap_room_index ~= invalid_index ) then
memory.write_u8(APItemRam, current_player_room)
rendering_foreign_item = foreign_items_by_room[current_player_room][prev_ap_room_index]
memory.write_u8(APItemRam + 1, rendering_foreign_item.room_x)
memory.write_u8(APItemRam + 2, rendering_foreign_item.room_y)
else
memory.write_u8(APItemRam, 0xFF, "System Bus")
end
end
if is_dead == 0 then
dragons_revived = false
player_dead = false
new_dragon_state = {0,0,0}
for index, dragon_state_addr in pairs(DragonState) do
new_dragon_state[index] = memory.read_u8(dragon_state_addr, "System Bus" )
if last_dragon_state[index] == 1 and new_dragon_state[index] ~= 1 then
dragons_revived = true
elseif last_dragon_state[index] ~= 1 and new_dragon_state[index] == 1 then
dragon_real_index = index - 1
print("Killed dragon: "..tostring(dragon_real_index))
local dragon_item = {}
dragon_item["short_location_id"] = 0xD0 + dragon_real_index
pending_foreign_items_collected[dragon_item.short_location_id] = dragon_item
end
if new_dragon_state[index] == 2 then
player_dead = true
end
end
if dragons_revived and player_dead == false then
TryFreeincarnate()
end
last_dragon_state = new_dragon_state
end
elseif (u8(PlayerRoomAddr) == 0x00) then -- not alive mode, in number room
ItemIndex = 0 -- reset our inventory
next_inventory_item = nil
skip_inventory_items = {}
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 5 == 0) then
receive()
if alive_mode() then
local was_dead = is_dead
is_dead = 0
for index, dragonStateAddr in pairs(DragonState) do
local dragonstateval = memory.read_u8(dragonStateAddr, "System Bus")
if ( dragonstateval == 2) then
is_dead = index
end
end
if was_dead ~= 0 and is_dead == 0 then
TryFreeincarnate()
end
if deathlink_rec == true and is_dead == 0 then
print("setting dead from deathlink")
deathlink_rec = false
deathlink_sent = true
is_dead = 1
memory.write_u8(carryAddress, nullObjectId, "System Bus")
memory.write_u8(DragonState[1], 2, "System Bus")
end
if (is_dead > 0 and deathlink_send == 0 and not deathlink_sent) then
deathlink_send = is_dead
print("setting deathlink_send to "..tostring(is_dead))
elseif (is_dead == 0) then
deathlink_send = 0
deathlink_sent = false
end
if ItemsReceived ~= nil and ItemsReceived[ItemIndex + 1] ~= nil then
while ItemsReceived[ItemIndex + 1] ~= nil and skip_inventory_items[ItemsReceived[ItemIndex + 1]] ~= nil do
print("skip")
ItemIndex = ItemIndex + 1
end
local static_id = ItemsReceived[ItemIndex + 1]
if static_id ~= nil then
inventory[static_id] = 1
if next_inventory_item == nil then
next_inventory_item = static_id
end
end
end
end
end
elseif (curstate == STATE_UNINITIALIZED) then
if (frame % 60 == 0) then
print("Waiting for client.")
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
atariSocket = client
atariSocket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

380
data/lua/ADVENTURE/json.lua Normal file
View File

@@ -0,0 +1,380 @@
--
-- json.lua
--
-- Copyright (c) 2015 rxi
--
-- This library is free software; you can redistribute it and/or modify it
-- under the terms of the MIT license. See LICENSE for details.
--
local json = { _version = "0.1.0" }
-------------------------------------------------------------------------------
-- Encode
-------------------------------------------------------------------------------
local encode
local escape_char_map = {
[ "\\" ] = "\\\\",
[ "\"" ] = "\\\"",
[ "\b" ] = "\\b",
[ "\f" ] = "\\f",
[ "\n" ] = "\\n",
[ "\r" ] = "\\r",
[ "\t" ] = "\\t",
}
local escape_char_map_inv = { [ "\\/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function escape_char(c)
return escape_char_map[c] or string.format("\\u%04x", c:byte())
end
local function encode_nil(val)
return "null"
end
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference?
if stack[val] then error("circular reference") end
stack[val] = true
if val[1] ~= nil or next(val) == nil then
-- Treat as array -- check keys are valid and it is not sparse
local n = 0
for k in pairs(val) do
if type(k) ~= "number" then
error("invalid table: mixed or invalid key types")
end
n = n + 1
end
if n ~= #val then
error("invalid table: sparse array")
end
-- Encode
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Treat as an object
for k, v in pairs(val) do
if type(k) ~= "string" then
error("invalid table: mixed or invalid key types")
end
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
error("unexpected number value '" .. tostring(val) .. "'")
end
return string.format("%.14g", val)
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
error("unexpected type '" .. t .. "'")
end
function json.encode(val)
return ( encode(val) )
end
-------------------------------------------------------------------------------
-- Decode
-------------------------------------------------------------------------------
local parse
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")
local literal_map = {
[ "true" ] = true,
[ "false" ] = false,
[ "null" ] = nil,
}
local function next_char(str, idx, set, negate)
for i = idx, #str do
if set[str:sub(i, i)] ~= negate then
return i
end
end
return #str + 1
end
local function decode_error(str, idx, msg)
--local line_count = 1
--local col_count = 1
--for i = 1, idx - 1 do
-- col_count = col_count + 1
-- if str:sub(i, i) == "\n" then
-- line_count = line_count + 1
-- col_count = 1
-- end
-- end
-- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(3, 6), 16 )
local n2 = tonumber( s:sub(9, 12), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local has_unicode_escape = false
local has_surrogate_escape = false
local has_escape = false
local last
for j = i + 1, #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
end
if last == 92 then -- "\\" (escape char)
if x == 117 then -- "u" (unicode escape sequence)
local hex = str:sub(j + 1, j + 5)
if not hex:find("%x%x%x%x") then
decode_error(str, j, "invalid unicode escape in string")
end
if hex:find("^[dD][89aAbB]") then
has_surrogate_escape = true
else
has_unicode_escape = true
end
else
local c = string.char(x)
if not escape_chars[c] then
decode_error(str, j, "invalid escape char '" .. c .. "' in string")
end
has_escape = true
end
last = nil
elseif x == 34 then -- '"' (end of string)
local s = str:sub(i + 1, j - 1)
if has_surrogate_escape then
s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape)
end
if has_unicode_escape then
s = s:gsub("\\u....", parse_unicode_escape)
end
if has_escape then
s = s:gsub("\\.", escape_char_map_inv)
end
return s, j + 1
else
last = x
end
end
decode_error(str, i, "expected closing quote for string")
end
local function parse_number(str, i)
local x = next_char(str, i, delim_chars)
local s = str:sub(i, x - 1)
local n = tonumber(s)
if not n then
decode_error(str, i, "invalid number '" .. s .. "'")
end
return n, x
end
local function parse_literal(str, i)
local x = next_char(str, i, delim_chars)
local word = str:sub(i, x - 1)
if not literals[word] then
decode_error(str, i, "invalid literal '" .. word .. "'")
end
return literal_map[word], x
end
local function parse_array(str, i)
local res = {}
local n = 1
i = i + 1
while 1 do
local x
i = next_char(str, i, space_chars, true)
-- Empty / end of array?
if str:sub(i, i) == "]" then
i = i + 1
break
end
-- Read token
x, i = parse(str, i)
res[n] = x
n = n + 1
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "]" then break end
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
end
return res, i
end
local function parse_object(str, i)
local res = {}
i = i + 1
while 1 do
local key, val
i = next_char(str, i, space_chars, true)
-- Empty / end of object?
if str:sub(i, i) == "}" then
i = i + 1
break
end
-- Read key
if str:sub(i, i) ~= '"' then
decode_error(str, i, "expected string for key")
end
key, i = parse(str, i)
-- Read ':' delimiter
i = next_char(str, i, space_chars, true)
if str:sub(i, i) ~= ":" then
decode_error(str, i, "expected ':' after key")
end
i = next_char(str, i + 1, space_chars, true)
-- Read value
val, i = parse(str, i)
-- Set
res[key] = val
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "}" then break end
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
end
return res, i
end
local char_func_map = {
[ '"' ] = parse_string,
[ "0" ] = parse_number,
[ "1" ] = parse_number,
[ "2" ] = parse_number,
[ "3" ] = parse_number,
[ "4" ] = parse_number,
[ "5" ] = parse_number,
[ "6" ] = parse_number,
[ "7" ] = parse_number,
[ "8" ] = parse_number,
[ "9" ] = parse_number,
[ "-" ] = parse_number,
[ "t" ] = parse_literal,
[ "f" ] = parse_literal,
[ "n" ] = parse_literal,
[ "[" ] = parse_array,
[ "{" ] = parse_object,
}
parse = function(str, idx)
local chr = str:sub(idx, idx)
local f = char_func_map[chr]
if f then
return f(str, idx)
end
decode_error(str, idx, "unexpected character '" .. chr .. "'")
end
function json.decode(str)
if type(str) ~= "string" then
error("expected argument of type string, got " .. type(str))
end
return ( parse(str, next_char(str, 1, space_chars, true)) )
end
return json

View File

@@ -0,0 +1,132 @@
-----------------------------------------------------------------------------
-- LuaSocket helper module
-- Author: Diego Nehab
-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
-- Declare module and import dependencies
-----------------------------------------------------------------------------
local base = _G
local string = require("string")
local math = require("math")
local socket = require("socket.core")
module("socket")
-----------------------------------------------------------------------------
-- Exported auxiliar functions
-----------------------------------------------------------------------------
function connect(address, port, laddress, lport)
local sock, err = socket.tcp()
if not sock then return nil, err end
if laddress then
local res, err = sock:bind(laddress, lport, -1)
if not res then return nil, err end
end
local res, err = sock:connect(address, port)
if not res then return nil, err end
return sock
end
function bind(host, port, backlog)
local sock, err = socket.tcp()
if not sock then return nil, err end
sock:setoption("reuseaddr", true)
local res, err = sock:bind(host, port)
if not res then return nil, err end
res, err = sock:listen(backlog)
if not res then return nil, err end
return sock
end
try = newtry()
function choose(table)
return function(name, opt1, opt2)
if base.type(name) ~= "string" then
name, opt1, opt2 = "default", name, opt1
end
local f = table[name or "nil"]
if not f then base.error("unknown key (".. base.tostring(name) ..")", 3)
else return f(opt1, opt2) end
end
end
-----------------------------------------------------------------------------
-- Socket sources and sinks, conforming to LTN12
-----------------------------------------------------------------------------
-- create namespaces inside LuaSocket namespace
sourcet = {}
sinkt = {}
BLOCKSIZE = 2048
sinkt["close-when-done"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if not chunk then
sock:close()
return 1
else return sock:send(chunk) end
end
})
end
sinkt["keep-open"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if chunk then return sock:send(chunk)
else return 1 end
end
})
end
sinkt["default"] = sinkt["keep-open"]
sink = choose(sinkt)
sourcet["by-length"] = function(sock, length)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if length <= 0 then return nil end
local size = math.min(socket.BLOCKSIZE, length)
local chunk, err = sock:receive(size)
if err then return nil, err end
length = length - string.len(chunk)
return chunk
end
})
end
sourcet["until-closed"] = function(sock)
local done
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if done then return nil end
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
if not err then return chunk
elseif err == "closed" then
sock:close()
done = 1
return partial
else return nil, err end
end
})
end
sourcet["default"] = sourcet["until-closed"]
source = choose(sourcet)

View File

@@ -7,7 +7,7 @@ local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local SCRIPT_VERSION = 1
local SCRIPT_VERSION = 3
local APIndex = 0x1A6E
local APDeathLinkAddress = 0x00FD
@@ -16,7 +16,8 @@ local EventFlagAddress = 0x1735
local MissableAddress = 0x161A
local HiddenItemsAddress = 0x16DE
local RodAddress = 0x1716
local InGame = 0x1A71
local DexSanityAddress = 0x1A71
local InGameAddress = 0x1A84
local ClientCompatibilityAddress = 0xFF00
local ItemsReceived = nil
@@ -34,6 +35,7 @@ local frame = 0
local u8 = nil
local wU8 = nil
local u16
local compat = nil
local function defineMemoryFunctions()
local memDomain = {}
@@ -70,18 +72,6 @@ function slice (tbl, s, e)
return new
end
function processBlock(block)
if block == nil then
return
end
local itemsBlock = block["items"]
memDomain.wram()
if itemsBlock ~= nil then
ItemsReceived = itemsBlock
end
deathlink_rec = block["deathlink"]
end
function difference(a, b)
local aa = {}
for k,v in pairs(a) do aa[v]=true end
@@ -99,6 +89,7 @@ function generateLocationsChecked()
events = uRange(EventFlagAddress, 0x140)
missables = uRange(MissableAddress, 0x20)
hiddenitems = uRange(HiddenItemsAddress, 0x0E)
dexsanity = uRange(DexSanityAddress, 19)
rod = u8(RodAddress)
data = {}
@@ -108,6 +99,9 @@ function generateLocationsChecked()
table.foreach(hiddenitems, function(k, v) table.insert(data, v) end)
table.insert(data, rod)
if compat > 1 then
table.foreach(dexsanity, function(k, v) table.insert(data, v) end)
end
return data
end
@@ -141,7 +135,15 @@ function receive()
return
end
if l ~= nil then
processBlock(json.decode(l))
block = json.decode(l)
if block ~= nil then
local itemsBlock = block["items"]
if itemsBlock ~= nil then
ItemsReceived = itemsBlock
end
deathlink_rec = block["deathlink"]
end
end
-- Determine Message to send back
memDomain.rom()
@@ -156,15 +158,31 @@ function receive()
seedName = newSeedName
local retTable = {}
retTable["scriptVersion"] = SCRIPT_VERSION
retTable["clientCompatibilityVersion"] = u8(ClientCompatibilityAddress)
if compat == nil then
compat = u8(ClientCompatibilityAddress)
if compat < 2 then
InGameAddress = 0x1A71
end
end
retTable["clientCompatibilityVersion"] = compat
retTable["playerName"] = playerName
retTable["seedName"] = seedName
memDomain.wram()
if u8(InGame) == 0xAC then
in_game = u8(InGameAddress)
if in_game == 0x2A or in_game == 0xAC then
retTable["locations"] = generateLocationsChecked()
elseif in_game ~= 0 then
print("Game may have crashed")
curstate = STATE_UNINITIALIZED
return
end
retTable["deathLink"] = deathlink_send
deathlink_send = false
msg = json.encode(retTable).."\n"
local ret, error = gbSocket:send(msg)
if ret == nil then
@@ -193,16 +211,23 @@ function main()
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 5 == 0) then
receive()
if u8(InGame) == 0xAC and u8(APItemAddress) == 0x00 then
ItemIndex = u16(APIndex)
if deathlink_rec == true then
wU8(APDeathLinkAddress, 1)
elseif u8(APDeathLinkAddress) == 3 then
wU8(APDeathLinkAddress, 0)
deathlink_send = true
end
if ItemsReceived[ItemIndex + 1] ~= nil then
wU8(APItemAddress, ItemsReceived[ItemIndex + 1] - 172000000)
in_game = u8(InGameAddress)
if in_game == 0x2A or in_game == 0xAC then
if u8(APItemAddress) == 0x00 then
ItemIndex = u16(APIndex)
if deathlink_rec == true then
wU8(APDeathLinkAddress, 1)
elseif u8(APDeathLinkAddress) == 3 then
wU8(APDeathLinkAddress, 0)
deathlink_send = true
end
if ItemsReceived[ItemIndex + 1] ~= nil then
item_id = ItemsReceived[ItemIndex + 1] - 172000000
if item_id > 255 then
item_id = item_id - 256
end
wU8(APItemAddress, item_id)
end
end
end
end

View File

@@ -0,0 +1,702 @@
--Shamelessly based off the FF1 lua
local socket = require("socket")
local json = require('json')
local math = require('math')
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local itemMessages = {}
local consumableStacks = nil
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local zeldaSocket = nil
local frame = 0
local gameMode = 0
local cave_index
local triforce_byte
local game_state
local u8 = nil
local wU8 = nil
local isNesHawk = false
local shopsChecked = {}
local shopSlotLeft = 0x0628
local shopSlotMiddle = 0x0629
local shopSlotRight = 0x062A
--N.B.: you won't find these in a RAM map. They're flag values that the base patch derives from the cave ID.
local blueRingShopBit = 0x40
local potionShopBit = 0x02
local arrowShopBit = 0x08
local candleShopBit = 0x10
local shieldShopBit = 0x20
local takeAnyCaveBit = 0x01
local sword = 0x0657
local bombs = 0x0658
local maxBombs = 0x067C
local keys = 0x066E
local arrow = 0x0659
local bow = 0x065A
local candle = 0x065B
local recorder = 0x065C
local food = 0x065D
local waterOfLife = 0x065E
local magicalRod = 0x065F
local raft = 0x0660
local bookOfMagic = 0x0661
local ring = 0x0662
local stepladder = 0x0663
local magicalKey = 0x0664
local powerBracelet = 0x0665
local letter = 0x0666
local clockItem = 0x066C
local heartContainers = 0x066F
local partialHearts = 0x0670
local triforceFragments = 0x0671
local boomerang = 0x0674
local magicalBoomerang = 0x0675
local magicalShield = 0x0676
local rupeesToAdd = 0x067D
local rupeesToSubtract = 0x067E
local itemsObtained = 0x0677
local takeAnyCavesChecked = 0x0678
local localTriforce = 0x0679
local bonusItemsObtained = 0x067A
itemAPids = {
["Boomerang"] = 7100,
["Bow"] = 7101,
["Magical Boomerang"] = 7102,
["Raft"] = 7103,
["Stepladder"] = 7104,
["Recorder"] = 7105,
["Magical Rod"] = 7106,
["Red Candle"] = 7107,
["Book of Magic"] = 7108,
["Magical Key"] = 7109,
["Red Ring"] = 7110,
["Silver Arrow"] = 7111,
["Sword"] = 7112,
["White Sword"] = 7113,
["Magical Sword"] = 7114,
["Heart Container"] = 7115,
["Letter"] = 7116,
["Magical Shield"] = 7117,
["Candle"] = 7118,
["Arrow"] = 7119,
["Food"] = 7120,
["Water of Life (Blue)"] = 7121,
["Water of Life (Red)"] = 7122,
["Blue Ring"] = 7123,
["Triforce Fragment"] = 7124,
["Power Bracelet"] = 7125,
["Small Key"] = 7126,
["Bomb"] = 7127,
["Recovery Heart"] = 7128,
["Five Rupees"] = 7129,
["Rupee"] = 7130,
["Clock"] = 7131,
["Fairy"] = 7132
}
itemCodes = {
["Boomerang"] = 0x1D,
["Bow"] = 0x0A,
["Magical Boomerang"] = 0x1E,
["Raft"] = 0x0C,
["Stepladder"] = 0x0D,
["Recorder"] = 0x05,
["Magical Rod"] = 0x10,
["Red Candle"] = 0x07,
["Book of Magic"] = 0x11,
["Magical Key"] = 0x0B,
["Red Ring"] = 0x13,
["Silver Arrow"] = 0x09,
["Sword"] = 0x01,
["White Sword"] = 0x02,
["Magical Sword"] = 0x03,
["Heart Container"] = 0x1A,
["Letter"] = 0x15,
["Magical Shield"] = 0x1C,
["Candle"] = 0x06,
["Arrow"] = 0x08,
["Food"] = 0x04,
["Water of Life (Blue)"] = 0x1F,
["Water of Life (Red)"] = 0x20,
["Blue Ring"] = 0x12,
["Triforce Fragment"] = 0x1B,
["Power Bracelet"] = 0x14,
["Small Key"] = 0x19,
["Bomb"] = 0x00,
["Recovery Heart"] = 0x22,
["Five Rupees"] = 0x0F,
["Rupee"] = 0x18,
["Clock"] = 0x21,
["Fairy"] = 0x23
}
--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["ram"] = function() memory.usememorydomain("RAM") 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["ram"] = function() memory.usememorydomain("RAM") end
memDomain["saveram"] = function() memory.usememorydomain("WRAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
end
return memDomain
end
local memDomain = defineMemoryFunctions()
u8 = memory.read_u8
wU8 = memory.write_u8
uRange = memory.readbyterange
itemIDNames = {}
for key, value in pairs(itemAPids) do
itemIDNames[value] = key
end
local function determineItem(array)
memdomain.ram()
currentItemsObtained = u8(itemsObtained)
end
local function gotSword()
local currentSword = u8(sword)
wU8(sword, math.max(currentSword, 1))
end
local function gotWhiteSword()
local currentSword = u8(sword)
wU8(sword, math.max(currentSword, 2))
end
local function gotMagicalSword()
wU8(sword, 3)
end
local function gotBomb()
local currentBombs = u8(bombs)
local currentMaxBombs = u8(maxBombs)
wU8(bombs, math.min(currentBombs + 4, currentMaxBombs))
wU8(0x505, 0x29) -- Fake bomb to show item get.
end
local function gotArrow()
local currentArrow = u8(arrow)
wU8(arrow, math.max(currentArrow, 1))
end
local function gotSilverArrow()
wU8(arrow, 2)
end
local function gotBow()
wU8(bow, 1)
end
local function gotCandle()
local currentCandle = u8(candle)
wU8(candle, math.max(currentCandle, 1))
end
local function gotRedCandle()
wU8(candle, 2)
end
local function gotRecorder()
wU8(recorder, 1)
end
local function gotFood()
wU8(food, 1)
end
local function gotWaterOfLifeBlue()
local currentWaterOfLife = u8(waterOfLife)
wU8(waterOfLife, math.max(currentWaterOfLife, 1))
end
local function gotWaterOfLifeRed()
wU8(waterOfLife, 2)
end
local function gotMagicalRod()
wU8(magicalRod, 1)
end
local function gotBookOfMagic()
wU8(bookOfMagic, 1)
end
local function gotRaft()
wU8(raft, 1)
end
local function gotBlueRing()
local currentRing = u8(ring)
wU8(ring, math.max(currentRing, 1))
memDomain.saveram()
local currentTunicColor = u8(0x0B92)
if currentTunicColor == 0x29 then
wU8(0x0B92, 0x32)
wU8(0x0804, 0x32)
end
end
local function gotRedRing()
wU8(ring, 2)
memDomain.saveram()
wU8(0x0B92, 0x16)
wU8(0x0804, 0x16)
end
local function gotStepladder()
wU8(stepladder, 1)
end
local function gotMagicalKey()
wU8(magicalKey, 1)
end
local function gotPowerBracelet()
wU8(powerBracelet, 1)
end
local function gotLetter()
wU8(letter, 1)
end
local function gotHeartContainer()
local currentHeartContainers = bit.rshift(bit.band(u8(heartContainers), 0xF0), 4)
if currentHeartContainers < 16 then
currentHeartContainers = math.min(currentHeartContainers + 1, 16)
local currentHearts = bit.band(u8(heartContainers), 0x0F) + 1
wU8(heartContainers, bit.lshift(currentHeartContainers, 4) + currentHearts)
end
end
local function gotTriforceFragment()
local triforceByte = 0xFF
local newTriforceCount = u8(localTriforce) + 1
wU8(localTriforce, newTriforceCount)
end
local function gotBoomerang()
wU8(boomerang, 1)
end
local function gotMagicalBoomerang()
wU8(magicalBoomerang, 1)
end
local function gotMagicalShield()
wU8(magicalShield, 1)
end
local function gotRecoveryHeart()
local currentHearts = bit.band(u8(heartContainers), 0x0F)
local currentHeartContainers = bit.rshift(bit.band(u8(heartContainers), 0xF0), 4)
if currentHearts < currentHeartContainers then
currentHearts = currentHearts + 1
else
wU8(partialHearts, 0xFF)
end
currentHearts = bit.bor(bit.band(u8(heartContainers), 0xF0), currentHearts)
wU8(heartContainers, currentHearts)
end
local function gotFairy()
local currentHearts = bit.band(u8(heartContainers), 0x0F)
local currentHeartContainers = bit.rshift(bit.band(u8(heartContainers), 0xF0), 4)
if currentHearts < currentHeartContainers then
currentHearts = currentHearts + 3
if currentHearts > currentHeartContainers then
currentHearts = currentHeartContainers
wU8(partialHearts, 0xFF)
end
else
wU8(partialHearts, 0xFF)
end
currentHearts = bit.bor(bit.band(u8(heartContainers), 0xF0), currentHearts)
wU8(heartContainers, currentHearts)
end
local function gotClock()
wU8(clockItem, 1)
end
local function gotFiveRupees()
local currentRupeesToAdd = u8(rupeesToAdd)
wU8(rupeesToAdd, math.min(currentRupeesToAdd + 5, 255))
end
local function gotSmallKey()
wU8(keys, math.min(u8(keys) + 1, 9))
end
local function gotItem(item)
--Write itemCode to itemToLift
--Write 128 to itemLiftTimer
--Write 4 to sound effect queue
itemName = itemIDNames[item]
itemCode = itemCodes[itemName]
wU8(0x505, itemCode)
wU8(0x506, 128)
wU8(0x602, 4)
numberObtained = u8(itemsObtained) + 1
wU8(itemsObtained, numberObtained)
if itemName == "Boomerang" then gotBoomerang() end
if itemName == "Bow" then gotBow() end
if itemName == "Magical Boomerang" then gotMagicalBoomerang() end
if itemName == "Raft" then gotRaft() end
if itemName == "Stepladder" then gotStepladder() end
if itemName == "Recorder" then gotRecorder() end
if itemName == "Magical Rod" then gotMagicalRod() end
if itemName == "Red Candle" then gotRedCandle() end
if itemName == "Book of Magic" then gotBookOfMagic() end
if itemName == "Magical Key" then gotMagicalKey() end
if itemName == "Red Ring" then gotRedRing() end
if itemName == "Silver Arrow" then gotSilverArrow() end
if itemName == "Sword" then gotSword() end
if itemName == "White Sword" then gotWhiteSword() end
if itemName == "Magical Sword" then gotMagicalSword() end
if itemName == "Heart Container" then gotHeartContainer() end
if itemName == "Letter" then gotLetter() end
if itemName == "Magical Shield" then gotMagicalShield() end
if itemName == "Candle" then gotCandle() end
if itemName == "Arrow" then gotArrow() end
if itemName == "Food" then gotFood() end
if itemName == "Water of Life (Blue)" then gotWaterOfLifeBlue() end
if itemName == "Water of Life (Red)" then gotWaterOfLifeRed() end
if itemName == "Blue Ring" then gotBlueRing() end
if itemName == "Triforce Fragment" then gotTriforceFragment() end
if itemName == "Power Bracelet" then gotPowerBracelet() end
if itemName == "Small Key" then gotSmallKey() end
if itemName == "Bomb" then gotBomb() end
if itemName == "Recovery Heart" then gotRecoveryHeart() end
if itemName == "Five Rupees" then gotFiveRupees() end
if itemName == "Fairy" then gotFairy() end
if itemName == "Clock" then gotClock() end
end
local function StateOKForMainLoop()
memDomain.ram()
local gameMode = u8(0x12)
return gameMode == 5
end
local function checkCaveItemObtained()
memDomain.ram()
local returnTable = {}
returnTable["slot1"] = u8(shopSlotLeft)
returnTable["slot2"] = u8(shopSlotMiddle)
returnTable["slot3"] = u8(shopSlotRight)
returnTable["takeAnys"] = u8(takeAnyCavesChecked)
return returnTable
end
function table.empty (self)
for _, _ in pairs(self) do
return false
end
return true
end
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
local bizhawk_version = client.getversion()
local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_version:sub(1,3)=="2.4") or (bizhawk_version:sub(1,3)=="2.5")
local is26To28 = (bizhawk_version:sub(1,3)=="2.6") or (bizhawk_version:sub(1,3)=="2.7") or (bizhawk_version:sub(1,3)=="2.8")
local function getMaxMessageLength()
if is23Or24Or25 then
return client.screenwidth()/11
elseif is26To28 then
return client.screenwidth()/12
end
end
local function drawText(x, y, message, color)
if is23Or24Or25 then
gui.addmessage(message)
elseif is26To28 then
gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", "middle", "bottom", nil, "client")
end
end
local function clearScreen()
if is23Or24Or25 then
return
elseif is26To28 then
drawText(0, 0, "", "black")
end
end
local function drawMessages()
if table.empty(itemMessages) then
clearScreen()
return
end
local y = 10
found = false
maxMessageLength = getMaxMessageLength()
for k, v in pairs(itemMessages) do
if v["TTL"] > 0 then
message = v["message"]
while true do
drawText(5, y, message:sub(1, maxMessageLength), v["color"])
y = y + 16
message = message:sub(maxMessageLength + 1, message:len())
if message:len() == 0 then
break
end
end
newTTL = 0
if is26To28 then
newTTL = itemMessages[k]["TTL"] - 1
end
itemMessages[k]["TTL"] = newTTL
found = true
end
end
if found == false then
clearScreen()
end
end
function generateOverworldLocationChecked()
memDomain.ram()
data = uRange(0x067E, 0x81)
data[0] = nil
return data
end
function getHCLocation()
memDomain.rom()
data = u8(0x1789A)
return data
end
function getPBLocation()
memDomain.rom()
data = u8(0x10CB2)
return data
end
function generateUnderworld16LocationChecked()
memDomain.ram()
data = uRange(0x06FE, 0x81)
data[0] = nil
return data
end
function generateUnderworld79LocationChecked()
memDomain.ram()
data = uRange(0x077E, 0x81)
data[0] = nil
return data
end
function updateTriforceFragments()
memDomain.ram()
local triforceByte = 0xFF
totalTriforceCount = u8(localTriforce)
local currentPieces = bit.rshift(triforceByte, 8 - math.min(8, totalTriforceCount))
wU8(triforceFragments, currentPieces)
end
function processBlock(block)
if block ~= nil then
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 bonusItems = block["bonusItems"]
if bonusItems ~= nil and isInGame then
for i, item in ipairs(bonusItems) do
memDomain.ram()
if i > u8(bonusItemsObtained) then
if u8(0x505) == 0 then
gotItem(item)
wU8(itemsObtained, u8(itemsObtained) - 1)
wU8(bonusItemsObtained, u8(bonusItemsObtained) + 1)
end
end
end
end
local itemsBlock = block["items"]
memDomain.saveram()
isInGame = StateOKForMainLoop()
updateTriforceFragments()
if itemsBlock ~= nil and isInGame then
memDomain.ram()
--get item from item code
--get function from item
--do function
for i, item in ipairs(itemsBlock) do
memDomain.ram()
if u8(0x505) == 0 then
if i > u8(itemsObtained) then
gotItem(item)
end
end
end
end
local shopsBlock = block["shops"]
if shopsBlock ~= nil then
wU8(shopSlotLeft, bit.bor(u8(shopSlotLeft), shopsBlock["left"]))
wU8(shopSlotMiddle, bit.bor(u8(shopSlotMiddle), shopsBlock["middle"]))
wU8(shopSlotRight, bit.bor(u8(shopSlotRight), shopsBlock["right"]))
end
end
end
function difference(a, b)
local aa = {}
for k,v in pairs(a) do aa[v]=true end
for k,v in pairs(b) do aa[v]=nil end
local ret = {}
local n = 0
for k,v in pairs(a) do
if aa[v] then n=n+1 ret[n]=v end
end
return ret
end
function receive()
l, e = zeldaSocket: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(0x1F, 0x11)
playerName[0] = nil
local retTable = {}
retTable["playerName"] = playerName
if StateOKForMainLoop() then
retTable["overworld"] = generateOverworldLocationChecked()
retTable["underworld1"] = generateUnderworld16LocationChecked()
retTable["underworld2"] = generateUnderworld79LocationChecked()
end
retTable["caves"] = checkCaveItemObtained()
memDomain.ram()
if gameMode ~= 19 then
gameMode = u8(0x12)
end
retTable["gameMode"] = gameMode
retTable["overworldHC"] = getHCLocation()
retTable["overworldPB"] = getPBLocation()
retTable["itemsObtained"] = u8(itemsObtained)
msg = json.encode(retTable).."\n"
local ret, error = zeldaSocket: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 (is23Or24Or25 or is26To28) == false then
print("Must use a version of bizhawk 2.3.1 or higher")
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 Zelda1Client.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
zeldaSocket = client
zeldaSocket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

BIN
data/lua/TLoZ/core.dll Normal file

Binary file not shown.

380
data/lua/TLoZ/json.lua Normal file
View File

@@ -0,0 +1,380 @@
--
-- json.lua
--
-- Copyright (c) 2015 rxi
--
-- This library is free software; you can redistribute it and/or modify it
-- under the terms of the MIT license. See LICENSE for details.
--
local json = { _version = "0.1.0" }
-------------------------------------------------------------------------------
-- Encode
-------------------------------------------------------------------------------
local encode
local escape_char_map = {
[ "\\" ] = "\\\\",
[ "\"" ] = "\\\"",
[ "\b" ] = "\\b",
[ "\f" ] = "\\f",
[ "\n" ] = "\\n",
[ "\r" ] = "\\r",
[ "\t" ] = "\\t",
}
local escape_char_map_inv = { [ "\\/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function escape_char(c)
return escape_char_map[c] or string.format("\\u%04x", c:byte())
end
local function encode_nil(val)
return "null"
end
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference?
if stack[val] then error("circular reference") end
stack[val] = true
if val[1] ~= nil or next(val) == nil then
-- Treat as array -- check keys are valid and it is not sparse
local n = 0
for k in pairs(val) do
if type(k) ~= "number" then
error("invalid table: mixed or invalid key types")
end
n = n + 1
end
if n ~= #val then
error("invalid table: sparse array")
end
-- Encode
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Treat as an object
for k, v in pairs(val) do
if type(k) ~= "string" then
error("invalid table: mixed or invalid key types")
end
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
error("unexpected number value '" .. tostring(val) .. "'")
end
return string.format("%.14g", val)
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
error("unexpected type '" .. t .. "'")
end
function json.encode(val)
return ( encode(val) )
end
-------------------------------------------------------------------------------
-- Decode
-------------------------------------------------------------------------------
local parse
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")
local literal_map = {
[ "true" ] = true,
[ "false" ] = false,
[ "null" ] = nil,
}
local function next_char(str, idx, set, negate)
for i = idx, #str do
if set[str:sub(i, i)] ~= negate then
return i
end
end
return #str + 1
end
local function decode_error(str, idx, msg)
--local line_count = 1
--local col_count = 1
--for i = 1, idx - 1 do
-- col_count = col_count + 1
-- if str:sub(i, i) == "\n" then
-- line_count = line_count + 1
-- col_count = 1
-- end
-- end
-- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(3, 6), 16 )
local n2 = tonumber( s:sub(9, 12), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local has_unicode_escape = false
local has_surrogate_escape = false
local has_escape = false
local last
for j = i + 1, #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
end
if last == 92 then -- "\\" (escape char)
if x == 117 then -- "u" (unicode escape sequence)
local hex = str:sub(j + 1, j + 5)
if not hex:find("%x%x%x%x") then
decode_error(str, j, "invalid unicode escape in string")
end
if hex:find("^[dD][89aAbB]") then
has_surrogate_escape = true
else
has_unicode_escape = true
end
else
local c = string.char(x)
if not escape_chars[c] then
decode_error(str, j, "invalid escape char '" .. c .. "' in string")
end
has_escape = true
end
last = nil
elseif x == 34 then -- '"' (end of string)
local s = str:sub(i + 1, j - 1)
if has_surrogate_escape then
s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape)
end
if has_unicode_escape then
s = s:gsub("\\u....", parse_unicode_escape)
end
if has_escape then
s = s:gsub("\\.", escape_char_map_inv)
end
return s, j + 1
else
last = x
end
end
decode_error(str, i, "expected closing quote for string")
end
local function parse_number(str, i)
local x = next_char(str, i, delim_chars)
local s = str:sub(i, x - 1)
local n = tonumber(s)
if not n then
decode_error(str, i, "invalid number '" .. s .. "'")
end
return n, x
end
local function parse_literal(str, i)
local x = next_char(str, i, delim_chars)
local word = str:sub(i, x - 1)
if not literals[word] then
decode_error(str, i, "invalid literal '" .. word .. "'")
end
return literal_map[word], x
end
local function parse_array(str, i)
local res = {}
local n = 1
i = i + 1
while 1 do
local x
i = next_char(str, i, space_chars, true)
-- Empty / end of array?
if str:sub(i, i) == "]" then
i = i + 1
break
end
-- Read token
x, i = parse(str, i)
res[n] = x
n = n + 1
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "]" then break end
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
end
return res, i
end
local function parse_object(str, i)
local res = {}
i = i + 1
while 1 do
local key, val
i = next_char(str, i, space_chars, true)
-- Empty / end of object?
if str:sub(i, i) == "}" then
i = i + 1
break
end
-- Read key
if str:sub(i, i) ~= '"' then
decode_error(str, i, "expected string for key")
end
key, i = parse(str, i)
-- Read ':' delimiter
i = next_char(str, i, space_chars, true)
if str:sub(i, i) ~= ":" then
decode_error(str, i, "expected ':' after key")
end
i = next_char(str, i + 1, space_chars, true)
-- Read value
val, i = parse(str, i)
-- Set
res[key] = val
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "}" then break end
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
end
return res, i
end
local char_func_map = {
[ '"' ] = parse_string,
[ "0" ] = parse_number,
[ "1" ] = parse_number,
[ "2" ] = parse_number,
[ "3" ] = parse_number,
[ "4" ] = parse_number,
[ "5" ] = parse_number,
[ "6" ] = parse_number,
[ "7" ] = parse_number,
[ "8" ] = parse_number,
[ "9" ] = parse_number,
[ "-" ] = parse_number,
[ "t" ] = parse_literal,
[ "f" ] = parse_literal,
[ "n" ] = parse_literal,
[ "[" ] = parse_array,
[ "{" ] = parse_object,
}
parse = function(str, idx)
local chr = str:sub(idx, idx)
local f = char_func_map[chr]
if f then
return f(str, idx)
end
decode_error(str, idx, "unexpected character '" .. chr .. "'")
end
function json.decode(str)
if type(str) ~= "string" then
error("expected argument of type string, got " .. type(str))
end
return ( parse(str, next_char(str, 1, space_chars, true)) )
end
return json

132
data/lua/TLoZ/socket.lua Normal file
View File

@@ -0,0 +1,132 @@
-----------------------------------------------------------------------------
-- LuaSocket helper module
-- Author: Diego Nehab
-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
-- Declare module and import dependencies
-----------------------------------------------------------------------------
local base = _G
local string = require("string")
local math = require("math")
local socket = require("socket.core")
module("socket")
-----------------------------------------------------------------------------
-- Exported auxiliar functions
-----------------------------------------------------------------------------
function connect(address, port, laddress, lport)
local sock, err = socket.tcp()
if not sock then return nil, err end
if laddress then
local res, err = sock:bind(laddress, lport, -1)
if not res then return nil, err end
end
local res, err = sock:connect(address, port)
if not res then return nil, err end
return sock
end
function bind(host, port, backlog)
local sock, err = socket.tcp()
if not sock then return nil, err end
sock:setoption("reuseaddr", true)
local res, err = sock:bind(host, port)
if not res then return nil, err end
res, err = sock:listen(backlog)
if not res then return nil, err end
return sock
end
try = newtry()
function choose(table)
return function(name, opt1, opt2)
if base.type(name) ~= "string" then
name, opt1, opt2 = "default", name, opt1
end
local f = table[name or "nil"]
if not f then base.error("unknown key (".. base.tostring(name) ..")", 3)
else return f(opt1, opt2) end
end
end
-----------------------------------------------------------------------------
-- Socket sources and sinks, conforming to LTN12
-----------------------------------------------------------------------------
-- create namespaces inside LuaSocket namespace
sourcet = {}
sinkt = {}
BLOCKSIZE = 2048
sinkt["close-when-done"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if not chunk then
sock:close()
return 1
else return sock:send(chunk) end
end
})
end
sinkt["keep-open"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if chunk then return sock:send(chunk)
else return 1 end
end
})
end
sinkt["default"] = sinkt["keep-open"]
sink = choose(sinkt)
sourcet["by-length"] = function(sock, length)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if length <= 0 then return nil end
local size = math.min(socket.BLOCKSIZE, length)
local chunk, err = sock:receive(size)
if err then return nil, err end
length = length - string.len(chunk)
return chunk
end
})
end
sourcet["until-closed"] = function(sock)
local done
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if done then return nil end
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
if not err then return chunk
elseif err == "closed" then
sock:close()
done = 1
return partial
else return nil, err end
end
})
end
sourcet["default"] = sourcet["until-closed"]
source = choose(sourcet)

View File

@@ -0,0 +1,137 @@
-- SPDX-FileCopyrightText: 2023 Wilhelm Schürmann <wimschuermann@googlemail.com>
--
-- SPDX-License-Identifier: MIT
-- This script attempts to implement the basic functionality needed in order for
-- the LADXR Archipelago client to be able to talk to BizHawk instead of RetroArch
-- by reproducing the RetroArch API with BizHawk's Lua interface.
--
-- RetroArch UDP API: https://github.com/libretro/RetroArch/blob/master/command.c
--
-- Only
-- VERSION
-- GET_STATUS
-- READ_CORE_MEMORY
-- WRITE_CORE_MEMORY
-- commands are supported right now.
--
-- USAGE:
-- Load this script in BizHawk ("Tools" -> "Lua Console" -> "Script" -> "Open Script")
--
-- All inconsistencies (like missing newlines for some commands) of the RetroArch
-- UDP API (network_cmd_enable) are reproduced as-is in order for clients written to work with
-- RetroArch's current API to "just work"(tm).
--
-- This script has only been tested on GB(C). If you have made sure it works for N64 or other
-- cores supported by BizHawk, please let me know. Note that GET_STATUS, at the very least, will
-- have to be adjusted.
--
--
-- NOTE:
-- BizHawk's Lua API is very trigger-happy on throwing exceptions.
-- Emulation will continue fine, but the RetroArch API layer will stop working. This
-- is indicated only by an exception visible in the Lua console, which most players
-- will probably not have in the foreground.
--
-- pcall(), the usual way to catch exceptions in Lua, doesn't appear to be supported at all,
-- meaning that error/exception handling is not easily possible.
--
-- This means that a lot more error checking would need to happen before e.g. reading/writing
-- memory. Since the end goal, according to AP's Discord, seems to be SNI integration of GB(C),
-- no further fault-proofing has been done on this.
--
local socket = require("socket")
local udp = socket.udp()
udp:setsockname('127.0.0.1', 55355)
udp:settimeout(0)
while true do
-- Attempt to lessen the CPU load by only polling the UDP socket every x frames.
-- x = 10 is entirely arbitrary, very little thought went into it.
-- We could try to make use of client.get_approx_framerate() here, but the values returned
-- seemed more or less arbitrary as well.
--
-- NOTE: Never mind the above, the LADXR Archipelago client appears to run into problems with
-- interwoven GET_STATUS calls, leading to stopped communication.
-- For GB(C), polling the socket on every frame is OK-ish, so we just do that.
--
--while emu.framecount() % 10 ~= 0 do
-- emu.frameadvance()
--end
local data, msg_or_ip, port_or_nil = udp:receivefrom()
if data then
-- "data" format is "COMMAND [PARAMETERS] [...]"
local command = string.match(data, "%S+")
if command == "VERSION" then
-- 1.14 is the latest RetroArch release at the time of writing this, no other reason
-- for choosing this here.
udp:sendto("1.14.0\n", msg_or_ip, port_or_nil)
elseif command == "GET_STATUS" then
local status = "PLAYING"
if client.ispaused() then
status = "PAUSED"
end
if emu.getsystemid() == "GBC" then
-- Actual reply from RetroArch's API:
-- "GET_STATUS PLAYING game_boy,AP_62468482466172374046_P1_Lonk,crc32=3ecb7b6f"
-- CRC32 isn't readily available through the Lua API. We could calculate
-- it ourselves, but since LADXR doesn't make use of this field it is
-- simply replaced by the hash that BizHawk _does_ make available.
udp:sendto(
"GET_STATUS " .. status .. " game_boy," ..
string.gsub(gameinfo.getromname(), "[%s,]", "_") ..
",romhash=" ..
gameinfo.getromhash() .. "\n",
msg_or_ip, port_or_nil
)
else -- No ROM loaded
-- NOTE: No newline is intentional here for 1:1 RetroArch compatibility
udp:sendto("GET_STATUS CONTENTLESS", msg_or_ip, port_or_nil)
end
elseif command == "READ_CORE_MEMORY" then
local _, address, length = string.match(data, "(%S+) (%S+) (%S+)")
address = tonumber(address, 16)
length = tonumber(length)
-- NOTE: mainmemory.read_bytes_as_array() would seem to be the obvious choice
-- here instead, but it isn't. At least for Sameboy and Gambatte, the "main"
-- memory differs (ROM vs WRAM).
-- Using memory.read_bytes_as_array() and explicitly using the System Bus
-- as the active memory domain solves this incompatibility, allowing us
-- to hopefully use whatever GB(C) emulator we want.
local mem = memory.read_bytes_as_array(address, length, "System Bus")
local hex_string = ""
for _, v in ipairs(mem) do
hex_string = hex_string .. string.format("%02X ", v)
end
hex_string = hex_string:sub(1, -2) -- Hang head in shame, remove last " "
local reply = string.format("%s %02x %s\n", command, address, hex_string)
udp:sendto(reply, msg_or_ip, port_or_nil)
elseif command == "WRITE_CORE_MEMORY" then
local _, address = string.match(data, "(%S+) (%S+)")
address = tonumber(address, 16)
local to_write = {}
local i = 1
for byte_str in string.gmatch(data, "%S+") do
if i > 2 then
table.insert(to_write, tonumber(byte_str, 16))
end
i = i + 1
end
memory.write_bytes_as_array(address, to_write, "System Bus")
local reply = string.format("%s %02x %d\n", command, address, i - 3)
udp:sendto(reply, msg_or_ip, port_or_nil)
end
end
emu.frameadvance()
end

BIN
data/lua/core.dll Normal file

Binary file not shown.

132
data/lua/socket.lua Normal file
View File

@@ -0,0 +1,132 @@
-----------------------------------------------------------------------------
-- LuaSocket helper module
-- Author: Diego Nehab
-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
-- Declare module and import dependencies
-----------------------------------------------------------------------------
local base = _G
local string = require("string")
local math = require("math")
local socket = require("socket.core")
module("socket")
-----------------------------------------------------------------------------
-- Exported auxiliar functions
-----------------------------------------------------------------------------
function connect(address, port, laddress, lport)
local sock, err = socket.tcp()
if not sock then return nil, err end
if laddress then
local res, err = sock:bind(laddress, lport, -1)
if not res then return nil, err end
end
local res, err = sock:connect(address, port)
if not res then return nil, err end
return sock
end
function bind(host, port, backlog)
local sock, err = socket.tcp()
if not sock then return nil, err end
sock:setoption("reuseaddr", true)
local res, err = sock:bind(host, port)
if not res then return nil, err end
res, err = sock:listen(backlog)
if not res then return nil, err end
return sock
end
try = newtry()
function choose(table)
return function(name, opt1, opt2)
if base.type(name) ~= "string" then
name, opt1, opt2 = "default", name, opt1
end
local f = table[name or "nil"]
if not f then base.error("unknown key (".. base.tostring(name) ..")", 3)
else return f(opt1, opt2) end
end
end
-----------------------------------------------------------------------------
-- Socket sources and sinks, conforming to LTN12
-----------------------------------------------------------------------------
-- create namespaces inside LuaSocket namespace
sourcet = {}
sinkt = {}
BLOCKSIZE = 2048
sinkt["close-when-done"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if not chunk then
sock:close()
return 1
else return sock:send(chunk) end
end
})
end
sinkt["keep-open"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if chunk then return sock:send(chunk)
else return 1 end
end
})
end
sinkt["default"] = sinkt["keep-open"]
sink = choose(sinkt)
sourcet["by-length"] = function(sock, length)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if length <= 0 then return nil end
local size = math.min(socket.BLOCKSIZE, length)
local chunk, err = sock:receive(size)
if err then return nil, err end
length = length - string.len(chunk)
return chunk
end
})
end
sourcet["until-closed"] = function(sock)
local done
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if done then return nil end
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
if not err then return chunk
elseif err == "closed" then
sock:close()
done = 1
return partial
else return nil, err end
end
})
end
sourcet["default"] = sourcet["until-closed"]
source = choose(sourcet)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

After

Width:  |  Height:  |  Size: 535 KiB

View File

@@ -75,6 +75,18 @@ flowchart LR
end
SNI <-- Various, depending on SNES device --> DK3
%% Super Mario World
subgraph Super Mario World
SMW[SNES]
end
SNI <-- Various, depending on SNES device --> SMW
%% Lufia II Ancient Cave
subgraph Lufia II Ancient Cave
L2AC[SNES]
end
SNI <-- Various, depending on SNES device --> L2AC
%% Native Clients or Games
%% Games or clients which compile to native or which the client is integrated in the game.
subgraph "Native"

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -64,18 +64,19 @@ These packets are are sent from the multiworld server to the client. They are no
### RoomInfo
Sent to clients when they connect to an Archipelago server.
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| version | [NetworkVersion](#NetworkVersion) | Object denoting the version of Archipelago which the server is running. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` |
| password | bool | Denoted whether a password is required to join this room.|
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
| hint_cost | int | The amount of points it costs to receive a hint from the server. |
| location_check_points | int | The amount of hint points you receive per item/location check completed. ||
| games | list\[str\] | List of games present in this multiworld. |
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. Used to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents). |
| seed_name | str | uniquely identifying name of this generation |
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
| Name | Type | Notes |
|-----------------------|-----------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| version | [NetworkVersion](#NetworkVersion) | Object denoting the version of Archipelago which the server is running. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` |
| password | bool | Denoted whether a password is required to join this room. |
| permissions | dict\[str, [Permission](#Permission)\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "release", "collect" and "remaining". |
| hint_cost | int | The percentage of total locations that need to be checked to receive a hint from the server. |
| location_check_points | int | The amount of hint points you receive per item/location check completed. |
| games | list\[str\] | List of games present in this multiworld. |
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. Used to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents). **Deprecated. Use `datapackage_checksums` instead.** |
| datapackage_checksums | dict[str, str] | Checksum hash of the individual games' data packages the server will send. Used by newer clients to decide which games' caches are outdated. See [Data Package Contents](#Data-Package-Contents) for more information. |
| seed_name | str | Uniquely identifying name of this generation |
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
#### release
Dictates what is allowed when it comes to a player releasing their run. A release is an action which distributes the rest of the items in a player's run to those other players awaiting them.
@@ -106,8 +107,8 @@ Dictates what is allowed when it comes to a player querying the items remaining
### ConnectionRefused
Sent to clients when the server refuses connection. This is sent during the initial connection handshake.
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| Name | Type | Notes |
|--------|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| errors | list\[str\] | Optional. When provided, should contain any one of: `InvalidSlot`, `InvalidGame`, `IncompatibleVersion`, `InvalidPassword`, or `InvalidItemsHandling`. |
InvalidSlot indicates that the sent 'name' field did not match any auth entry on the server.
@@ -554,12 +555,16 @@ Color options:
`flags` contains the [NetworkItem](#NetworkItem) flags that belong to the item
### Client States
An enumeration containing the possible client states that may be used to inform the server in [StatusUpdate](#StatusUpdate).
An enumeration containing the possible client states that may be used to inform
the server in [StatusUpdate](#StatusUpdate). The MultiServer automatically sets
the client state to `ClientStatus.CLIENT_CONNECTED` on the first active connection
to a slot.
```python
import enum
class ClientStatus(enum.IntEnum):
CLIENT_UNKNOWN = 0
CLIENT_CONNECTED = 5
CLIENT_READY = 10
CLIENT_PLAYING = 20
CLIENT_GOAL = 30
@@ -644,11 +649,12 @@ Note:
#### GameData
GameData is a **dict** but contains these keys and values. It's broken out into another "type" for ease of documentation.
| Name | Type | Notes |
| ---- | ---- | ----- |
| item_name_to_id | dict[str, int] | Mapping of all item names to their respective ID. |
| location_name_to_id | dict[str, int] | Mapping of all location names to their respective ID. |
| version | int | Version number of this game's data |
| Name | Type | Notes |
|---------------------|----------------|-------------------------------------------------------------------------------------------------------------------------------|
| item_name_to_id | dict[str, int] | Mapping of all item names to their respective ID. |
| location_name_to_id | dict[str, int] | Mapping of all location names to their respective ID. |
| version | int | Version number of this game's data. Deprecated. Used by older clients to request an updated datapackage if cache is outdated. |
| checksum | str | A checksum hash of this game's data. |
### Tags
Tags are represented as a list of strings, the common Client tags follow:

188
docs/options api.md Normal file
View File

@@ -0,0 +1,188 @@
# Archipelago Options API
This document covers some of the generic options available using Archipelago's options handling system.
For more information on where these options go in your world please refer to:
- [world api.md](/docs/world%20api.md)
Archipelago will be abbreviated as "AP" from now on.
## Option Definitions
Option parsing in AP is done using different Option classes. For each option you would like to have in your game, you
need to create:
- A new option class with a docstring detailing what the option will do to your user.
- A `display_name` to be displayed on the webhost.
- A new entry in the `option_definitions` dict for your World.
By style and convention, the internal names should be snake_case. If the option supports having multiple sub_options
such as Choice options, these can be defined with `option_my_sub_option`, where the preceding `option_` is required and
stripped for users, so will show as `my_sub_option` in yaml files and if `auto_display_name` is True `My Sub Option`
on the webhost. All options support `random` as a generic option. `random` chooses from any of the available
values for that option, and is reserved by AP. You can set this as your default value but you cannot define your own
new `option_random`.
### Option Creation
As an example, suppose we want an option that lets the user start their game with a sword in their inventory. Let's
create our option class (with a docstring), give it a `display_name`, and add it to a dictionary that keeps track of our
options:
```python
# Options.py
class StartingSword(Toggle):
"""Adds a sword to your starting inventory."""
display_name = "Start With Sword"
example_options = {
"starting_sword": StartingSword
}
```
This will create a `Toggle` option, internally called `starting_sword`. To then submit this to the multiworld, we add it
to our world's `__init__.py`:
```python
from worlds.AutoWorld import World
from .Options import options
class ExampleWorld(World):
option_definitions = options
```
### Option Checking
Options are parsed by `Generate.py` before the worlds are created, and then the option classes are created shortly after
world instantiation. These are created as attributes on the MultiWorld and can be accessed with
`self.multiworld.my_option_name[self.player]`. This is the option class, which supports direct comparison methods to
relevant objects (like comparing a Toggle class to a `bool`). If you need to access the option result directly, this is
the option class's `value` attribute. For our example above we can do a simple check:
```python
if self.multiworld.starting_sword[self.player]:
do_some_things()
```
or if I need a boolean object, such as in my slot_data I can access it as:
```python
start_with_sword = bool(self.multiworld.starting_sword[self.player].value)
```
## Generic Option Classes
These options are generically available to every game automatically, but can be overridden for slightly different
behavior, if desired. See `worlds/soe/Options.py` for an example.
### Accessibility
Sets rules for availability of locations for the player. `Items` is for all items available but not necessarily all
locations, such as self-locking keys, but needs to be set by the world for this to be different from locations access.
### ProgressionBalancing
Algorithm for moving progression items into earlier spheres to make the gameplay experience a bit smoother. Can be
overridden if you want a different default value.
### LocalItems
Forces the players' items local to their world.
### NonLocalItems
Forces the players' items outside their world.
### StartInventory
Allows the player to define a dictionary of starting items with item name and quantity.
### StartHints
Gives the player starting hints for where the items defined here are.
### StartLocationHints
Gives the player starting hints for the items on locations defined here.
### ExcludeLocations
Marks locations given here as `LocationProgressType.Excluded` so that progression items can't be placed on them.
### PriorityLocations
Marks locations given here as `LocationProgressType.Priority` forcing progression items on them.
### ItemLinks
Allows users to share their item pool with other players. Currently item links are per game. A link of one game between
two players will combine their items in the link into a single item, which then gets replaced with `World.create_filler()`.
## Basic Option Classes
### Toggle
The example above. This simply has 0 and 1 as its available results with 0 (false) being the default value. Cannot be
compared to strings but can be directly compared to True and False.
### DefaultOnToggle
Like Toggle, but 1 (true) is the default value.
### Choice
A numeric option allowing you to define different sub options. Values are stored as integers, but you can also do
comparison methods with the class and strings, so if you have an `option_early_sword`, this can be compared with:
```python
if self.multiworld.sword_availability[self.player] == "early_sword":
do_early_sword_things()
```
or:
```python
from .Options import SwordAvailability
if self.multiworld.sword_availability[self.player] == SwordAvailability.option_early_sword:
do_early_sword_things()
```
### Range
A numeric option allowing a variety of integers including the endpoints. Has a default `range_start` of 0 and default
`range_end` of 1. Allows for negative values as well. This will always be an integer and has no methods for string
comparisons.
### SpecialRange
Like range but also allows you to define a dictionary of special names the user can use to equate to a specific value.
For example:
```python
special_range_names: {
"normal": 20,
"extreme": 99,
}
```
will let users use the names "normal" or "extreme" in their options selections, but will still return those as integers
to you. Useful if you want special handling regarding those specified values.
## More Advanced Options
### FreeText
This is an option that allows the user to enter any possible string value. Can only be compared with strings, and has
no validation step, so if this needs to be validated, you can either add a validation step to the option class or
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 `Union[str, int]` so if you need the value at a specified
point, `self.multiworld.my_option[self.player].current_key` will always return a string.
### PlandoBosses
An option specifically built for handling boss rando, if your game can use it. Is a subclass of TextChoice so supports
everything it does, as well as having multiple validation steps to automatically support boss plando from users. If
using this class, you must define `bosses`, a set of valid boss names, and `locations`, a set of valid boss location
names, and `def can_place_boss`, which passes a boss and location, allowing you to check if that placement is valid for
your game. When this function is called, `bosses`, `locations`, and the passed strings will all be lowercase. There is
also a `duplicate_bosses` attribute allowing you to define if a boss can be placed multiple times in your world. False
by default, and will reject duplicate boss names from the user. For an example of using this class, refer to
`worlds.alttp.options.py`
### OptionDict
This option returns a dictionary. Setting a default here is recommended as it will output the dictionary to the
template. If you set a [Schema](https://pypi.org/project/schema/) on the class with `schema = Schema()`, then the
options system will automatically validate the user supplied data against the schema to ensure it's in the correct
format.
### ItemDict
Like OptionDict, except this will verify that every key in the dictionary is a valid name for an item for your world.
### OptionList
This option defines a List, where the user can add any number of strings to said list, allowing duplicate values. You
can define a set of keys in `valid_keys`, and a default list if you want certain options to be available without editing
for this. If `valid_keys_casefold` is true, the verification will be case-insensitive; `verify_item_name` will check
that each value is a valid item name; and`verify_location_name` will check that each value is a valid location name.
### OptionSet
Like OptionList, but returns a set, preventing duplicates.
### ItemSet
Like OptionSet, but will verify that all the items in the set are a valid name for an item for your world.

View File

@@ -7,10 +7,11 @@ use that version. These steps are for developers or platforms without compiled r
## General
What you'll need:
* Python 3.8.7 or newer
* pip (Depending on platform may come included)
* A C compiler
* possibly optional, read OS-specific sections
* [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version
* **Python 3.11 does not work currently**
* pip: included in downloads from python.org, separate in many Linux distributions
* Matching C compiler
* possibly optional, read operating system specific sections
Then run any of the starting point scripts, like Generate.py, and the included ModuleUpdater should prompt to install or update the
required modules and after pressing enter proceed to install everything automatically.
@@ -29,6 +30,8 @@ After this, you should be able to run the programs.
Recommended steps
* Download and install a "Windows installer (64-bit)" from the [Python download page](https://www.python.org/downloads)
* **Python 3.11 does not work currently**
* Download and install full Visual Studio from
[Visual Studio Downloads](https://visualstudio.microsoft.com/downloads/)
or an older "Build Tools for Visual Studio" from
@@ -40,6 +43,8 @@ Recommended steps
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
* Run Generate.py which will prompt installation of missing modules, press enter to confirm
* In PyCharm: right-click Generate.py and select `Run 'Generate'`
* Without PyCharm: open a command prompt in the source folder and type `py Generate.py`
## macOS
@@ -59,7 +64,7 @@ setting in host.yaml at your Enemizer executable.
## Optional: SNI
SNI is required to use SNIClient. If not integrated into the project, it has to be started manually.
[SNI](https://github.com/alttpo/sni/blob/main/README.md) is required to use SNIClient. If not integrated into the project, it has to be started manually.
You can get the latest SNI release at [SNI Github releases](https://github.com/alttpo/sni/releases).
It should be dropped as "SNI" into the root folder of the project. Alternatively, you can point the sni setting in

View File

@@ -48,5 +48,5 @@
# TODO
#JSON_AS_ASCII: false
# Patch target. This is the address encoded into the patch that will be used for client auto-connect.
#PATCH_TARGET: archipelago.gg
# Host Address. This is the address encoded into the patch that will be used for client auto-connect.
#HOST_ADDRESS: archipelago.gg

View File

@@ -364,14 +364,9 @@ class MyGameLocation(Location): # or from Locations import MyGameLocation
class MyGameWorld(World):
"""Insert description of the world/game here."""
game: str = "My Game" # name of the game/world
game = "My Game" # name of the game/world
option_definitions = mygame_options # options the player can set
topology_present: bool = True # show path to required location checks in spoiler
# data_version is used to signal that items, locations or their names
# changed. Set this to 0 during development so other games' clients do not
# cache any texts, then increase by 1 for each release that makes changes.
data_version = 0
topology_present = True # show path to required location checks in spoiler
# ID of first item and location, could be hard-coded but code may be easier
# to read with this as a propery.

View File

@@ -93,6 +93,10 @@ sni_options:
lttp_options:
# File name of the v1.0 J rom
rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
ladx_options:
# File name of the Link's Awakening DX rom
rom_file: "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc"
lufia2ac_options:
# File name of the US rom
rom_file: "Lufia II - Rise of the Sinistrals (USA).sfc"
@@ -107,7 +111,7 @@ factorio_options:
filter_item_sends: false
# Whether to send chat messages from players on the Factorio server to Archipelago.
bridge_chat_out: true
minecraft_options:
minecraft_options:
forge_directory: "Minecraft Forge server"
max_heap_size: "2G"
# release channel, currently "release", or "beta"
@@ -125,6 +129,15 @@ soe_options:
rom_file: "Secret of Evermore (USA).sfc"
ffr_options:
display_msgs: true
tloz_options:
# File name of the Zelda 1
rom_file: "Legend of Zelda, The (U) (PRG0) [!].nes"
# Set this to false to never autostart a rom (such as after patching)
# true for operating system default program
# Alternatively, a path to a program to open the .nes file with
rom_start: true
# Display message inside of Bizhawk
display_msgs: true
dkc3_options:
# File name of the DKC3 US rom
rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
@@ -154,3 +167,25 @@ zillion_options:
# RetroArch doesn't make it easy to launch a game from the command line.
# You have to know the path to the emulator core library on the user's computer.
rom_start: "retroarch"
adventure_options:
# File name of the standard NTSC Adventure rom.
# The licensed "The 80 Classic Games" CD-ROM contains this.
# It may also have a .a26 extension
rom_file: "ADVNTURE.BIN"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program for '.a26'
# Alternatively, a path to a program to open the .a26 file with (generally EmuHawk for multiworld)
rom_start: true
# Optional, additional args passed into rom_start before the .bin file
# For example, this can be used to autoload the connector script in BizHawk
# (see BizHawk --lua= option)
# Windows example:
# rom_args: "--lua=C:/ProgramData/Archipelago/data/lua/ADVENTURE/adventure_connector.lua"
rom_args: " "
# Set this to true to display item received messages in Emuhawk
display_msgs: true

View File

@@ -25,9 +25,9 @@ OutputBaseFilename=Setup {#MyAppName} {#MyAppVersionText}
Compression=lzma2
SolidCompression=yes
LZMANumBlockThreads=8
ArchitecturesInstallIn64BitMode=x64
ArchitecturesInstallIn64BitMode=x64 arm64
ChangesAssociations=yes
ArchitecturesAllowed=x64
ArchitecturesAllowed=x64 arm64
AllowNoIcons=yes
SetupIconFile={#MyAppIcon}
UninstallDisplayIcon={app}\{#MyAppExeName}
@@ -63,6 +63,8 @@ Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full
Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning
Name: "generator/pkmn_r"; Description: "Pokemon Red ROM Setup"; Types: full hosting
Name: "generator/pkmn_b"; Description: "Pokemon Blue ROM Setup"; Types: full hosting
Name: "generator/ladx"; Description: "Link's Awakening DX ROM Setup"; Types: full hosting
Name: "generator/tloz"; Description: "The Legend of Zelda ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 135168; Flags: disablenouninstallwarning
Name: "server"; Description: "Server"; Types: full hosting
Name: "client"; Description: "Clients"; Types: full playing
Name: "client/sni"; Description: "SNI Client"; Types: full playing
@@ -72,15 +74,20 @@ Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch
Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/l2ac"; Description: "SNI Client - Lufia II Ancient Cave Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "client/kh2"; Description: "Kingdom Hearts 2"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
Name: "client/pkmn"; Description: "Pokemon Client"
Name: "client/pkmn/red"; Description: "Pokemon Client - Pokemon Red Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/pkmn/blue"; Description: "Pokemon Client - Pokemon Blue Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/ladx"; Description: "Link's Awakening Client"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
Name: "client/wargroove"; Description: "Wargroove"; Types: full playing
Name: "client/zl"; Description: "Zillion"; Types: full playing
Name: "client/tloz"; Description: "The Legend of Zelda"; Types: full playing
Name: "client/advn"; Description: "Adventure"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs]
@@ -97,15 +104,20 @@ Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda
Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl
Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r
Source: "{code:GetBlueROMPath}"; DestDir: "{app}"; DestName: "Pokemon Blue (UE) [S][!].gb"; Flags: external; Components: client/pkmn/blue or generator/pkmn_b
Source: "{code:GetLADXROMPath}"; DestDir: "{app}"; DestName: "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc"; Flags: external; Components: client/ladx or generator/ladx
Source: "{code:GetTLoZROMPath}"; DestDir: "{app}"; DestName: "Legend of Zelda, The (U) (PRG0) [!].nes"; Flags: external; Components: client/tloz or generator/tloz
Source: "{code:GetAdvnROMPath}"; DestDir: "{app}"; DestName: "ADVNTURE.BIN"; Flags: external; Components: client/advn
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni
Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp
Source: "{#source_path}\ArchipelagoLauncher.exe"; DestDir: "{app}"; Flags: ignoreversion;
Source: "{#source_path}\ArchipelagoGenerate.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: generator
Source: "{#source_path}\ArchipelagoServer.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: server
Source: "{#source_path}\ArchipelagoFactorioClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/factorio
Source: "{#source_path}\ArchipelagoTextClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/text
Source: "{#source_path}\ArchipelagoSNIClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni
Source: "{#source_path}\ArchipelagoLinksAwakeningClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ladx
Source: "{#source_path}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni/lttp or generator/lttp
Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
@@ -115,6 +127,10 @@ Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: igno
Source: "{#source_path}\ArchipelagoPokemonClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/pkmn
Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf
Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2
Source: "{#source_path}\ArchipelagoZelda1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/tloz
Source: "{#source_path}\ArchipelagoWargrooveClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/wargroove
Source: "{#source_path}\ArchipelagoKH2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/kh2
Source: "{#source_path}\ArchipelagoAdventureClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/advn
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
[Icons]
@@ -130,6 +146,10 @@ Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\Archipelag
Name: "{group}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Components: client/pkmn
Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf
Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2
Name: "{group}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app}\ArchipelagoZelda1Client.exe"; Components: client/tloz
Name: "{group}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Components: client/kh2
Name: "{group}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Components: client/advn
Name: "{group}\{#MyAppName} Wargroove Client"; Filename: "{app}\ArchipelagoWargrooveClient.exe"; Components: client/wargroove
Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon
Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; Components: server
@@ -142,6 +162,10 @@ Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\Ar
Name: "{commondesktop}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Tasks: desktopicon; Components: client/pkmn
Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf
Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2
Name: "{commondesktop}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app}\ArchipelagoZelda1Client.exe"; Tasks: desktopicon; Components: client/tloz
Name: "{commondesktop}\{#MyAppName} Wargroove Client"; Filename: "{app}\ArchipelagoWargrooveClient.exe"; Tasks: desktopicon; Components: client/wargroove
Name: "{commondesktop}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Tasks: desktopicon; Components: client/kh2
Name: "{commondesktop}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Tasks: desktopicon; Components: client/advn
[Run]
@@ -219,6 +243,21 @@ Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Ar
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/ladx
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/ladx
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: ""; Components: client/ladx
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/ladx
Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/tloz
Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/tloz
Root: HKCR; Subkey: "{#MyAppName}tlozpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZelda1Client.exe,0"; ValueType: string; ValueName: ""; Components: client/tloz
Root: HKCR; Subkey: "{#MyAppName}tlozpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZelda1Client.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/tloz
Root: HKCR; Subkey: ".apadvn"; ValueData: "{#MyAppName}advnpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/advn
Root: HKCR; Subkey: "{#MyAppName}advnpatch"; ValueData: "Archipelago Adventure Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/advn
Root: HKCR; Subkey: "{#MyAppName}advnpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoAdventureClient.exe,0"; ValueType: string; ValueName: ""; Components: client/advn
Root: HKCR; Subkey: "{#MyAppName}advnpatch\shell\open\command"; ValueData: """{app}\ArchipelagoAdventureClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/advn
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server
@@ -286,6 +325,15 @@ var RedROMFilePage: TInputFileWizardPage;
var bluerom: string;
var BlueROMFilePage: TInputFileWizardPage;
var ladxrom: string;
var LADXROMFilePage: TInputFileWizardPage;
var tlozrom: string;
var TLoZROMFilePage: TInputFileWizardPage;
var advnrom: string;
var AdvnROMFilePage: TInputFileWizardPage;
function GetSNESMD5OfFile(const rom: string): string;
var data: AnsiString;
begin
@@ -346,6 +394,25 @@ begin
end;
end;
function CheckNESRom(name: string; hash: string): string;
var rom: string;
begin
log('Handling ' + name)
rom := FileSearch(name, WizardDirValue());
if Length(rom) > 0 then
begin
log('existing ROM found');
log(IntToStr(CompareStr(GetSMSMD5OfFile(rom), hash)));
if CompareStr(GetSMSMD5OfFile(rom), hash) = 0 then
begin
log('existing ROM verified');
Result := rom;
exit;
end;
log('existing ROM failed verification');
end;
end;
function AddRomPage(name: string): TInputFileWizardPage;
begin
Result :=
@@ -392,6 +459,21 @@ begin
'.sms');
end;
function AddNESRomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'NES ROM files|*.nes|All files|*.*',
'.nes');
end;
procedure AddOoTRomPage();
begin
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
@@ -422,6 +504,21 @@ begin
'.z64');
end;
function AddA26Page(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'A2600 ROM files|*.BIN;*.a26|All files|*.*',
'.BIN');
end;
function NextButtonClick(CurPageID: Integer): Boolean;
begin
if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then
@@ -440,6 +537,16 @@ begin
Result := not (OoTROMFilePage.Values[0] = '')
else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then
Result := not (ZlROMFilePage.Values[0] = '')
else if (assigned(RedROMFilePage)) and (CurPageID = RedROMFilePage.ID) then
Result := not (RedROMFilePage.Values[0] = '')
else if (assigned(BlueROMFilePage)) and (CurPageID = BlueROMFilePage.ID) then
Result := not (BlueROMFilePage.Values[0] = '')
else if (assigned(LADXROMFilePage)) and (CurPageID = LADXROMFilePage.ID) then
Result := not (LADXROMFilePage.Values[0] = '')
else if (assigned(TLoZROMFilePage)) and (CurPageID = TLoZROMFilePage.ID) then
Result := not (TLoZROMFilePage.Values[0] = '')
else if (assigned(AdvnROMFilePage)) and (CurPageID = AdvnROMFilePage.ID) then
Result := not (AdvnROMFilePage.Values[0] = '')
else
Result := True;
end;
@@ -576,7 +683,7 @@ function GetRedROMPath(Param: string): string;
begin
if Length(redrom) > 0 then
Result := redrom
else if Assigned(RedRomFilePage) then
else if Assigned(RedROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(RedROMFilePage.Values[0]), '3d45c1ee9abd5738df46d2bdda8b57dc')
if R <> 0 then
@@ -592,7 +699,7 @@ function GetBlueROMPath(Param: string): string;
begin
if Length(bluerom) > 0 then
Result := bluerom
else if Assigned(BlueRomFilePage) then
else if Assigned(BlueROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(BlueROMFilePage.Values[0]), '50927e843568814f7ed45ec4f944bd8b')
if R <> 0 then
@@ -603,6 +710,54 @@ begin
else
Result := '';
end;
function GetTLoZROMPath(Param: string): string;
begin
if Length(tlozrom) > 0 then
Result := tlozrom
else if Assigned(TLoZROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(TLoZROMFilePage.Values[0]), '337bd6f1a1163df31bf2633665589ab0');
if R <> 0 then
MsgBox('The Legend of Zelda ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := TLoZROMFilePage.Values[0]
end
else
Result := '';
end;
function GetLADXROMPath(Param: string): string;
begin
if Length(ladxrom) > 0 then
Result := ladxrom
else if Assigned(LADXROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(LADXROMFilePage.Values[0]), '07c211479386825042efb4ad31bb525f')
if R <> 0 then
MsgBox('Link''s Awakening DX ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := LADXROMFilePage.Values[0]
end
else
Result := '';
end;
function GetAdvnROMPath(Param: string): string;
begin
if Length(advnrom) > 0 then
Result := advnrom
else if Assigned(AdvnROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(AdvnROMFilePage.Values[0]), '157bddb7192754a45372be196797f284');
if R <> 0 then
MsgBox('Adventure ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := AdvnROMFilePage.Values[0]
end
else
Result := '';
end;
procedure InitializeWizard();
begin
@@ -640,9 +795,21 @@ begin
if Length(bluerom) = 0 then
BlueROMFilePage:= AddGBRomPage('Pokemon Blue (UE) [S][!].gb');
ladxrom := CheckRom('Legend of Zelda, The - Link''s Awakening DX (USA, Europe) (SGB Enhanced).gbc','07c211479386825042efb4ad31bb525f');
if Length(ladxrom) = 0 then
LADXROMFilePage:= AddGBRomPage('Legend of Zelda, The - Link''s Awakening DX (USA, Europe) (SGB Enhanced).gbc');
l2acrom := CheckRom('Lufia II - Rise of the Sinistrals (USA).sfc', '6efc477d6203ed2b3b9133c1cd9e9c5d');
if Length(l2acrom) = 0 then
L2ACROMFilePage:= AddRomPage('Lufia II - Rise of the Sinistrals (USA).sfc');
tlozrom := CheckNESROM('Legend of Zelda, The (U) (PRG0) [!].nes', '337bd6f1a1163df31bf2633665589ab0');
if Length(tlozrom) = 0 then
TLoZROMFilePage:= AddNESRomPage('Legend of Zelda, The (U) (PRG0) [!].nes');
advnrom := CheckSMSRom('ADVNTURE.BIN', '157bddb7192754a45372be196797f284');
if Length(advnrom) = 0 then
AdvnROMFilePage:= AddA26Page('ADVNTURE.BIN');
end;
@@ -669,4 +836,10 @@ begin
Result := not (WizardIsComponentSelected('generator/pkmn_r') or WizardIsComponentSelected('client/pkmn/red'));
if (assigned(BlueROMFilePage)) and (PageID = BlueROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/pkmn_b') or WizardIsComponentSelected('client/pkmn/blue'));
if (assigned(LADXROMFilePage)) and (PageID = LADXROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/ladx') or WizardIsComponentSelected('client/ladx'));
if (assigned(TLoZROMFilePage)) and (PageID = TLoZROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/tloz') or WizardIsComponentSelected('client/tloz'));
if (assigned(AdvnROMFilePage)) and (PageID = AdvnROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/advn'));
end;

12
kvui.py
View File

@@ -148,9 +148,11 @@ class ServerLabel(HovererableLabel):
for permission_name, permission_data in ctx.permissions.items():
text += f"\n {permission_name}: {permission_data}"
if ctx.hint_cost is not None and ctx.total_locations:
min_cost = int(ctx.server_version >= (0, 3, 9))
text += f"\nA new !hint <itemname> costs {ctx.hint_cost}% of checks made. " \
f"For you this means every {max(0, int(ctx.hint_cost * 0.01 * ctx.total_locations))} " \
"location checks."
f"For you this means every " \
f"{max(min_cost, int(ctx.hint_cost * 0.01 * ctx.total_locations))}" \
f" location checks."
elif ctx.hint_cost == 0:
text += "\n!hint is free to use."
@@ -486,6 +488,10 @@ class GameManager(App):
if hasattr(self, "energy_link_label"):
self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J"
# default F1 keybind, opens a settings menu, that seems to break the layout engine once closed
def open_settings(self, *largs):
pass
class LogtoUI(logging.Handler):
def __init__(self, on_log):
@@ -606,7 +612,7 @@ class KivyJSONtoTextParser(JSONtoTextParser):
ExceptionManager.add_handler(E())
Builder.load_file(Utils.local_path("data", "client.kv"))
user_file = Utils.local_path("data", "user.kv")
user_file = Utils.user_path("data", "user.kv")
if os.path.exists(user_file):
logging.info("Loading user.kv into builder.")
Builder.load_file(user_file)

View File

@@ -1,8 +1,9 @@
colorama>=0.4.5
websockets>=10.3
PyYAML>=6.0
jellyfish>=0.9.0
jellyfish>=0.11.0
jinja2>=3.1.2
schema>=0.7.5
kivy>=2.1.0
bsdiff4>=1.2.2
bsdiff4>=1.2.3
platformdirs>=3.2.0

134
setup.py
View File

@@ -7,22 +7,43 @@ import sys
import sysconfig
import typing
import zipfile
import urllib.request
import io
import json
import threading
import subprocess
from collections.abc import Iterable
from hashlib import sha3_512
from pathlib import Path
import subprocess
import pkg_resources
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
try:
requirement = 'cx-Freeze>=6.14.1'
pkg_resources.require(requirement)
import cx_Freeze
except pkg_resources.ResolutionError:
requirement = 'cx-Freeze>=6.14.7'
import pkg_resources
try:
pkg_resources.require(requirement)
install_cx_freeze = False
except pkg_resources.ResolutionError:
install_cx_freeze = True
except ImportError:
install_cx_freeze = True
pkg_resources = None # type: ignore [assignment]
if install_cx_freeze:
# check if pip is available
try:
import pip # noqa: F401
except ImportError:
raise RuntimeError("pip not available. Please install pip.")
# install and import cx_freeze
if '--yes' not in sys.argv and '-y' not in sys.argv:
input(f'Requirement {requirement} is not satisfied, press enter to install it')
subprocess.call([sys.executable, '-m', 'pip', 'install', requirement, '--upgrade'])
import cx_Freeze
import pkg_resources
import cx_Freeze
# .build only exists if cx-Freeze is the right version, so we have to update/install that first before this line
import setuptools.command.build
@@ -34,7 +55,7 @@ if __name__ == "__main__":
ModuleUpdate.update(yes="--yes" in sys.argv or "-y" in sys.argv)
ModuleUpdate.update_ran = False # restore for later
from Launcher import components, icon_paths
from worlds.LauncherComponents import components, icon_paths
from Utils import version_tuple, is_windows, is_linux
@@ -43,12 +64,78 @@ apworlds: set = {
"Subnautica",
"Factorio",
"Rogue Legacy",
"Sonic Adventure 2 Battle",
"Donkey Kong Country 3",
"Super Mario World",
"Stardew Valley",
"Timespinner",
"Minecraft",
"The Messenger",
}
def download_SNI():
print("Updating SNI")
machine_to_go = {
"x86_64": "amd64",
"aarch64": "arm64",
"armv7l": "arm"
}
platform_name = platform.system().lower()
machine_name = platform.machine().lower()
# force amd64 on macos until we have universal2 sni, otherwise resolve to GOARCH
machine_name = "amd64" if platform_name == "darwin" else machine_to_go.get(machine_name, machine_name)
with urllib.request.urlopen("https://api.github.com/repos/alttpo/sni/releases/latest") as request:
data = json.load(request)
files = data["assets"]
source_url = None
for file in files:
download_url: str = file["browser_download_url"]
machine_match = download_url.rsplit("-", 1)[1].split(".", 1)[0] == machine_name
if platform_name in download_url and machine_match:
# prefer "many" builds
if "many" in download_url:
source_url = download_url
break
source_url = download_url
if source_url and source_url.endswith(".zip"):
with urllib.request.urlopen(source_url) as download:
with zipfile.ZipFile(io.BytesIO(download.read()), "r") as zf:
for member in zf.infolist():
zf.extract(member, path="SNI")
print(f"Downloaded SNI from {source_url}")
elif source_url and (source_url.endswith(".tar.xz") or source_url.endswith(".tar.gz")):
import tarfile
mode = "r:xz" if source_url.endswith(".tar.xz") else "r:gz"
with urllib.request.urlopen(source_url) as download:
sni_dir = None
with tarfile.open(fileobj=io.BytesIO(download.read()), mode=mode) as tf:
for member in tf.getmembers():
if member.name.startswith("/") or "../" in member.name:
raise ValueError(f"Unexpected file '{member.name}' in {source_url}")
elif member.isdir() and not sni_dir:
sni_dir = member.name
elif member.isfile() and not sni_dir or not member.name.startswith(sni_dir):
raise ValueError(f"Expected folder before '{member.name}' in {source_url}")
elif member.isfile() and sni_dir:
tf.extract(member)
# sadly SNI is in its own folder on non-windows, so we need to rename
shutil.rmtree("SNI", True)
os.rename(sni_dir, "SNI")
print(f"Downloaded SNI from {source_url}")
elif source_url:
print(f"Don't know how to extract SNI from {source_url}")
else:
print(f"No SNI found for system spec {platform_name} {machine_name}")
signtool: typing.Optional[str]
if os.path.exists("X:/pw.txt"):
print("Using signtool")
with open("X:/pw.txt", encoding="utf-8-sig") as f:
@@ -73,7 +160,7 @@ exes = [
target_name=c.frozen_name + (".exe" if is_windows else ""),
icon=icon_paths[c.icon],
base="Win32GUI" if is_windows and not c.cli else None
) for c in components if c.script_name
) for c in components if c.script_name and c.frozen_name
]
extra_data = ["LICENSE", "data", "EnemizerCLI", "host.yaml", "SNI"]
@@ -174,6 +261,10 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
print("Created Manifest")
def run(self):
# start downloading sni asap
sni_thread = threading.Thread(target=download_SNI, name="SNI Downloader")
sni_thread.start()
# pre build steps
print(f"Outputting to: {self.buildfolder}")
os.makedirs(self.buildfolder, exist_ok=True)
@@ -185,6 +276,9 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
self.buildtime = datetime.datetime.utcnow()
super().run()
# need to finish download before copying
sni_thread.join()
# include_files seems to not be done automatically. implement here
for src, dst in self.include_files:
print(f"copying {src} -> {self.buildfolder / dst}")
@@ -229,7 +323,6 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
# which should be ok
with zipfile.ZipFile(self.libfolder / "worlds" / (file_name + ".apworld"), "x", zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
entry: os.DirEntry
for path in world_directory.rglob("*.*"):
relative_path = os.path.join(*path.parts[path.parts.index("worlds")+1:])
zf.write(path, relative_path)
@@ -252,9 +345,9 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
for exe in self.distribution.executables:
print(f"Signing {exe.target_name}")
os.system(signtool + os.path.join(self.buildfolder, exe.target_name))
print(f"Signing SNI")
print("Signing SNI")
os.system(signtool + os.path.join(self.buildfolder, "SNI", "SNI.exe"))
print(f"Signing OoT Utils")
print("Signing OoT Utils")
for exe_path in (("Compress", "Compress.exe"), ("Decompress", "Decompress.exe")):
os.system(signtool + os.path.join(self.buildfolder, "lib", "worlds", "oot", "data", *exe_path))
@@ -308,7 +401,8 @@ class AppImageCommand(setuptools.Command):
yes: bool
def write_desktop(self):
desktop_filename = self.app_dir / f'{self.app_id}.desktop'
assert self.app_dir, "Invalid app_dir"
desktop_filename = self.app_dir / f"{self.app_id}.desktop"
with open(desktop_filename, 'w', encoding="utf-8") as f:
f.write("\n".join((
"[Desktop Entry]",
@@ -322,7 +416,8 @@ class AppImageCommand(setuptools.Command):
desktop_filename.chmod(0o755)
def write_launcher(self, default_exe: Path):
launcher_filename = self.app_dir / f'AppRun'
assert self.app_dir, "Invalid app_dir"
launcher_filename = self.app_dir / "AppRun"
with open(launcher_filename, 'w', encoding="utf-8") as f:
f.write(f"""#!/bin/sh
exe="{default_exe}"
@@ -344,11 +439,12 @@ $APPDIR/$exe "$@"
launcher_filename.chmod(0o755)
def install_icon(self, src: Path, name: typing.Optional[str] = None, symlink: typing.Optional[Path] = None):
assert self.app_dir, "Invalid app_dir"
try:
from PIL import Image
except ModuleNotFoundError:
if not self.yes:
input(f'Requirement PIL is not satisfied, press enter to install it')
input("Requirement PIL is not satisfied, press enter to install it")
subprocess.call([sys.executable, '-m', 'pip', 'install', 'Pillow', '--upgrade'])
from PIL import Image
im = Image.open(src)
@@ -425,8 +521,12 @@ def find_libs(*args: str) -> typing.Sequence[typing.Tuple[str, str]]:
return (lib, lib_arch, lib_libc), path
if not hasattr(find_libs, "cache"):
data = subprocess.run([shutil.which('ldconfig'), '-p'], capture_output=True, text=True).stdout.split('\n')[1:]
find_libs.cache = {k: v for k, v in (parse(line) for line in data if '=>' in line)}
ldconfig = shutil.which("ldconfig")
assert ldconfig, "Make sure ldconfig is in PATH"
data = subprocess.run([ldconfig, "-p"], capture_output=True, text=True).stdout.split("\n")[1:]
find_libs.cache = { # type: ignore [attr-defined]
k: v for k, v in (parse(line) for line in data if "=>" in line)
}
def find_lib(lib, arch, libc):
for k, v in find_libs.cache.items():

View File

@@ -112,7 +112,11 @@ class WorldTestBase(unittest.TestCase):
self.world_setup()
def world_setup(self, seed: typing.Optional[int] = None) -> None:
if type(self) is WorldTestBase:
if type(self) is WorldTestBase or \
(hasattr(WorldTestBase, self._testMethodName)
and not self.run_default_tests and
getattr(self, self._testMethodName).__code__ is
getattr(WorldTestBase, self._testMethodName, None).__code__):
return # setUp gets called for tests defined in the base class. We skip world_setup here.
if not hasattr(self, "game"):
raise NotImplementedError("didn't define game name")
@@ -195,11 +199,15 @@ class WorldTestBase(unittest.TestCase):
self.collect_all_but(all_items)
for location in self.multiworld.get_locations():
self.assertEqual(self.multiworld.state.can_reach(location), location.name not in locations)
loc_reachable = self.multiworld.state.can_reach(location)
self.assertEqual(loc_reachable, location.name not in locations,
f"{location.name} is reachable without {all_items}" if loc_reachable
else f"{location.name} is not reachable without {all_items}")
for item_names in possible_items:
items = self.collect_by_name(item_names)
for location in locations:
self.assertTrue(self.can_reach_location(location))
self.assertTrue(self.can_reach_location(location),
f"{location} not reachable with {item_names}")
self.remove(items)
def assertBeatable(self, beatable: bool):
@@ -208,16 +216,20 @@ class WorldTestBase(unittest.TestCase):
# following tests are automatically run
@property
def skip_default_tests(self) -> bool:
def run_default_tests(self) -> bool:
"""Not possible or identical to the base test that's always being run already"""
constructed = hasattr(self, "game") and hasattr(self, "multiworld")
return not constructed or (not self.options
and self.setUp is WorldTestBase.setUp
and self.world_setup is WorldTestBase.world_setup)
return (self.options
or self.setUp.__code__ is not WorldTestBase.setUp.__code__
or self.world_setup.__code__ is not WorldTestBase.world_setup.__code__)
@property
def constructed(self) -> bool:
"""A multiworld has been constructed by this point"""
return hasattr(self, "game") and hasattr(self, "multiworld")
def testAllStateCanReachEverything(self):
"""Ensure all state can reach everything with the defined options"""
if self.skip_default_tests:
"""Ensure all state can reach everything and complete the game with the defined options"""
if not (self.run_default_tests and self.constructed):
return
with self.subTest("Game", game=self.game):
excluded = self.multiworld.exclude_locations[1].value
@@ -226,10 +238,13 @@ class WorldTestBase(unittest.TestCase):
if location.name not in excluded:
with self.subTest("Location should be reached", location=location):
self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
with self.subTest("Beatable"):
self.multiworld.state = state
self.assertBeatable(True)
def testEmptyStateCanReachSomething(self):
"""Ensure empty state can reach at least one location with the defined options"""
if self.skip_default_tests:
if not (self.run_default_tests and self.constructed):
return
with self.subTest("Game", game=self.game):
state = CollectionState(self.multiworld)

View File

@@ -1,18 +1,24 @@
import unittest
from collections import Counter
from worlds.AutoWorld import AutoWorldRegister
from worlds.AutoWorld import AutoWorldRegister, call_all
from . import setup_solo_multiworld
class TestBase(unittest.TestCase):
def testCreateDuplicateLocations(self):
"""Tests that no two Locations share a name."""
"""Tests that no two Locations share a name or ID."""
for game_name, world_type in AutoWorldRegister.world_types.items():
multiworld = setup_solo_multiworld(world_type)
locations = Counter(multiworld.get_locations())
locations = Counter(location.name for location in multiworld.get_locations())
if locations:
self.assertLessEqual(locations.most_common(1)[0][1], 1,
f"{world_type.game} has duplicate of location {locations.most_common(1)}")
f"{world_type.game} has duplicate of location name {locations.most_common(1)}")
locations = Counter(location.address for location in multiworld.get_locations()
if type(location.address) is int)
if locations:
self.assertLessEqual(locations.most_common(1)[0][1], 1,
f"{world_type.game} has duplicate of location ID {locations.most_common(1)}")
def testLocationsInDatapackage(self):
"""Tests that created locations not filled before fill starts exist in the datapackage."""
@@ -23,3 +29,33 @@ class TestBase(unittest.TestCase):
for location in locations:
self.assertIn(location.name, world_type.location_name_to_id)
self.assertEqual(location.address, world_type.location_name_to_id[location.name])
def testLocationCreationSteps(self):
"""Tests that Regions and Locations aren't created after `create_items`."""
gen_steps = ("generate_early", "create_regions", "create_items")
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
multiworld._recache()
region_count = len(multiworld.get_regions())
location_count = len(multiworld.get_locations())
call_all(multiworld, "set_rules")
self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during rule creation")
self.assertEqual(location_count, len(multiworld.get_locations()),
f"{game_name} modified locations count during rule creation")
multiworld._recache()
call_all(multiworld, "generate_basic")
self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during generate_basic")
self.assertGreaterEqual(location_count, len(multiworld.get_locations()),
f"{game_name} modified locations count during generate_basic")
multiworld._recache()
call_all(multiworld, "pre_fill")
self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during pre_fill")
self.assertGreaterEqual(location_count, len(multiworld.get_locations()),
f"{game_name} modified locations count during pre_fill")

View File

@@ -1,12 +1,13 @@
from argparse import Namespace
from typing import Type, Tuple
from BaseClasses import MultiWorld
from worlds.AutoWorld import call_all
from worlds.AutoWorld import call_all, World
gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill"]
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
def setup_solo_multiworld(world_type) -> MultiWorld:
def setup_solo_multiworld(world_type: Type[World], steps: Tuple[str, ...] = gen_steps) -> MultiWorld:
multiworld = MultiWorld(1)
multiworld.game[1] = world_type.game
multiworld.player_name = {1: "Tester"}
@@ -16,6 +17,6 @@ def setup_solo_multiworld(world_type) -> MultiWorld:
setattr(args, name, {1: option.from_any(option.default)})
multiworld.set_options(args)
multiworld.set_default_common_options()
for step in gen_steps:
for step in steps:
call_all(multiworld, step)
return multiworld

View File

@@ -1,20 +1,24 @@
from __future__ import annotations
import hashlib
import logging
import sys
import pathlib
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Type, Union, TYPE_CHECKING, \
ClassVar
import sys
from typing import Any, Callable, ClassVar, Dict, FrozenSet, List, Optional, Set, TYPE_CHECKING, TextIO, Tuple, Type, \
Union
from Options import AssembleOptions
from BaseClasses import CollectionState
from Options import AssembleOptions
if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial
from . import GamesPackage
class AutoWorldRegister(type):
world_types: Dict[str, Type[World]] = {}
__file__: str
zip_path: Optional[str]
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoWorldRegister:
if "web" in dct:
@@ -32,6 +36,9 @@ class AutoWorldRegister(type):
in dct.get("item_name_groups", {}).items()}
dct["item_name_groups"]["Everything"] = dct["item_names"]
dct["location_names"] = frozenset(dct["location_name_to_id"])
dct["location_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("location_name_groups", {}).items()}
dct["location_name_groups"]["Everywhere"] = dct["location_names"]
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
# move away from get_required_client_version function
@@ -149,11 +156,19 @@ class World(metaclass=AutoWorldRegister):
item_name_groups: ClassVar[Dict[str, Set[str]]] = {}
"""maps item group names to sets of items. Example: {"Weapons": {"Sword", "Bow"}}"""
data_version: ClassVar[int] = 1
location_name_groups: ClassVar[Dict[str, Set[str]]] = {}
"""maps location group names to sets of locations. Example: {"Sewer": {"Sewer Key Drop 1", "Sewer Key Drop 2"}}"""
data_version: ClassVar[int] = 0
"""
increment this every time something in your world's names/id mappings changes.
While this is set to 0, this world's DataPackage is considered in testing mode and will be inserted to the multidata
and retrieved by clients on every connection.
Increment this every time something in your world's names/id mappings changes.
When this is set to 0, that world's DataPackage is considered in "testing mode", which signals to servers/clients
that it should not be cached, and clients should request that world's DataPackage every connection. Not
recommended for production-ready worlds.
Deprecated. Clients should utilize `checksum` to determine if DataPackage has changed since last connection and
request a new DataPackage, if necessary.
"""
required_client_version: Tuple[int, int, int] = (0, 1, 6)
@@ -340,8 +355,35 @@ class World(metaclass=AutoWorldRegister):
def create_filler(self) -> "Item":
return self.create_item(self.get_filler_item_name())
@classmethod
def get_data_package_data(cls) -> "GamesPackage":
sorted_item_name_groups = {
name: sorted(cls.item_name_groups[name]) for name in sorted(cls.item_name_groups)
}
sorted_location_name_groups = {
name: sorted(cls.location_name_groups[name]) for name in sorted(cls.location_name_groups)
}
res: "GamesPackage" = {
# sorted alphabetically
"item_name_groups": sorted_item_name_groups,
"item_name_to_id": cls.item_name_to_id,
"location_name_groups": sorted_location_name_groups,
"location_name_to_id": cls.location_name_to_id,
"version": cls.data_version,
}
res["checksum"] = data_package_checksum(res)
return res
# any methods attached to this can be used as part of CollectionState,
# please use a prefix as all of them get clobbered together
class LogicMixin(metaclass=AutoLogicRegister):
pass
def data_package_checksum(data: "GamesPackage") -> str:
"""Calculates the data package checksum for a game from a dict"""
assert "checksum" not in data, "Checksum already in data"
assert sorted(data) == list(data), "Data not ordered"
from NetUtils import encode
return hashlib.sha1(encode(data).encode()).hexdigest()

View File

@@ -0,0 +1,106 @@
from enum import Enum, auto
from typing import Optional, Callable, List, Iterable
from Utils import local_path, is_windows
class Type(Enum):
TOOL = auto()
FUNC = auto() # not a real component
CLIENT = auto()
ADJUSTER = auto()
class Component:
display_name: str
type: Optional[Type]
script_name: Optional[str]
frozen_name: Optional[str]
icon: str # just the name, no suffix
cli: bool
func: Optional[Callable]
file_identifier: Optional[Callable[[str], bool]]
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None,
file_identifier: Optional[Callable[[str], bool]] = None):
self.display_name = display_name
self.script_name = script_name
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
self.icon = icon
self.cli = cli
self.type = component_type or \
None if not display_name else \
Type.FUNC if func else \
Type.CLIENT if 'Client' in display_name else \
Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL
self.func = func
self.file_identifier = file_identifier
def handles_file(self, path: str):
return self.file_identifier(path) if self.file_identifier else False
def __repr__(self):
return f"{self.__class__.__name__}({self.display_name})"
class SuffixIdentifier:
suffixes: Iterable[str]
def __init__(self, *args: str):
self.suffixes = args
def __call__(self, path: str):
if isinstance(path, str):
for suffix in self.suffixes:
if path.endswith(suffix):
return True
return False
components: List[Component] = [
# Launcher
Component('', 'Launcher'),
# Core
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
Component('Generate', 'Generate', cli=True),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
# SNI
Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3',
'.apsmw', '.apl2ac')),
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'),
# Pokémon
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
# TLoZ
Component('Zelda 1 Client', 'Zelda1Client'),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# Wargroove
Component('Wargroove Client', 'WargrooveClient'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
#Kingdom Hearts 2
Component('KH2 Client', "KH2Client"),
]
icon_paths = {
'icon': local_path('data', 'icon.ico' if is_windows else 'icon.png'),
'mcicon': local_path('data', 'mcicon.ico')
}

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