Compare commits

..

75 Commits

Author SHA1 Message Date
black-sliver
8837e617e4 WebHost, Multiple Worlds: fix images not showing in guides (#5576)
* Multiple: resize FR RA network commands screenshot

This is now more in line with the text (and the english version).

* Multiple: optimize EN RA network commands screenshot

The URL has changed, so it's a good time to optimize.

* WebHost, Worlds: fix retroarch images not showing

Implements a src/url replacement for relative paths.
Moves the RA screenshots to worlds/generic since they are shared.
Also now uses the FR version in ffmq.
Also fixes the formatting that resultet in the list breaking.
Also moves imports in render_markdown.

Guides now also properly render on Github.

* Factorio: optimize screenshots

The URL has changed, so it's a good time to optimize.

* Factorio: change guide screenshots to use relative URL

* Test: markdown: fix tests on Windows

We also can't use delete=True, delete_on_close=False
because that's not supported in Py3.11.

* Test: markdown: fix typo

I hope that's it now. *sigh*

* Landstalker: fix doc images not showing

Change to relative img urls.

* Landstalker: optimize doc PNGs

The URL has changed, so it's a good time to optimize.
2025-10-25 22:19:38 +02:00
black-sliver
2bf410f285 CI: update appimagetool to 2025-10-19 (#5578)
Beware: this has a bug, but it does not impact our CI.
2025-10-25 16:49:05 +00:00
NewSoupVi
04fe43d53a kvui: Fix audio being completely non-functional on Linux (#5588)
* kvui: Fix audio on Linux

* Update kvui.py
2025-10-25 15:34:59 +02:00
NewSoupVi
643f61e7f4 Core: Add a ruff.toml to the root directory (#5259)
* Add a ruff.toml to the root directory

* spell out C901

* Add target version

* Add some more of the suggested rules

* ignore PLC0415

* TC is bad

* ignore B0011

* ignore N818

* Ignore some more rules

* Add PLC1802 to ignore list

* Update ruff.toml

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>

* oops

* R to RET and RSC

* oops

* Py311

* Update ruff.toml

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-10-25 00:19:42 +02:00
black-sliver
6b91ffecf1 WebHost: add missing docutils requirement ... (#5583)
... and update it to latest.
This is being used in WebHostLib.options directly.
A recent change bumped our required version, so this is actually a fix.
2025-10-24 00:55:10 +02:00
black-sliver
4f7f092b9b setup: check if the sign host is on a local network (#5501)
Could have a really bad timeout if it goes through default route and packet is dropped.
2025-10-24 00:54:27 +02:00
gaithern
df3c6b7980 KH1: Add specified encoding to file output from Client to avoid crashes with non ASCII characters (#5584)
* Fix Slot 2 Level Checks description

* Fix encoding issue
2025-10-23 23:01:02 +02:00
threeandthreee
19839399e5 LADX: stealing logic option (#3965)
* implement StealingInLogic option

* fix ladxr setting

* adjust docs

* option to disable stealing

* indicate disabled stealing with shopkeeper dialog

* merge upstream/main

* Revert "merge upstream/main"

This reverts commit c91d2d6b29.

* fix

* stealing in patch

* logic reorder and fix

sword to front for readability, but also can_farm condition was missing
2025-10-23 22:11:41 +02:00
CookieCat
4847be98d2 AHIT: Fix death link timestamps being incorrect (#5404) 2025-10-23 05:30:46 +02:00
black-sliver
3105320038 Test: check fields in world source manifest (#5558)
* Test: check game in world manifest

* Update test/general/test_world_manifest.py

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>

* Test: rework finding expected manifest location

* Test: fix doc comment

* Test: fix wrong custom_worlds path in test_world_manifest

Also simplifies the way we find ./worlds/.

* Test: make test_world_manifest easier to extend

* Test: check world_version in world manifest

according to docs/apworld specification.md

* Test: check no container version in source world manifest

according what was added to docs/apworld specification.md in PR 5509

* Test: better assertion messages in test_world_manifest.py

* Test: fix wording in world source manifest

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2025-10-22 01:52:44 +02:00
Silvris
e8c8b0dbc5 MM2: fix Proteus reading #5575 2025-10-21 19:10:39 +02:00
Duck
c199775c48 Pokemon RB: Fix likely unintended concatenation #5566 2025-10-20 21:48:17 +02:00
Duck
d2bf7fdaf7 AHiT: Fix likely unintended concatenation #5565 2025-10-20 21:47:49 +02:00
Duck
621ec274c3 Yugioh: Fix likely unintended concatenations (#5567)
* Fix likely unintended concatenations

* Yeah that makes sense why I thought there were more here
2025-10-20 21:47:16 +02:00
NewSoupVi
7cd73e2710 WebHost: Fix generate argparse with --config-override + add autogen unit tests so we can test that (#5541)
* Fix webhost argparse with extra args

* accidentally added line

* WebHost: fix some typing

B64 url conversion is used in test/hosting,
so it felt appropriate to include this here.

* Test: Hosting: also test autogen

* Test: Hosting: simplify stop_* and leave a note about Windows compat

* Test: Hosting: fix formatting error

* Test: Hosting: add limitted Windows support

There are actually some differences with MP on Windows
that make it impossible to run this in CI.

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-10-20 17:40:32 +02:00
NewSoupVi
708df4d1e2 WebHost: Fix flask-compress to 1.18 for Python 3.11 (to get CI to pass again) (#5573)
From Discord:

Well, flask-compress updated and now our 3.11 CI is failing

Why? They switched to a lib called backports.zstd
And 3.11 pkg_resources can't handle that.

pip finds it. But in our ModuleUpdate.py, we first pkg_resources.require packages, and this fails. I can't reproduce this locally yet, but in CI, it seems like even though backports.zstd is installed, it still fails on it and prompts installing it over and over in every unit test
Now what do we do :KEKW:
Black Sliver suggested pinning flask-compress for 3.11
But I would just like to point out that this means we can't unpin it until we drop 3.11
the real thing is we probably need to move away from pkg_resources? lol 
since it's been deprecated literally since the oldest version we support
2025-10-20 17:06:07 +02:00
black-sliver
914a534a3b WebHost: fix gen timeout/exception resource handling (#5540)
* WebHost: reset Generator proc title on error

* WebHost: fix shutting down autogen

This is still not perfect but solves some of the issues.

* WebHost: properly propagate JOB_TIME

* WebHost: handle autogen shutdown
2025-10-20 09:16:29 +02:00
NewSoupVi
11d18db452 Docs: APWorld documentation, make a distinction between APWorld and .apworld (#5509)
* APWorld docs: Make a distinction between APWorld and .apworld

* Update apworld specification.md

* Update apworld specification.md

* Be more anal about the launcher component

* Update apworld specification.md

* Update apworld specification.md
2025-10-19 09:05:34 +02:00
Nicholas Saylor
00acfe63d4 WebHost: Update publish_parts parameters (#5544)
old name is deprecated and new name allows both writer instance or alias/name.
2025-10-19 03:40:25 +02:00
Fafale
2ac9ab5337 Docs: add warning about BepInEx to HK translated setup guides (#5554)
* Update HK pt-br setup to add warning about BepInEx

* Update HK spanish setup guide to add warning about BepInEx
2025-10-19 03:36:35 +02:00
Benny D
2569c9e531 DLC Quest: Enable multi-classification items (#5552)
* implement prog trap item (thanks stardew)

* oops that's wrong

* okay this is right
2025-10-19 03:30:24 +02:00
Rosalie
946f227226 [FF1] Added Deep Dungeon locations to locations.json so they exist in the datapackage (#5392)
* Added DD locations to locations.json so they exist in the datapackage.

* Update worlds/ff1/data/locations.json

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Update worlds/ff1/data/locations.json

Forgot trailing commas aren't allowed in JSON.

Co-authored-by: qwint <qwint.42@gmail.com>

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: qwint <qwint.42@gmail.com>
2025-10-17 16:44:11 +02:00
Carter Hesterman
7ead8fdf49 Civ 6: Add era requirements for boosts and update boost prereqs (#5296)
* Resolve #5136

* Resolves #5210
2025-10-17 16:35:44 +02:00
Rosalie
f5f554cb3d [FF1] Client fix and improvement (#5390)
* FF1 Client fixes.

* Strip leading/trailing spaces from rom-stored player name.

* FF1R encodes the name as utf-8, as it happens.

* UTF-8 is four bytes per character, so we need 64 bytes for the name, not 16.
2025-10-17 16:34:10 +02:00
Alchav
3f2942c599 Super Mario Land 2: Logic fixes #5258
Co-authored-by: alchav <alchav@jalchavware.com>
2025-10-17 16:32:58 +02:00
Snarky
da519e7f73 SC2: fix incorrect preset option (#5551)
* SC2: fix incorrect preset option

* SC2: fix incorrect evil logic preset option

---------

Co-authored-by: Snarky <sparkykueken@gmail.com>
2025-10-17 16:30:05 +02:00
Duck
0718ada682 Core: Allow PlandoItems to be pickled (#5335)
* Add Options.PlandoItem

* Remove worlds.generic.PlandoItem handling

* Add plando pickling test

* Revert old PlandoItem cleanup

* Deprecate old PlandoItem

* Change to warning message

* Use deprecated decorator
2025-10-17 03:20:34 +02:00
Duck
f756919dd9 CI: Add worlds manifests to build action trigger (#5555)
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-10-16 23:58:12 +02:00
Jérémie Bolduc
406b905dc8 Stardew Valley: Add archipelago.json (#5535)
* add apworld manifest

* add world version
2025-10-16 22:23:23 +02:00
JaredWeakStrike
91439e0fb0 KH2: Manifest eletric boogaloo (#5556)
* manifest file

* x y z for world version

* Update archipelago.json
2025-10-16 20:25:11 +02:00
RoobyRoo
03bd59bff6 Ocarina of Time: Create manifest (#5536)
* Create archipelago.json

* Sure, let's call it 7.0.0

* Update archipelago.json

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-10-16 11:48:04 +02:00
BlastSlimey
cf02e1a1aa shapez: Fix floating layers logic error #5263 2025-10-15 23:41:15 +02:00
JaredWeakStrike
f6d696ea62 KH2: Manifest File (#5553)
* manifest file

* x y z for world version
2025-10-15 23:40:21 +02:00
BadMagic100
123acdef23 Docs: warn HK users not to use BepInEx #5550 2025-10-15 13:35:00 +02:00
Nicholas Saylor
28c7a214dc Core: Use Better Practices Accessing Manifests (#5543)
* Close manifest files

* Name explicit encoding
2025-10-15 01:09:05 +02:00
NewSoupVi
bdae7cd42c MultiServer: Fix hinting multi-copy items bleeding found status (#5547)
* fix hinting multi-copy items bleeding found status

* reword
2025-10-14 20:44:01 +02:00
Silvris
fc404d0cf7 MM2: fix Heat Man always being invulnerable to Atomic Fire #5546 2025-10-14 09:27:41 +02:00
threeandthreee
5ce71db048 LADX: use start_inventory_from_pool (#4641) 2025-10-13 19:32:49 +02:00
NewSoupVi
aff98a5b78 CommonClient: Fix manually connecting to a url when the username or password has a space in it (#5528)
* CommonClient: Fix manually connecting to a url when the username or password has a space in it

* Update CommonClient.py

* Update CommonClient.py
2025-10-13 18:55:44 +02:00
Exempt-Medic
30cedb13f3 Core: Limit ItemLink Name to 16 Characters (#4318) 2025-10-13 18:32:53 +02:00
Seldom
0c1ecf7297 Terraria: Remove /apstart from docs (#5537) 2025-10-13 18:06:25 +02:00
black-sliver
5390561b58 MultiServer: Fix breaking weakrefs for SetNotify (#5539) 2025-10-12 21:46:16 +02:00
threeandthreee
bb457b0f73 SNI Client: fix that it isnt using host.yaml settings (#5533) 2025-10-11 11:16:47 +02:00
threeandthreee
6276ccf415 LADX: move client out of root (#4226)
* init

* Revert "init"

This reverts commit bba6b7a306.

* put it back but clean

* pass args

* windows stuff

* delete old exe

this seems like it?

* use marin icon in launcher

* use LauncherComponents.launch
2025-10-10 17:56:15 +02:00
Mysteryem
d3588a057c Tests: gc.freeze() by default in the test\benchmark\locations.py (#5055)
Without `gc.freeze()` and `gc.unfreeze()` afterward, the `gc.collect()`
call within each benchmark often takes much longer than all 100_000
iterations of the location access rule, making it difficult to benchmark
all but the slowest of access rules.

This change enables using `gc.freeze()` by default.
2025-10-10 17:19:52 +02:00
Katelyn Gigante
30ce74d6d5 core: Add host.yaml setting to make !countdown configurable (#5465)
* core:  Add host.yaml setting to make !countdown configurable

* Store /option changes to countdown_mode in save file

* Wording changes in host.yaml

* Use .get

* Fix validation for /option command
2025-10-10 15:02:56 +02:00
NewSoupVi
ff59b86335 Docs: More apworld manifest documentation (#5477)
* Expand apworld specification manifest part

* clarity

* expand example

* clarify

* correct

* Correct

* elaborate on what version is

* Add where the apworlds are output

* authors & update versions

* Update apworld specification.md

* Update apworld specification.md

* Update apworld specification.md

* Update apworld specification.md
2025-10-09 20:23:21 +02:00
NewSoupVi
e355d20063 WebHost: Don't show e.__cause__ on the generation error page #5521 2025-10-08 07:22:14 +02:00
Fabian Dill
28ea2444a4 kvui: re-enable settings menu (#4823) 2025-10-08 06:34:00 +02:00
black-sliver
e907980ff0 MultiServer: slight optimizations (#5527)
* Core: optimize MultiServer.Client

* Core: optimize websocket compression settings
2025-10-08 02:22:34 +02:00
Snarky
5a933a160a SC2: Add option presets (#5436)
* SC2: Add option presets

* SC2: Address reviews

* SC2: Fix import

* SC2: Update key mode

* SC2: Update renamed option

* sc2: PR comment; switching from __dataclass_fields__ to dataclasses.fields()

* sc2: Changing quote style to match AP standard

* sc2: PR comments; Switching to Starcraft2.type_hints

---------

Co-authored-by: Snarky <sparkykueken@gmail.com>
Co-authored-by: MatthewMarinets <matthew.marinets@gmail.com>
2025-10-07 17:25:08 +02:00
Duck
c7978bcc12 Docs: Add info about custom worlds (#5510)
* Cleaning up (#4)

Cleanup

* Added new paragraph for new games

* Update worlds/generic/docs/setup_en.md

Proofier-comitting

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Added a mention in the header of the games page to refer to this guide if needed.

* Small tweaks

* Added mention regarding alternate version of worlds

* Update WebHostLib/templates/supportedGames.html

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Update worlds/generic/docs/setup_en.md

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Edits for comments

* Slight alternate versions rewording

* Edit subheadings

* Adjust link text

* Replace alternate versions section and reword first

---------

Co-authored-by: Danaël V <104455676+ReverM@users.noreply.github.com>
Co-authored-by: Rever <danael.villeneuve@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-10-06 04:48:42 +02:00
Duck
5c7a84748b WebHost: Handle blank values for OptionCounters #5517 2025-10-06 04:38:38 +02:00
Duck
8dc9719b99 Core: Cleanup unneeded use of Version/tuplize_version (#5519)
* Remove weird version uses

* Restore version var

* Unrestore version var
2025-10-06 01:56:09 +02:00
black-sliver
60617c682e WebHost: fix log fetching extra characters when there is non-ascii (#5515) 2025-10-05 21:05:52 +02:00
massimilianodelliubaldini
fd879408f3 WebHost: Improve user friendliness of generation failure webpage (#4964)
* Improve user friendliness of generation failure webpage.

* Add details to other render for seedError.html.

* Refactor css to avoid !important tags.

* Update WebHostLib/static/styles/themes/ocean-island.css

Co-authored-by: qwint <qwint.42@gmail.com>

* Update WebHostLib/generate.py

Co-authored-by: qwint <qwint.42@gmail.com>

* use f words

* small refactor

* Update WebHostLib/generate.py

Co-authored-by: qwint <qwint.42@gmail.com>

* Fix whitespace.

* Update one new use of seedError template for pickling errors.

---------

Co-authored-by: qwint <qwint.42@gmail.com>
2025-10-05 15:38:57 +02:00
Mysteryem
8decde0370 Core: Don't waste swaps by swapping two copies of the same item (#5516)
There is a limit to the number of times an item can be swapped to
prevent swapping going on potentially forever. Swapping an item with a
copy of itself is assumed to be a pointless swap, and was wasting
possible swaps in cases where there were multiple copies of an item
being placed.

This swapping behaviour was noticed from debugging solo LADX generations
that was wasting swaps by swapping copies of the same item.

This patch adds a check that if the placed_item and item_to_place are
equal, then the location is skipped and no attempt to swap is made.

If worlds do intend to have seemingly equal items to actually have
different logical behaviour, those worlds should override __eq__ on
their Item subclasses so that the item instances are not considered
equal.

Generally, fill_restrictive should only be used with progression items,
so it is assumed that swapping won't have to deal with multiple copies
of an item where some copies are progression and some are not. This is
relevant because Item.__eq__ only compares .name and .player.
2025-10-05 15:07:12 +02:00
PoryGone
adb5a7d632 SA2B, DKC3, SMW, Celeste 64, Celeste (Open World): Manifest manifests 2025-10-05 06:47:01 +02:00
Jérémie Bolduc
f07fea2771 CommonClient: Move command marker to last_autofillable_command (#4907)
* handle autocomplete command when press question

* fix test

* add docstring to get_input_text_from_response

* fix line lenght
2025-10-05 05:39:30 +02:00
James White
a2460b7fe7 Pokemon RB: Add client tracking for tracker relevant events (#5495)
* Pokemon RB: Add client tracking for tracker relevant events

* Pokemon RB: Use list for tracker events

* Pokemon RB: Use correct bill event

* Pokemon RB: Add champion event tracking
2025-10-05 05:33:52 +02:00
Katelyn Gigante
f8f30f41b7 Launcher: Newly installed custom worlds are not relative #4989
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-10-05 05:30:52 +02:00
Benny D
60070c2f1e PyCharm: add a run config for the new apworld builder workflow (#5489)
* add Build APWorld PyCharm run config

* change casing of the argument

* Update Build APWorld.run.xml

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-10-05 05:13:04 +02:00
Louis M
3eb25a59dc Aquaria: Updating documentation to add latest clients informations (#5438)
* Updating Aquaria documentation to add latest clients informations

* Typo in the permission explanation
2025-10-05 05:08:34 +02:00
Branden Wood
1cbc5d6649 Short Hike: improve setup guide docs #5470 2025-10-05 05:08:15 +02:00
DJ-lennart
bdef410eb2 Civilization VI: Update for the setup instructions #5286 2025-10-05 05:07:11 +02:00
Duck
ec9145e61d Region: Use Mapping type for adding locations/exits #5354 2025-10-05 05:04:02 +02:00
Duck
a547c8dd7d Core: Add location count field for world to spoiler log (#5440)
* Add location count

* Only count non-events

* Add total count
2025-10-05 05:02:26 +02:00
PinkSwitch
7996fd8d19 Core: Update start inventory description to mention item quantities (#5460)
* SNIClient: new SnesReader interface

* fix Python 3.8 compatibility
`bisect_right`

* move to worlds
because we don't have good separation importable modules and entry points

* `read` gives object that contains data

* remove python 3.10 implementation and update typing

* remove obsolete comment

* freeze _MemRead and assert type of get parameter

* some optimization in `SnesData.get`

* pass context to `read` so that we can have a static instance of `SnesReader`

* add docstring to `SnesReader`

* remove unused import

* break big reads into chunks

* some minor improvements

- `dataclass` instead of `NamedTuple` for `Read`
- comprehension in `SnesData.__init__`
- `slots` for dataclasses

* Change descriptions

* Fix sni client?

---------

Co-authored-by: beauxq <beauxq@yahoo.com>
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-10-05 05:01:56 +02:00
Scipio Wright
7a652518a3 [Website docs] Update wording of "adding a game to archipelago" section 2025-10-05 04:59:52 +02:00
Duck
ae4426af08 Core: Pad version string in world printout #5511 2025-10-05 04:46:26 +02:00
black-sliver
91e97b68d4 Webhost: eagerly free resources in customserver (#5512)
* Unref some locals that would live long for no reason.
* Limit scope of db_session in init_save.
2025-10-05 03:49:56 +02:00
NewSoupVi
6a08064a52 Core: Assert that if an apworld manifest file exists, it has a game field (#5478)
* Assert that if an apworld manifest file exists, it has a game field

* god damnit

* Update worlds/LauncherComponents.py

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>

* Update setup.py

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2025-10-04 03:04:23 +02:00
Kaito Sinclaire
83cfb803a7 SMZ3: Fix forced fill behaviors (GT junk fill, initial Super/PB front fill) (#5361)
* SMZ3: Make GT fill behave like upstream SMZ3 multiworld GT fill

This means: All items local, 50% guaranteed filler, followed by possible
useful items, never progression.

* Fix item links

* SMZ3: Ensure in all cases, we remove the right item from the pool

Previously front fill would cause erratic errors on frozen, with the
cause immediately revealed by, on source, tripping the assert that was
added in #5109

* SMZ3: Truly, *properly* fix GT junk fill

After hours of diving deep into the upstream SMZ3 randomizer, it finally
behaves identically to how it does there
2025-10-03 02:05:29 +02:00
qwint
6d7abb3780 Webhost: Ignore Invalid Worlds in Webhost (#5433)
* filter world types at top of webhost so worlds that aren't loadable in webhost are "uninstalled"

* mark invalid worlds, show error if any, then filter to exclude them
2025-10-03 01:56:11 +02:00
Silvris
50f6cf04f6 Core: "Build APWorlds" cleanup (#5507)
* allow filtered build, subprocess

* component description

* correct name

* move back to running directly
2025-10-02 09:36:33 +02:00
135 changed files with 2366 additions and 543 deletions

View File

@@ -9,12 +9,14 @@ on:
- 'setup.py' - 'setup.py'
- 'requirements.txt' - 'requirements.txt'
- '*.iss' - '*.iss'
- 'worlds/*/archipelago.json'
pull_request: pull_request:
paths: paths:
- '.github/workflows/build.yml' - '.github/workflows/build.yml'
- 'setup.py' - 'setup.py'
- 'requirements.txt' - 'requirements.txt'
- '*.iss' - '*.iss'
- 'worlds/*/archipelago.json'
workflow_dispatch: workflow_dispatch:
env: env:
@@ -22,7 +24,7 @@ env:
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, # NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
# we check the sha256 and require manual intervention if it was updated. # we check the sha256 and require manual intervention if it was updated.
APPIMAGETOOL_VERSION: continuous APPIMAGETOOL_VERSION: continuous
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e' APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: continuous APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b' APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'

View File

@@ -12,7 +12,7 @@ env:
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore, # NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
# we check the sha256 and require manual intervention if it was updated. # we check the sha256 and require manual intervention if it was updated.
APPIMAGETOOL_VERSION: continuous APPIMAGETOOL_VERSION: continuous
APPIMAGETOOL_X86_64_HASH: '29348a20b80827cd261c28e95172ff828b69d43d4e4e18e3fd069e2c8693c94e' APPIMAGETOOL_X86_64_HASH: '9493a6b253a01f84acb9c624c38810ecfa11d99daa829b952b0bff43113080f9'
APPIMAGE_RUNTIME_VERSION: continuous APPIMAGE_RUNTIME_VERSION: continuous
APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b' APPIMAGE_RUNTIME_X86_64_HASH: 'e70ffa9b69b211574d0917adc482dd66f25a0083427b5945783965d55b0b0a8b'

View File

@@ -0,0 +1,24 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Build APWorld" type="PythonConfigurationType" factoryName="Python">
<module name="Archipelago" />
<option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>
<env name="PYTHONUNBUFFERED" value="1" />
</envs>
<option name="SDK_HOME" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/" />
<option name="IS_MODULE_SDK" value="true" />
<option name="ADD_CONTENT_ROOTS" value="true" />
<option name="ADD_SOURCE_ROOTS" value="true" />
<option name="SCRIPT_NAME" value="$ContentRoot$/Launcher.py" />
<option name="PARAMETERS" value="\&quot;Build APWorlds\&quot;" />
<option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="false" />
<option name="REDIRECT_INPUT" value="false" />
<option name="INPUT_FILE" value="" />
<method v="2" />
</configuration>
</component>

View File

@@ -1346,8 +1346,7 @@ class Region:
for entrance in self.entrances: # BFS might be better here, trying DFS for now. for entrance in self.entrances: # BFS might be better here, trying DFS for now.
return entrance.parent_region.get_connecting_entrance(is_main_entrance) return entrance.parent_region.get_connecting_entrance(is_main_entrance)
def add_locations(self, locations: Dict[str, Optional[int]], def add_locations(self, locations: Mapping[str, int | None], location_type: type[Location] | None = None) -> None:
location_type: Optional[type[Location]] = None) -> None:
""" """
Adds locations to the Region object, where location_type is your Location class and locations is a dict of Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address. location names to address.
@@ -1435,8 +1434,8 @@ class Region:
entrance.connect(self) entrance.connect(self)
return entrance return entrance
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]], def add_exits(self, exits: Iterable[str] | Mapping[str, str | None],
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]: rules: Mapping[str, Callable[[CollectionState], bool]] | None = None) -> List[Entrance]:
""" """
Connects current region to regions in exit dictionary. Passed region names must exist first. Connects current region to regions in exit dictionary. Passed region names must exist first.
@@ -1444,7 +1443,7 @@ class Region:
created entrances will be named "self.name -> connecting_region" created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region": rule} :param rules: rules for the exits from this region. format is {"connecting_region": rule}
""" """
if not isinstance(exits, Dict): if not isinstance(exits, Mapping):
exits = dict.fromkeys(exits) exits = dict.fromkeys(exits)
return [ return [
self.connect( self.connect(
@@ -1858,6 +1857,9 @@ class Spoiler:
Utils.__version__, self.multiworld.seed)) Utils.__version__, self.multiworld.seed))
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm) outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
outfile.write('Players: %d\n' % self.multiworld.players) outfile.write('Players: %d\n' % self.multiworld.players)
if self.multiworld.players > 1:
loc_count = len([loc for loc in self.multiworld.get_locations() if not loc.is_event])
outfile.write('Total Location Count: %d\n' % loc_count)
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n') outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile) AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
@@ -1866,6 +1868,9 @@ class Spoiler:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player]) outfile.write('Game: %s\n' % self.multiworld.game[player])
loc_count = len([loc for loc in self.multiworld.get_locations(player) if not loc.is_event])
outfile.write('Location Count: %d\n' % loc_count)
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items(): for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
write_option(f_option, option) write_option(f_option, option)

View File

@@ -856,9 +856,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
server_url = urllib.parse.urlparse(address) server_url = urllib.parse.urlparse(address)
if server_url.username: if server_url.username:
ctx.username = server_url.username ctx.username = urllib.parse.unquote(server_url.username)
if server_url.password: if server_url.password:
ctx.password = server_url.password ctx.password = urllib.parse.unquote(server_url.password)
def reconnect_hint() -> str: def reconnect_hint() -> str:
return ", type /connect to reconnect" if ctx.server_address else "" return ", type /connect to reconnect" if ctx.server_address else ""

View File

@@ -129,6 +129,10 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
for i, location in enumerate(placements)) for i, location in enumerate(placements))
for (i, location, unsafe) in swap_attempts: for (i, location, unsafe) in swap_attempts:
placed_item = location.item placed_item = location.item
if item_to_place == placed_item:
# The number of allowed swaps is limited, so do not allow a swap of an item with a copy of
# itself.
continue
# Unplaceable items can sometimes be swapped infinitely. Limit the # Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this # number of times we will swap an individual item to prevent this
swap_count = swapped_items[placed_item.player, placed_item.name, unsafe] swap_count = swapped_items[placed_item.player, placed_item.name, unsafe]

View File

@@ -23,7 +23,7 @@ from BaseClasses import seeddigits, get_seed, PlandoOptions
from Utils import parse_yamls, version_tuple, __version__, tuplize_version from Utils import parse_yamls, version_tuple, __version__, tuplize_version
def mystery_argparse(): def mystery_argparse(argv: list[str] | None = None):
from settings import get_settings from settings import get_settings
settings = get_settings() settings = get_settings()
defaults = settings.generator defaults = settings.generator
@@ -57,7 +57,7 @@ def mystery_argparse():
parser.add_argument("--spoiler_only", action="store_true", parser.add_argument("--spoiler_only", action="store_true",
help="Skips generation assertion and multidata, outputting only a spoiler log. " help="Skips generation assertion and multidata, outputting only a spoiler log. "
"Intended for debugging and testing purposes.") "Intended for debugging and testing purposes.")
args = parser.parse_args() args = parser.parse_args(argv)
if args.skip_output and args.spoiler_only: if args.skip_output and args.spoiler_only:
parser.error("Cannot mix --skip_output and --spoiler_only") parser.error("Cannot mix --skip_output and --spoiler_only")

View File

@@ -54,13 +54,16 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} 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) longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) world_classes = AutoWorld.AutoWorldRegister.world_types.values()
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
version_count = max(len(cls.world_version.as_simple_string()) for cls in world_classes)
item_count = len(str(max(len(cls.item_names) for cls in world_classes)))
location_count = len(str(max(len(cls.location_names) for cls in world_classes)))
for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0: if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: " logger.info(f" {name:{longest_name}}: "
f"v{cls.world_version.as_simple_string()} |" f"v{cls.world_version.as_simple_string():{version_count}} | "
f"Items: {len(cls.item_names):{item_count}} | " f"Items: {len(cls.item_names):{item_count}} | "
f"Locations: {len(cls.location_names):{location_count}}") f"Locations: {len(cls.location_names):{location_count}}")

View File

@@ -32,7 +32,7 @@ if typing.TYPE_CHECKING:
import colorama import colorama
import websockets import websockets
from websockets.extensions.permessage_deflate import PerMessageDeflate from websockets.extensions.permessage_deflate import PerMessageDeflate, ServerPerMessageDeflateFactory
try: try:
# ponyorm is a requirement for webhost, not default server, so may not be importable # ponyorm is a requirement for webhost, not default server, so may not be importable
from pony.orm.dbapiprovider import OperationalError from pony.orm.dbapiprovider import OperationalError
@@ -50,6 +50,15 @@ from BaseClasses import ItemClassification
min_client_version = Version(0, 5, 0) min_client_version = Version(0, 5, 0)
colorama.just_fix_windows_console() colorama.just_fix_windows_console()
no_version = Version(0, 0, 0)
assert isinstance(no_version, tuple) # assert immutable
server_per_message_deflate_factory = ServerPerMessageDeflateFactory(
server_max_window_bits=11,
client_max_window_bits=11,
compress_settings={"memLevel": 4},
)
def remove_from_list(container, value): def remove_from_list(container, value):
try: try:
@@ -125,8 +134,31 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
class Client(Endpoint): class Client(Endpoint):
version = Version(0, 0, 0) __slots__ = (
tags: typing.List[str] "__weakref__",
"version",
"auth",
"team",
"slot",
"send_index",
"tags",
"messageprocessor",
"ctx",
"remote_items",
"remote_start_inventory",
"no_items",
"no_locations",
"no_text",
)
version: Version
auth: bool
team: int | None
slot: int | None
send_index: int
tags: list[str]
messageprocessor: ClientMessageProcessor
ctx: weakref.ref[Context]
remote_items: bool remote_items: bool
remote_start_inventory: bool remote_start_inventory: bool
no_items: bool no_items: bool
@@ -135,6 +167,7 @@ class Client(Endpoint):
def __init__(self, socket: "ServerConnection", ctx: Context) -> None: def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
super().__init__(socket) super().__init__(socket)
self.version = no_version
self.auth = False self.auth = False
self.team = None self.team = None
self.slot = None self.slot = None
@@ -142,6 +175,11 @@ class Client(Endpoint):
self.tags = [] self.tags = []
self.messageprocessor = client_message_processor(ctx, self) self.messageprocessor = client_message_processor(ctx, self)
self.ctx = weakref.ref(ctx) self.ctx = weakref.ref(ctx)
self.remote_items = False
self.remote_start_inventory = False
self.no_items = False
self.no_locations = False
self.no_text = False
@property @property
def items_handling(self): def items_handling(self):
@@ -179,6 +217,7 @@ class Context:
"release_mode": str, "release_mode": str,
"remaining_mode": str, "remaining_mode": str,
"collect_mode": str, "collect_mode": str,
"countdown_mode": str,
"item_cheat": bool, "item_cheat": bool,
"compatibility": int} "compatibility": int}
# team -> slot id -> list of clients authenticated to slot. # team -> slot id -> list of clients authenticated to slot.
@@ -208,8 +247,8 @@ class Context:
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled", hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, countdown_mode: str = "auto", remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0,
log_network: bool = False, logger: logging.Logger = logging.getLogger()): compatibility: int = 2, log_network: bool = False, logger: logging.Logger = logging.getLogger()):
self.logger = logger self.logger = logger
super(Context, self).__init__() super(Context, self).__init__()
self.slot_info = {} self.slot_info = {}
@@ -242,6 +281,7 @@ class Context:
self.release_mode: str = release_mode self.release_mode: str = release_mode
self.remaining_mode: str = remaining_mode self.remaining_mode: str = remaining_mode
self.collect_mode: str = collect_mode self.collect_mode: str = collect_mode
self.countdown_mode: str = countdown_mode
self.item_cheat = item_cheat self.item_cheat = item_cheat
self.exit_event = asyncio.Event() self.exit_event = asyncio.Event()
self.client_activity_timers: typing.Dict[ self.client_activity_timers: typing.Dict[
@@ -627,6 +667,7 @@ class Context:
"server_password": self.server_password, "password": self.password, "server_password": self.server_password, "password": self.password,
"release_mode": self.release_mode, "release_mode": self.release_mode,
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode, "remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
"countdown_mode": self.countdown_mode,
"item_cheat": self.item_cheat, "compatibility": self.compatibility} "item_cheat": self.item_cheat, "compatibility": self.compatibility}
} }
@@ -661,6 +702,7 @@ class Context:
self.release_mode = savedata["game_options"]["release_mode"] self.release_mode = savedata["game_options"]["release_mode"]
self.remaining_mode = savedata["game_options"]["remaining_mode"] self.remaining_mode = savedata["game_options"]["remaining_mode"]
self.collect_mode = savedata["game_options"]["collect_mode"] self.collect_mode = savedata["game_options"]["collect_mode"]
self.countdown_mode = savedata["game_options"].get("countdown_mode", self.countdown_mode)
self.item_cheat = savedata["game_options"]["item_cheat"] self.item_cheat = savedata["game_options"]["item_cheat"]
self.compatibility = savedata["game_options"]["compatibility"] self.compatibility = savedata["game_options"]["compatibility"]
@@ -1158,16 +1200,17 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
found = location_id in ctx.location_checks[team, finding_player] found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hint_status = status # Assign again because we're in a for loop
if found: if found:
status = HintStatus.HINT_FOUND hint_status = HintStatus.HINT_FOUND
elif status is None: elif hint_status is None:
if item_flags & ItemClassification.trap: if item_flags & ItemClassification.trap:
status = HintStatus.HINT_AVOID hint_status = HintStatus.HINT_AVOID
else: else:
status = HintStatus.HINT_PRIORITY hint_status = HintStatus.HINT_PRIORITY
hints.append( hints.append(
Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, status) Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status)
) )
return hints return hints
@@ -1492,6 +1535,23 @@ class ClientMessageProcessor(CommonCommandProcessor):
" You can ask the server admin for a /collect") " You can ask the server admin for a /collect")
return False return False
def _cmd_countdown(self, seconds: str = "10") -> bool:
"""Start a countdown in seconds"""
if self.ctx.countdown_mode == "disabled" or \
self.ctx.countdown_mode == "auto" and len(self.ctx.player_names) >= 30:
self.output("Sorry, client countdowns have been disabled on this server. You can ask the server admin for a /countdown")
return False
try:
timer = int(seconds, 10)
except ValueError:
timer = 10
else:
if timer > 60 * 60:
raise ValueError(f"{timer} is invalid. Maximum is 1 hour.")
async_start(countdown(self.ctx, timer))
return True
def _cmd_remaining(self) -> bool: def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient""" """List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled": if self.ctx.remaining_mode == "enabled":
@@ -2452,6 +2512,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
elif value_type == str and option_name.endswith("password"): elif value_type == str and option_name.endswith("password"):
def value_type(input_text: str): def value_type(input_text: str):
return None if input_text.lower() in {"null", "none", '""', "''"} else input_text return None if input_text.lower() in {"null", "none", '""', "''"} else input_text
elif option_name == "countdown_mode":
valid_values = {"enabled", "disabled", "auto"}
if option_value.lower() not in valid_values:
self.output(f"Unrecognized {option_name} value '{option_value}', known: {', '.join(valid_values)}")
return False
elif value_type == str and option_name.endswith("mode"): elif value_type == str and option_name.endswith("mode"):
valid_values = {"goal", "enabled", "disabled"} valid_values = {"goal", "enabled", "disabled"}
valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else []) valid_values.update(("auto", "auto_enabled") if option_name != "remaining_mode" else [])
@@ -2539,6 +2604,13 @@ def parse_args() -> argparse.Namespace:
goal: !collect can be used after goal completion goal: !collect can be used after goal completion
auto-enabled: !collect is available and automatically triggered on goal completion auto-enabled: !collect is available and automatically triggered on goal completion
''') ''')
parser.add_argument('--countdown_mode', default=defaults["countdown_mode"], nargs='?',
choices=['enabled', 'disabled', "auto"], help='''\
Select !countdown Accessibility. (default: %(default)s)
enabled: !countdown is always available
disabled: !countdown is never available
auto: !countdown is available for rooms with less than 30 players
''')
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?', parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
choices=['enabled', 'disabled', "goal"], help='''\ choices=['enabled', 'disabled', "goal"], help='''\
Select !remaining Accessibility. (default: %(default)s) Select !remaining Accessibility. (default: %(default)s)
@@ -2604,7 +2676,7 @@ async def main(args: argparse.Namespace):
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points, ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode, args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
args.remaining_mode, args.countdown_mode, args.remaining_mode,
args.auto_shutdown, args.compatibility, args.log_network) args.auto_shutdown, args.compatibility, args.log_network)
data_filename = args.multidata data_filename = args.multidata
@@ -2639,7 +2711,13 @@ async def main(args: argparse.Namespace):
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context) ctx.server = websockets.serve(
functools.partial(server, ctx=ctx),
host=ctx.host,
port=ctx.port,
ssl=ssl_context,
extensions=[server_per_message_deflate_factory],
)
ip = args.host if args.host else Utils.get_public_ipv4() ip = args.host if args.host else Utils.get_public_ipv4()
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port, logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
'No password' if not ctx.password else 'Password: %s' % ctx.password)) 'No password' if not ctx.password else 'Password: %s' % ctx.password))

View File

@@ -174,6 +174,8 @@ decode = JSONDecoder(object_hook=_object_hook).decode
class Endpoint: class Endpoint:
__slots__ = ("socket",)
socket: "ServerConnection" socket: "ServerConnection"
def __init__(self, socket): def __init__(self, socket):

View File

@@ -1380,7 +1380,7 @@ class NonLocalItems(ItemSet):
class StartInventory(ItemDict): class StartInventory(ItemDict):
"""Start with these items.""" """Start with the specified amount of these items. Example: "Bomb: 1" """
verify_item_name = True verify_item_name = True
display_name = "Start Inventory" display_name = "Start Inventory"
rich_text_doc = True rich_text_doc = True
@@ -1388,7 +1388,7 @@ class StartInventory(ItemDict):
class StartInventoryPool(StartInventory): class StartInventoryPool(StartInventory):
"""Start with these items and don't place them in the world. """Start with the specified amount of these items and don't place them in the world. Example: "Bomb: 1"
The game decides what the replacement items will be. The game decides what the replacement items will be.
""" """
@@ -1474,8 +1474,10 @@ class ItemLinks(OptionList):
super(ItemLinks, self).verify(world, player_name, plando_options) super(ItemLinks, self).verify(world, player_name, plando_options)
existing_links = set() existing_links = set()
for link in self.value: for link in self.value:
link["name"] = link["name"].strip()[:16].strip()
if link["name"] in existing_links: if link["name"] in existing_links:
raise Exception(f"You cannot have more than one link named {link['name']}.") raise Exception(f"Item link names are limited to their first 16 characters and must be unique. "
f"You have more than one link named '{link['name']}'.")
existing_links.add(link["name"]) existing_links.add(link["name"])
pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world) pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world)
@@ -1710,7 +1712,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
from jinja2 import Template from jinja2 import Template
from worlds import AutoWorldRegister from worlds import AutoWorldRegister
from Utils import local_path, __version__, tuplize_version from Utils import local_path, __version__
full_path: str full_path: str

View File

@@ -18,7 +18,7 @@ from json import loads, dumps
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
import Utils import Utils
from settings import Settings import settings
from Utils import async_start from Utils import async_start
from MultiServer import mark_raw from MultiServer import mark_raw
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
@@ -286,7 +286,7 @@ class SNESState(enum.IntEnum):
def launch_sni() -> None: def launch_sni() -> None:
sni_path = Settings.sni_options.sni_path sni_path = settings.get_settings().sni_options.sni_path
if not os.path.isdir(sni_path): if not os.path.isdir(sni_path):
sni_path = Utils.local_path(sni_path) sni_path = Utils.local_path(sni_path)
@@ -669,7 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None:
async def run_game(romfile: str) -> None: async def run_game(romfile: str) -> None:
auto_start = Settings.sni_options.snes_rom_start auto_start = settings.get_settings().sni_options.snes_rom_start
if auto_start is True: if auto_start is True:
import webbrowser import webbrowser
webbrowser.open(romfile) webbrowser.open(romfile)

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import concurrent.futures
import json import json
import typing import typing
import builtins import builtins
@@ -35,7 +36,7 @@ if typing.TYPE_CHECKING:
def tuplize_version(version: str) -> Version: def tuplize_version(version: str) -> Version:
return Version(*(int(piece, 10) for piece in version.split("."))) return Version(*(int(piece) for piece in version.split(".")))
class Version(typing.NamedTuple): class Version(typing.NamedTuple):
@@ -49,7 +50,6 @@ class Version(typing.NamedTuple):
__version__ = "0.6.4" __version__ = "0.6.4"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
version = Version(*version_tuple)
is_linux = sys.platform.startswith("linux") is_linux = sys.platform.startswith("linux")
is_macos = sys.platform == "darwin" is_macos = sys.platform == "darwin"
@@ -478,7 +478,7 @@ class RestrictedUnpickler(pickle.Unpickler):
mod = importlib.import_module(module) mod = importlib.import_module(module)
obj = getattr(mod, name) obj = getattr(mod, name)
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection, if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
self.options_module.PlandoText)): self.options_module.PlandoItem, self.options_module.PlandoText)):
return obj return obj
# Forbid everything else. # Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
@@ -721,13 +721,22 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]: def get_input_text_from_response(text: str, command: str) -> typing.Optional[str]:
"""
Parses the response text from `get_intended_text` to find the suggested input and autocomplete the command in
arguments with it.
:param text: The response text from `get_intended_text`.
:param command: The command to which the input text should be added. Must contain the prefix used by the command
(`!` or `/`).
:return: The command with the suggested input text appended, or None if no suggestion was found.
"""
if "did you mean " in text: if "did you mean " in text:
for question in ("Didn't find something that closely matches", for question in ("Didn't find something that closely matches",
"Too many close matches"): "Too many close matches"):
if text.startswith(question): if text.startswith(question):
name = get_text_between(text, "did you mean '", name = get_text_between(text, "did you mean '",
"'? (") "'? (")
return f"!{command} {name}" return f"{command} {name}"
elif text.startswith("Missing: "): elif text.startswith("Missing: "):
return text.replace("Missing: ", "!hint_location ") return text.replace("Missing: ", "!hint_location ")
return None return None
@@ -1130,3 +1139,40 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any]
if isinstance(obj, str): if isinstance(obj, str):
return False return False
return isinstance(obj, typing.Iterable) return isinstance(obj, typing.Iterable)
class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor):
"""
ThreadPoolExecutor that uses daemonic threads that do not keep the program alive.
NOTE: use this with caution because killed threads will not properly clean up.
"""
def _adjust_thread_count(self):
# see upstream ThreadPoolExecutor for details
import threading
import weakref
from concurrent.futures.thread import _worker
if self._idle_semaphore.acquire(timeout=0):
return
def weakref_cb(_, q=self._work_queue):
q.put(None)
num_threads = len(self._threads)
if num_threads < self._max_workers:
thread_name = f"{self._thread_name_prefix or self}_{num_threads}"
t = threading.Thread(
name=thread_name,
target=_worker,
args=(
weakref.ref(self, weakref_cb),
self._work_queue,
self._initializer,
self._initargs,
),
daemon=True,
)
t.start()
self._threads.add(t)
# NOTE: don't add to _threads_queues so we don't block on shutdown

View File

@@ -109,6 +109,13 @@ if __name__ == "__main__":
logging.exception(e) logging.exception(e)
logging.warning("Could not update LttP sprites.") logging.warning("Could not update LttP sprites.")
app = get_app() app = get_app()
from worlds import AutoWorldRegister
# Update to only valid WebHost worlds
invalid_worlds = {name for name, world in AutoWorldRegister.world_types.items()
if not hasattr(world.web, "tutorials")}
if invalid_worlds:
logging.error(f"Following worlds not loaded as they are invalid for WebHost: {invalid_worlds}")
AutoWorldRegister.world_types = {k: v for k, v in AutoWorldRegister.world_types.items() if k not in invalid_worlds}
create_options_files() create_options_files()
copy_tutorials_files_to_static() copy_tutorials_files_to_static()
if app.config["SELFLAUNCH"]: if app.config["SELFLAUNCH"]:

View File

@@ -1,6 +1,7 @@
import base64 import base64
import os import os
import socket import socket
import typing
import uuid import uuid
from flask import Flask from flask import Flask
@@ -61,20 +62,21 @@ cache = Cache()
Compress(app) Compress(app)
def to_python(value): def to_python(value: str) -> uuid.UUID:
return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '==')) return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '=='))
def to_url(value): def to_url(value: uuid.UUID) -> str:
return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
class B64UUIDConverter(BaseConverter): class B64UUIDConverter(BaseConverter):
def to_python(self, value): def to_python(self, value: str) -> uuid.UUID:
return to_python(value) return to_python(value)
def to_url(self, value): def to_url(self, value: typing.Any) -> str:
assert isinstance(value, uuid.UUID)
return to_url(value) return to_url(value)
@@ -84,7 +86,7 @@ app.jinja_env.filters["suuid"] = to_url
app.jinja_env.filters["title_sorted"] = title_sorted app.jinja_env.filters["title_sorted"] = title_sorted
def register(): def register() -> None:
"""Import submodules, triggering their registering on flask routing. """Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem.""" Note: initializes worlds subsystem."""
import importlib import importlib

View File

@@ -1,16 +1,15 @@
import json import json
import typing
from uuid import UUID from uuid import UUID
from flask import request, session, url_for from flask import request, session, url_for
from markupsafe import Markup from markupsafe import Markup
from pony.orm import commit, select from pony.orm import commit
from Utils import restricted_dumps from Utils import restricted_dumps
from WebHostLib import app, cache from WebHostLib import app
from WebHostLib.check import get_yaml_data, roll_options from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta from WebHostLib.generate import get_meta
from WebHostLib.models import Generation, STATE_QUEUED, STATE_STARTED, Seed, STATE_ERROR from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
from . import api_endpoints from . import api_endpoints
@@ -75,23 +74,12 @@ def generate_api():
def wait_seed_api(seed: UUID): def wait_seed_api(seed: UUID):
seed_id = seed seed_id = seed
seed = Seed.get(id=seed_id) seed = Seed.get(id=seed_id)
reply_dict: dict[str, typing.Any] = {"queue_len": get_queue_length()}
if seed: if seed:
reply_dict["text"] = "Generation done" return {"text": "Generation done"}, 201
return reply_dict, 201
generation = Generation.get(id=seed_id) generation = Generation.get(id=seed_id)
if not generation: if not generation:
reply_dict["text"] = "Generation not found" return {"text": "Generation not found"}, 404
return reply_dict, 404
elif generation.state == STATE_ERROR: elif generation.state == STATE_ERROR:
reply_dict["text"] = "Generation failed" return {"text": "Generation failed"}, 500
return reply_dict, 500 return {"text": "Generation running"}, 202
reply_dict["text"] = "Generation running"
return reply_dict, 202
@cache.memoize(timeout=5)
def get_queue_length() -> int:
return select(generation for generation in Generation if
generation.state == STATE_STARTED or generation.state == STATE_QUEUED).count()

View File

@@ -17,7 +17,7 @@ from .locker import Locker, AlreadyRunningException
_stop_event = Event() _stop_event = Event()
def stop(): def stop() -> None:
"""Stops previously launched threads""" """Stops previously launched threads"""
global _stop_event global _stop_event
stop_event = _stop_event stop_event = _stop_event
@@ -36,25 +36,39 @@ def handle_generation_failure(result: BaseException):
logging.exception(e) logging.exception(e)
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None: def _mp_gen_game(
gen_options: dict,
meta: dict[str, Any] | None = None,
owner=None,
sid=None,
timeout: int|None = None,
) -> PrimaryKey | None:
from setproctitle import setproctitle from setproctitle import setproctitle
setproctitle(f"Generator ({sid})") setproctitle(f"Generator ({sid})")
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid) try:
setproctitle(f"Generator (idle)") return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout)
return res finally:
setproctitle(f"Generator (idle)")
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None:
try: try:
meta = json.loads(generation.meta) meta = json.loads(generation.meta)
options = restricted_loads(generation.options) options = restricted_loads(generation.options)
logging.info(f"Generating {generation.id} for {len(options)} players") logging.info(f"Generating {generation.id} for {len(options)} players")
pool.apply_async(_mp_gen_game, (options,), pool.apply_async(
{"meta": meta, _mp_gen_game,
"sid": generation.id, (options,),
"owner": generation.owner}, {
handle_generation_success, handle_generation_failure) "meta": meta,
"sid": generation.id,
"owner": generation.owner,
"timeout": timeout,
},
handle_generation_success,
handle_generation_failure,
)
except Exception as e: except Exception as e:
generation.state = STATE_ERROR generation.state = STATE_ERROR
commit() commit()
@@ -135,6 +149,7 @@ def autogen(config: dict):
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator, with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
initargs=(config,), maxtasksperchild=10) as generator_pool: initargs=(config,), maxtasksperchild=10) as generator_pool:
job_time = config["JOB_TIME"]
with db_session: with db_session:
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED) to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
@@ -145,7 +160,7 @@ def autogen(config: dict):
if sid: if sid:
generation.delete() generation.delete()
else: else:
launch_generator(generator_pool, generation) launch_generator(generator_pool, generation, timeout=job_time)
commit() commit()
select(generation for generation in Generation if generation.state == STATE_ERROR).delete() select(generation for generation in Generation if generation.state == STATE_ERROR).delete()
@@ -157,7 +172,7 @@ def autogen(config: dict):
generation for generation in Generation generation for generation in Generation
if generation.state == STATE_QUEUED).for_update() if generation.state == STATE_QUEUED).for_update()
for generation in to_start: for generation in to_start:
launch_generator(generator_pool, generation) launch_generator(generator_pool, generation, timeout=job_time)
except AlreadyRunningException: except AlreadyRunningException:
logging.info("Autogen reports as already running, not starting another.") logging.info("Autogen reports as already running, not starting another.")

View File

@@ -19,7 +19,10 @@ from pony.orm import commit, db_session, select
import Utils import Utils
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert from MultiServer import (
Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert,
server_per_message_deflate_factory,
)
from Utils import restricted_loads, cache_argsless from Utils import restricted_loads, cache_argsless
from .locker import Locker from .locker import Locker
from .models import Command, GameDataPackage, Room, db from .models import Command, GameDataPackage, Room, db
@@ -97,6 +100,7 @@ class WebHostContext(Context):
self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext) self.main_loop.call_soon_threadsafe(cmdprocessor, command.commandtext)
command.delete() command.delete()
commit() commit()
del commands
time.sleep(5) time.sleep(5)
@db_session @db_session
@@ -146,13 +150,13 @@ class WebHostContext(Context):
self.location_name_groups = static_location_name_groups self.location_name_groups = static_location_name_groups
return self._load(multidata, game_data_packages, True) return self._load(multidata, game_data_packages, True)
@db_session
def init_save(self, enabled: bool = True): def init_save(self, enabled: bool = True):
self.saving = enabled self.saving = enabled
if self.saving: if self.saving:
savegame_data = Room.get(id=self.room_id).multisave with db_session:
if savegame_data: savegame_data = Room.get(id=self.room_id).multisave
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave)) if savegame_data:
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
self._start_async_saving(atexit_save=False) self._start_async_saving(atexit_save=False)
threading.Thread(target=self.listen_to_db_commands, daemon=True).start() threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@@ -282,8 +286,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
assert ctx.server is None assert ctx.server is None
try: try:
ctx.server = websockets.serve( ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context()) functools.partial(server, ctx=ctx),
ctx.host,
ctx.port,
ssl=get_ssl_context(),
extensions=[server_per_message_deflate_factory],
)
await ctx.server await ctx.server
except OSError: # likely port in use except OSError: # likely port in use
ctx.server = websockets.serve( ctx.server = websockets.serve(
@@ -304,6 +312,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
with db_session: with db_session:
room = Room.get(id=ctx.room_id) room = Room.get(id=ctx.room_id)
room.last_port = port room.last_port = port
del room
else: else:
ctx.logger.exception("Could not determine port. Likely hosting failure.") ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session: with db_session:
@@ -322,6 +331,7 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
with db_session: with db_session:
room = Room.get(id=room_id) room = Room.get(id=room_id)
room.last_port = -1 room.last_port = -1
del room
logger.exception(e) logger.exception(e)
raise raise
else: else:
@@ -333,11 +343,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task # NOTE: async saving should probably be an async task and could be merged with shutdown_task
with (db_session): with db_session:
# ensure the Room does not spin up again on its own, minute of safety buffer # ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id) room = Room.get(id=room_id)
room.last_activity = datetime.datetime.utcnow() - \ room.last_activity = datetime.datetime.utcnow() - \
datetime.timedelta(minutes=1, seconds=room.timeout) datetime.timedelta(minutes=1, seconds=room.timeout)
del room
logging.info(f"Shutting down room {room_id} on {name}.") logging.info(f"Shutting down room {room_id} on {name}.")
finally: finally:
await asyncio.sleep(5) await asyncio.sleep(5)

View File

@@ -14,7 +14,7 @@ from pony.orm import commit, db_session
from BaseClasses import get_seed, seeddigits from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name, mystery_argparse from Generate import PlandoOptions, handle_name, mystery_argparse
from Main import main as ERmain from Main import main as ERmain
from Utils import __version__, restricted_dumps from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor
from WebHostLib import app from WebHostLib import app
from settings import ServerOptions, GeneratorOptions from settings import ServerOptions, GeneratorOptions
from .check import get_yaml_data, roll_options from .check import get_yaml_data, roll_options
@@ -33,6 +33,7 @@ def get_meta(options_source: dict, race: bool = False) -> dict[str, list[str] |
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)), "release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)), "remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)), "collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
"countdown_mode": str(options_source.get("countdown_mode", ServerOptions.countdown_mode)),
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))), "item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
"server_password": str(options_source.get("server_password", None)), "server_password": str(options_source.get("server_password", None)),
} }
@@ -72,6 +73,10 @@ def generate(race=False):
return render_template("generate.html", race=race, version=__version__) return render_template("generate.html", race=race, version=__version__)
def format_exception(e: BaseException) -> str:
return f"{e.__class__.__name__}: {e}"
def start_generation(options: dict[str, dict | str], meta: dict[str, Any]): def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
results, gen_options = roll_options(options, set(meta["plando_options"])) results, gen_options = roll_options(options, set(meta["plando_options"]))
@@ -92,7 +97,9 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
except PicklingError as e: except PicklingError as e:
from .autolauncher import handle_generation_failure from .autolauncher import handle_generation_failure
handle_generation_failure(e) handle_generation_failure(e)
return render_template("seedError.html", seed_error=("PicklingError: " + str(e))) meta["error"] = format_exception(e)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
commit() commit()
@@ -100,16 +107,18 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]):
else: else:
try: try:
seed_id = gen_game({name: vars(options) for name, options in gen_options.items()}, seed_id = gen_game({name: vars(options) for name, options in gen_options.items()},
meta=meta, owner=session["_id"].int) meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"])
except BaseException as e: except BaseException as e:
from .autolauncher import handle_generation_failure from .autolauncher import handle_generation_failure
handle_generation_failure(e) handle_generation_failure(e)
return render_template("seedError.html", seed_error=(e.__class__.__name__ + ": " + str(e))) meta["error"] = format_exception(e)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
return redirect(url_for("view_seed", seed=seed_id)) return redirect(url_for("view_seed", seed=seed_id))
def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None): def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None):
if meta is None: if meta is None:
meta = {} meta = {}
@@ -128,7 +137,7 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)) seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
args = mystery_argparse() args = mystery_argparse([]) # Just to set up the Namespace with defaults
args.multi = playercount args.multi = playercount
args.seed = seed args.seed = seed
args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
@@ -163,11 +172,12 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
ERmain(args, seed, baked_server_options=meta["server_options"]) ERmain(args, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race) return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
thread_pool = DaemonThreadPoolExecutor(max_workers=1)
thread = thread_pool.submit(task) thread = thread_pool.submit(task)
try: try:
return thread.result(app.config["JOB_TIME"]) return thread.result(timeout)
except concurrent.futures.TimeoutError as e: except concurrent.futures.TimeoutError as e:
if sid: if sid:
with db_session: with db_session:
@@ -175,11 +185,14 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
if gen is not None: if gen is not None:
gen.state = STATE_ERROR gen.state = STATE_ERROR
meta = json.loads(gen.meta) meta = json.loads(gen.meta)
meta["error"] = ( meta["error"] = ("Allowed time for Generation exceeded, " +
"Allowed time for Generation exceeded, please consider generating locally instead. " + "please consider generating locally instead. " +
e.__class__.__name__ + ": " + str(e)) format_exception(e))
gen.meta = json.dumps(meta) gen.meta = json.dumps(meta)
commit() commit()
except (KeyboardInterrupt, SystemExit):
# don't update db, retry next time
raise
except BaseException as e: except BaseException as e:
if sid: if sid:
with db_session: with db_session:
@@ -187,10 +200,15 @@ def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None,
if gen is not None: if gen is not None:
gen.state = STATE_ERROR gen.state = STATE_ERROR
meta = json.loads(gen.meta) meta = json.loads(gen.meta)
meta["error"] = (e.__class__.__name__ + ": " + str(e)) meta["error"] = format_exception(e)
gen.meta = json.dumps(meta) gen.meta = json.dumps(meta)
commit() commit()
raise raise
finally:
# free resources claimed by thread pool, if possible
# NOTE: Timeout depends on the process being killed at some point
# since we can't actually cancel a running gen at the moment.
thread_pool.shutdown(wait=False, cancel_futures=True)
@app.route('/wait/<suuid:seed>') @app.route('/wait/<suuid:seed>')
@@ -204,7 +222,9 @@ def wait_seed(seed: UUID):
if not generation: if not generation:
return "Generation not found." return "Generation not found."
elif generation.state == STATE_ERROR: elif generation.state == STATE_ERROR:
return render_template("seedError.html", seed_error=generation.meta) meta = json.loads(generation.meta)
details = json.dumps(meta, indent=4).strip()
return render_template("seedError.html", seed_error=meta["error"], details=details)
return render_template("waitSeed.html", seed_id=seed_id) return render_template("waitSeed.html", seed_id=seed_id)

90
WebHostLib/markdown.py Normal file
View File

@@ -0,0 +1,90 @@
import re
from collections import Counter
import mistune
from werkzeug.utils import secure_filename
__all__ = [
"ImgUrlRewriteInlineParser",
'render_markdown',
]
class ImgUrlRewriteInlineParser(mistune.InlineParser):
relative_url_base: str
def __init__(self, relative_url_base: str, hard_wrap: bool = False) -> None:
super().__init__(hard_wrap)
self.relative_url_base = relative_url_base
@staticmethod
def _find_game_name_by_folder_name(name: str) -> str | None:
from worlds.AutoWorld import AutoWorldRegister
for world_name, world_type in AutoWorldRegister.world_types.items():
if world_type.__module__ == f"worlds.{name}":
return world_name
return None
def parse_link(self, m: re.Match[str], state: mistune.InlineState) -> int | None:
res = super().parse_link(m, state)
if res is not None and state.tokens and state.tokens[-1]["type"] == "image":
image_token = state.tokens[-1]
url: str = image_token["attrs"]["url"]
if not url.startswith("/") and not "://" in url:
# replace relative URL to another world's doc folder with the webhost folder layout
if url.startswith("../../") and "/docs/" in self.relative_url_base:
parts = url.split("/", 4)
if parts[2] != ".." and parts[3] == "docs":
game_name = self._find_game_name_by_folder_name(parts[2])
if game_name is not None:
url = "/".join(parts[1:2] + [secure_filename(game_name)] + parts[4:])
# change relative URL to point to deployment folder
url = f"{self.relative_url_base}/{url}"
image_token['attrs']['url'] = url
return res
def render_markdown(path: str, img_url_base: str | None = None) -> str:
markdown = mistune.create_markdown(
escape=False,
plugins=[
"strikethrough",
"footnotes",
"table",
"speedup",
],
)
heading_id_count: Counter[str] = Counter()
def heading_id(text: str) -> str:
nonlocal heading_id_count
# there is no good way to do this without regex
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
n = heading_id_count[s]
heading_id_count[s] += 1
if n > 0:
s += f"-{n}"
return s
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
for tok in state.tokens:
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
text = tok["text"]
assert isinstance(text, str)
unique_id = heading_id(text)
tok["attrs"]["id"] = unique_id
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
markdown.before_render_hooks.append(id_hook)
if img_url_base:
markdown.inline = ImgUrlRewriteInlineParser(img_url_base)
with open(path, encoding="utf-8-sig") as f:
document = f.read()
html = markdown(document)
assert isinstance(html, str), "Unexpected mistune renderer in render_markdown"
return html

View File

@@ -9,6 +9,7 @@ from werkzeug.utils import secure_filename
from worlds.AutoWorld import AutoWorldRegister, World from worlds.AutoWorld import AutoWorldRegister, World
from . import app, cache from . import app, cache
from .markdown import render_markdown
from .models import Seed, Room, Command, UUID, uuid4 from .models import Seed, Room, Command, UUID, uuid4
from Utils import title_sorted from Utils import title_sorted
@@ -27,49 +28,6 @@ def get_visible_worlds() -> dict[str, type(World)]:
return worlds return worlds
def render_markdown(path: str) -> str:
import mistune
from collections import Counter
markdown = mistune.create_markdown(
escape=False,
plugins=[
"strikethrough",
"footnotes",
"table",
"speedup",
],
)
heading_id_count: Counter[str] = Counter()
def heading_id(text: str) -> str:
nonlocal heading_id_count
import re # there is no good way to do this without regex
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
n = heading_id_count[s]
heading_id_count[s] += 1
if n > 0:
s += f"-{n}"
return s
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
for tok in state.tokens:
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
text = tok["text"]
assert isinstance(text, str)
unique_id = heading_id(text)
tok["attrs"]["id"] = unique_id
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
markdown.before_render_hooks.append(id_hook)
with open(path, encoding="utf-8-sig") as f:
document = f.read()
return markdown(document)
@app.errorhandler(404) @app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound) @app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err): def page_not_found(err):
@@ -91,10 +49,9 @@ def game_info(game, lang):
theme = get_world_theme(game) theme = get_world_theme(game)
secure_game_name = secure_filename(game) secure_game_name = secure_filename(game)
lang = secure_filename(lang) lang = secure_filename(lang)
document = render_markdown(os.path.join( file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
app.static_folder, "generated", "docs", file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
secure_game_name, f"{lang}_{secure_game_name}.md" document = render_markdown(os.path.join(file_dir, f"{lang}_{secure_game_name}.md"), file_dir_url)
))
return render_template( return render_template(
"markdown_document.html", "markdown_document.html",
title=f"{game} Guide", title=f"{game} Guide",
@@ -119,10 +76,9 @@ def tutorial(game: str, file: str):
theme = get_world_theme(game) theme = get_world_theme(game)
secure_game_name = secure_filename(game) secure_game_name = secure_filename(game)
file = secure_filename(file) file = secure_filename(file)
document = render_markdown(os.path.join( file_dir = os.path.join(app.static_folder, "generated", "docs", secure_game_name)
app.static_folder, "generated", "docs", file_dir_url = url_for("static", filename=f"generated/docs/{secure_game_name}")
secure_game_name, file+".md" document = render_markdown(os.path.join(file_dir, f"{file}.md"), file_dir_url)
))
return render_template( return render_template(
"markdown_document.html", "markdown_document.html",
title=f"{game} Guide", title=f"{game} Guide",
@@ -271,9 +227,9 @@ def host_room(room: UUID):
or "Discordbot" in request.user_agent.string or "Discordbot" in request.user_agent.string
or not any(browser_token in request.user_agent.string for browser_token in browser_tokens)) or not any(browser_token in request.user_agent.string for browser_token in browser_tokens))
def get_log(max_size: int = 0 if automated else 1024000) -> str: def get_log(max_size: int = 0 if automated else 1024000) -> Tuple[str, int]:
if max_size == 0: if max_size == 0:
return "" return "", 0
try: try:
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log: with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
raw_size = 0 raw_size = 0
@@ -284,9 +240,9 @@ def host_room(room: UUID):
break break
raw_size += len(block) raw_size += len(block)
fragments.append(block.decode("utf-8")) fragments.append(block.decode("utf-8"))
return "".join(fragments) return "".join(fragments), raw_size
except FileNotFoundError: except FileNotFoundError:
return "" return "", 0
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log) return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)

View File

@@ -76,7 +76,7 @@ def filter_rst_to_html(text: str) -> str:
lines = text.splitlines() lines = text.splitlines()
text = lines[0] + "\n" + dedent("\n".join(lines[1:])) text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
return publish_parts(text, writer_name='html', settings=None, settings_overrides={ return publish_parts(text, writer='html', settings=None, settings_overrides={
'raw_enable': False, 'raw_enable': False,
'file_insertion_enabled': False, 'file_insertion_enabled': False,
'output_encoding': 'unicode' 'output_encoding': 'unicode'
@@ -231,7 +231,7 @@ def generate_yaml(game: str):
if key_parts[-1] == "qty": if key_parts[-1] == "qty":
if key_parts[0] not in options: if key_parts[0] not in options:
options[key_parts[0]] = {} options[key_parts[0]] = {}
if val != "0": if val and val != "0":
options[key_parts[0]][key_parts[1]] = int(val) options[key_parts[0]][key_parts[1]] = int(val)
del options[key] del options[key]

View File

@@ -4,9 +4,11 @@ pony>=0.7.19; python_version <= '3.12'
pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13' pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13'
waitress>=3.0.2 waitress>=3.0.2
Flask-Caching>=2.3.0 Flask-Caching>=2.3.0
Flask-Compress>=1.17 Flask-Compress>=1.17; python_version >= '3.12'
Flask-Compress==1.18; python_version <= '3.11' # 3.11's pkg_resources can't resolve the new "backports.zstd" dependency
Flask-Limiter>=3.12 Flask-Limiter>=3.12
bokeh>=3.6.3 bokeh>=3.6.3
markupsafe>=3.0.2 markupsafe>=3.0.2
setproctitle>=1.3.5 setproctitle>=1.3.5
mistune>=3.1.3 mistune>=3.1.3
docutils>=0.22.2

View File

@@ -66,7 +66,7 @@ is to ensure items necessary to complete the game will be accessible to the play
rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are rules allowing certain items to be placed in normally unreachable locations, provided the player has indicated they are
comfortable exploiting certain glitches in the game. comfortable exploiting certain glitches in the game.
## I want to add a game to the Archipelago randomizer. How do I do that? ## I want to develop a game implementation for Archipelago. How do I do that?
The best way to get started is to take a look at our code on GitHub: The best way to get started is to take a look at our code on GitHub:
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago). [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
@@ -77,4 +77,5 @@ There, you will find examples of games in the `worlds` folder:
You may also find developer documentation in the `docs` folder: You may also find developer documentation in the `docs` folder:
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs). [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions, feel free to ask in the **#ap-world-dev** channel on our Discord. If you have more questions regarding development of a game implementation, feel free to ask in the **#ap-world-dev**
channel on our Discord.

View File

@@ -72,3 +72,13 @@ code{
padding-right: 0.25rem; padding-right: 0.25rem;
color: #000000; color: #000000;
} }
code.grassy {
background-color: #b5e9a4;
border: 1px solid #2a6c2f;
white-space: preserve;
text-align: left;
display: block;
font-size: 14px;
line-height: 20px;
}

View File

@@ -13,3 +13,7 @@
min-height: 360px; min-height: 360px;
text-align: center; text-align: center;
} }
h2, h4 {
color: #ffffff;
}

View File

@@ -58,8 +58,7 @@
Open Log File... Open Log File...
</a> </a>
</div> </div>
{% set log = get_log() -%} {% set log, log_len = get_log() -%}
{%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
<div id="logger" style="white-space: pre">{{ log }}</div> <div id="logger" style="white-space: pre">{{ log }}</div>
<script> <script>
let url = '{{ url_for('display_log', room = room.id) }}'; let url = '{{ url_for('display_log', room = room.id) }}';

View File

@@ -4,16 +4,20 @@
{% block head %} {% block head %}
<title>Generation failed, please retry.</title> <title>Generation failed, please retry.</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/waitSeed.css') }}"/>
{% endblock %} {% endblock %}
{% block body %} {% block body %}
{% include 'header/oceanIslandHeader.html' %} {% include 'header/oceanIslandHeader.html' %}
<div id="wait-seed-wrapper" class="grass-island"> <div id="wait-seed-wrapper" class="grass-island">
<div id="wait-seed"> <div id="wait-seed">
<h1>Generation failed</h1> <h1>Generation Failed</h1>
<h2>please retry</h2> <h2>Please try again!</h2>
{{ seed_error }} <p>{{ seed_error }}</p>
<h4>More details:</h4>
<p>
<code class="grassy">{{ details }}</code>
</p>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -31,6 +31,9 @@
{% include 'header/oceanHeader.html' %} {% include 'header/oceanHeader.html' %}
<div id="games" class="markdown"> <div id="games" class="markdown">
<h1>Currently Supported Games</h1> <h1>Currently Supported Games</h1>
<p>Below are the games that are currently included with the Archipelago software. To play a game that is not on
this page, please refer to the <a href="/tutorial/Archipelago/setup/en#playing-with-custom-worlds">playing with
custom worlds</a> section of the setup guide.</p>
<div class="js-only"> <div class="js-only">
<label for="game-search">Search for your game below!</label><br /> <label for="game-search">Search for your game below!</label><br />
<div class="page-controls"> <div class="page-controls">

View File

@@ -30,21 +30,10 @@
} }
const data = await response.json(); const data = await response.json();
if (data.queue_len === 1){ waitSeedDiv.innerHTML = `
waitSeedDiv.innerHTML = ` <h1>Generation in Progress</h1>
<h1>Generation in Progress</h1> <p>${data.text}</p>
<p>${data.text}</p> `;
<p>This is the only generation in the queue.</p>
`;
}
else {
waitSeedDiv.innerHTML = `
<h1>Generation in Progress</h1>
<p>${data.text}</p>
<p>There are ${data.queue_len} generations in the queue.</p>
`;
}
setTimeout(checkStatus, 1000); // Continue polling. setTimeout(checkStatus, 1000); // Continue polling.
} catch (error) { } catch (error) {

View File

@@ -1,40 +1,83 @@
# apworld Specification # APWorld Specification
Archipelago depends on worlds to provide game-specific details like items, locations and output generation. Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
Those are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed). These are called "APWorlds".
They are located in the `worlds/` folder (source) or `<install dir>/lib/worlds/` (when installed).
See [world api.md](world%20api.md) for details. See [world api.md](world%20api.md) for details.
APWorlds can either be a folder, or they can be packaged as an .apworld file.
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld` ## .apworld File Format
file into the worlds folder.
**Warning:** apworlds have to be all lower case, otherwise they raise a bogus Exception when trying to import in frozen python 3.10+! The `.apworld` file format provides a way to package and ship an APWorld that is not part of the main distribution
by placing a `*.apworld` file into the worlds folder.
`.apworld` files are zip archives, all lower case, with the file ending `.apworld`.
## File Format
apworld files are zip archives, all lower case, with the file ending `.apworld`.
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`. the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
**Warning:** `.apworld` files have to be all lower case,
otherwise they raise a bogus Exception when trying to import in frozen python 3.10+!
## Metadata ## Metadata
Metadata about the apworld is defined in an `archipelago.json` file inside the zip archive. Metadata about the APWorld is defined in an `archipelago.json` file.
The current format version has at minimum:
If the APWorld is a folder, the only required field is "game":
```json ```json
{ {
"version": 6, "game": "Game Name"
"compatible_version": 5, }
```
There are also the following optional fields:
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
Archipelago version respectively to filter those files from being loaded.
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
An APWorld without a world_version is always treated as older than one with a version
(**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`)
* `authors` - a list of authors, to eventually be displayed in various user-facing places such as WebHost and
package managers. Should always be a list of strings.
If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`,
which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py).
These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the
["Build apworlds" launcher component](#build-apworlds-launcher-component),
which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself.
### "Build apworlds" Launcher Component
In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`,
and add `archipelago.json` manifest files to them.
These .apworld files will be output to `build/apworlds` (relative to the Archipelago root directory).
The `archipelago.json` file in each .apworld will automatically include the appropriate
`version` and `compatible_version`.
If a world folder has an `archipelago.json` in its root, any fields it contains will be carried over.
So, a world folder with an `archipelago.json` that looks like this:
```json
{
"game": "Game Name",
"minimum_ap_version": "0.6.4",
"world_version": "2.1.4",
"authors": ["NewSoupVi"]
}
```
will be packaged into an `.apworld` with a manifest file inside of it that looks like this:
```json
{
"minimum_ap_version": "0.6.4",
"world_version": "2.1.4",
"authors": ["NewSoupVi"],
"version": 7,
"compatible_version": 7,
"game": "Game Name" "game": "Game Name"
} }
``` ```
with the following optional version fields using the format `"1.0.0"` to represent major.minor.build: This is the recommended workflow for packaging your world to an `.apworld`.
* `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current
Archipelago version respectively to filter those files from being loaded
* `world_version` - an arbitrary version for that world in order to only load the newest valid world.
An apworld without a world_version is always treated as older than one with a version
## Extra Data ## Extra Data
@@ -43,7 +86,7 @@ The zip can contain arbitrary files in addition what was specified above.
## Caveats ## Caveats
Imports from other files inside the apworld have to use relative imports. e.g. `from .options import MyGameOptions` Imports from other files inside the APWorld have to use relative imports. e.g. `from .options import MyGameOptions`
Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or
`from worlds.AutoWorld import World` `from worlds.AutoWorld import World`

View File

@@ -180,8 +180,8 @@ Root: HKCR; Subkey: "{#MyAppName}mm2patch\shell\open\command"; ValueData: """{a
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";

21
kvui.py
View File

@@ -34,6 +34,17 @@ from kivy.config import Config
Config.set("input", "mouse", "mouse,disable_multitouch") Config.set("input", "mouse", "mouse,disable_multitouch")
Config.set("kivy", "exit_on_escape", "0") Config.set("kivy", "exit_on_escape", "0")
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
# Workaround for an issue where importing kivy.core.window before loading sounds
# will hang the whole application on Linux once the first sound is loaded.
# kivymd imports kivy.core.window, so we have to do this before the first kivymd import.
# No longer necessary when we switch to kivy 3.0.0, which fixes this issue.
from kivy.core.audio import SoundLoader
for classobj in SoundLoader._classes:
# The least invasive way to force a SoundLoader class to load its audio engine seems to be calling
# .extensions(), which e.g. in audio_sdl2.pyx then calls a function called "mix_init()"
classobj.extensions()
from kivymd.uix.divider import MDDivider from kivymd.uix.divider import MDDivider
from kivy.core.window import Window from kivy.core.window import Window
from kivy.core.clipboard import Clipboard from kivy.core.clipboard import Clipboard
@@ -838,15 +849,15 @@ class GameManager(ThemedApp):
self.log_panels: typing.Dict[str, Widget] = {} self.log_panels: typing.Dict[str, Widget] = {}
# keep track of last used command to autofill on click # keep track of last used command to autofill on click
self.last_autofillable_command = "hint" self.last_autofillable_command = "!hint"
autofillable_commands = ("hint_location", "hint", "getitem") autofillable_commands = ("!hint_location", "!hint", "!getitem")
original_say = ctx.on_user_say original_say = ctx.on_user_say
def intercept_say(text): def intercept_say(text):
text = original_say(text) text = original_say(text)
if text: if text:
for command in autofillable_commands: for command in autofillable_commands:
if text.startswith("!" + command): if text.startswith(command):
self.last_autofillable_command = command self.last_autofillable_command = command
break break
return text return text
@@ -1099,10 +1110,6 @@ class GameManager(ThemedApp):
hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", []) hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", [])
self.hint_log.refresh_hints(hints) self.hint_log.refresh_hints(hints)
# 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): class LogtoUI(logging.Handler):
def __init__(self, on_log): def __init__(self, on_log):

16
ruff.toml Normal file
View File

@@ -0,0 +1,16 @@
line-length = 120
indent-width = 4
target-version = "py311"
[lint]
select = ["B", "C", "E", "F", "W", "I", "N", "Q", "UP", "RET", "RSE", "RUF", "ISC", "PLC", "PLE", "PLW", "T20", "PERF"]
ignore = [
"B011", # In AP, the use of assert False is essential because we optimise out these statements for release builds.
"C901", # Author disagrees with limiting branch complexity
"N818", # Author agrees with this rule, but Core AP violates this and changing it would be a hassle.
"PLC0415", # In AP, we consider local imports totally fine & necessary
"PLC1802", # Author agrees with this rule, but it literally changes the functionality of the code, which is unsafe.
"PLC1901", # This is just not equivalent
"PLE1141", # Gives false positives when the dict keys are tuples, but does not mention this in the suggested fix.
"UP015", # Explicit is better than implicit, so we'd prefer to keep "r" in open() calls.
]

View File

@@ -579,6 +579,17 @@ class ServerOptions(Group):
"goal" -> Client can ask for remaining items after goal completion "goal" -> Client can ask for remaining items after goal completion
""" """
class CountdownMode(str):
"""
Countdown modes
Determines whether or not a player can initiate a countdown with !countdown
Note that /countdown is always available to the host.
"enabled" -> Client can always initiate a countdown with !countdown.
"disabled" -> Client can never initiate a countdown with !countdown.
"auto" -> !countdown will be available for any room with less than 30 slots.
"""
class AutoShutdown(int): class AutoShutdown(int):
"""Automatically shut down the server after this many seconds without new location checks, 0 to keep running""" """Automatically shut down the server after this many seconds without new location checks, 0 to keep running"""
@@ -613,6 +624,7 @@ class ServerOptions(Group):
release_mode: ReleaseMode = ReleaseMode("auto") release_mode: ReleaseMode = ReleaseMode("auto")
collect_mode: CollectMode = CollectMode("auto") collect_mode: CollectMode = CollectMode("auto")
remaining_mode: RemainingMode = RemainingMode("goal") remaining_mode: RemainingMode = RemainingMode("goal")
countdown_mode: CountdownMode = CountdownMode("auto")
auto_shutdown: AutoShutdown = AutoShutdown(0) auto_shutdown: AutoShutdown = AutoShutdown(0)
compatibility: Compatibility = Compatibility(2) compatibility: Compatibility = Compatibility(2)
log_network: LogNetwork = LogNetwork(0) log_network: LogNetwork = LogNetwork(0)

View File

@@ -146,7 +146,16 @@ def download_SNI() -> None:
signtool: str | None = None signtool: str | None = None
try: try:
with urllib.request.urlopen('http://192.168.206.4:12345/connector/status') as response: import socket
sign_host, sign_port = "192.168.206.4", 12345
# check if the sign_host is on a local network
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect((sign_host, sign_port))
if s.getsockname()[0].rsplit(".", 1)[0] != sign_host.rsplit(".", 1)[0]:
raise ConnectionError() # would go through default route
# configure signtool
with urllib.request.urlopen(f"http://{sign_host}:{sign_port}/connector/status") as response:
html = response.read() html = response.read()
if b"status=OK\n" in html: if b"status=OK\n" in html:
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 ' signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 '
@@ -372,7 +381,6 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
from Options import generate_yaml_templates from Options import generate_yaml_templates
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
from worlds.Files import APWorldContainer from worlds.Files import APWorldContainer
from Utils import version
assert not non_apworlds - set(AutoWorldRegister.world_types), \ assert not non_apworlds - set(AutoWorldRegister.world_types), \
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld" f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
folders_to_remove: list[str] = [] folders_to_remove: list[str] = []
@@ -382,15 +390,25 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1] file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = self.libfolder / "worlds" / file_name world_directory = self.libfolder / "worlds" / file_name
if os.path.isfile(world_directory / "archipelago.json"): if os.path.isfile(world_directory / "archipelago.json"):
manifest = json.load(open(world_directory / "archipelago.json")) with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it"
"does not define a \"game\"."
)
assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
)
else: else:
manifest = {} manifest = {}
# this method creates an apworld that cannot be moved to a different OS or minor python version, # this method creates an apworld that cannot be moved to a different OS or minor python version,
# which should be ok # which should be ok
zip_path = self.libfolder / "worlds" / (file_name + ".apworld") zip_path = self.libfolder / "worlds" / (file_name + ".apworld")
apworld = APWorldContainer(str(zip_path)) apworld = APWorldContainer(str(zip_path))
apworld.minimum_ap_version = version apworld.minimum_ap_version = version_tuple
apworld.maximum_ap_version = version apworld.maximum_ap_version = version_tuple
apworld.game = worldtype.game apworld.game = worldtype.game
manifest.update(apworld.get_manifest()) manifest.update(apworld.get_manifest())
apworld.manifest_path = f"{file_name}/archipelago.json" apworld.manifest_path = f"{file_name}/archipelago.json"

View File

View File

@@ -0,0 +1,227 @@
#!/usr/bin/env python
# based on python-websockets compression benchmark (c) Aymeric Augustin and contributors
# https://github.com/python-websockets/websockets/blob/main/experiments/compression/benchmark.py
import collections
import time
import zlib
from typing import Iterable
REPEAT = 10
WB, ML = 12, 5 # defaults used as a reference
WBITS = range(9, 16)
MEMLEVELS = range(1, 10)
def benchmark(data: Iterable[bytes]) -> None:
size: dict[int, dict[int, float]] = collections.defaultdict(dict)
duration: dict[int, dict[int, float]] = collections.defaultdict(dict)
for wbits in WBITS:
for memLevel in MEMLEVELS:
encoder = zlib.compressobj(wbits=-wbits, memLevel=memLevel)
encoded = []
print(f"Compressing {REPEAT} times with {wbits=} and {memLevel=}")
t0 = time.perf_counter()
for _ in range(REPEAT):
for item in data:
# Taken from PerMessageDeflate.encode
item = encoder.compress(item) + encoder.flush(zlib.Z_SYNC_FLUSH)
if item.endswith(b"\x00\x00\xff\xff"):
item = item[:-4]
encoded.append(item)
t1 = time.perf_counter()
size[wbits][memLevel] = sum(len(item) for item in encoded) / REPEAT
duration[wbits][memLevel] = (t1 - t0) / REPEAT
raw_size = sum(len(item) for item in data)
print("=" * 79)
print("Compression ratio")
print("=" * 79)
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
for wbits in WBITS:
print(
"\t".join(
[str(wbits)]
+ [
f"{100 * (1 - size[wbits][memLevel] / raw_size):.1f}%"
for memLevel in MEMLEVELS
]
)
)
print("=" * 79)
print()
print("=" * 79)
print("CPU time")
print("=" * 79)
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
for wbits in WBITS:
print(
"\t".join(
[str(wbits)]
+ [
f"{1000 * duration[wbits][memLevel]:.1f}ms"
for memLevel in MEMLEVELS
]
)
)
print("=" * 79)
print()
print("=" * 79)
print(f"Size vs. {WB} \\ {ML}")
print("=" * 79)
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
for wbits in WBITS:
print(
"\t".join(
[str(wbits)]
+ [
f"{100 * (size[wbits][memLevel] / size[WB][ML] - 1):.1f}%"
for memLevel in MEMLEVELS
]
)
)
print("=" * 79)
print()
print("=" * 79)
print(f"Time vs. {WB} \\ {ML}")
print("=" * 79)
print("\t".join(["wb \\ ml"] + [str(memLevel) for memLevel in MEMLEVELS]))
for wbits in WBITS:
print(
"\t".join(
[str(wbits)]
+ [
f"{100 * (duration[wbits][memLevel] / duration[WB][ML] - 1):.1f}%"
for memLevel in MEMLEVELS
]
)
)
print("=" * 79)
print()
def generate_data_package_corpus() -> list[bytes]:
# compared to default 12, 5:
# 11, 4 saves 16K RAM, gives +4.6% size, -5.0% time .. +1.1% time
# 10, 4 saves 20K RAM, gives +10.2% size, -3.8% time .. +0.6% time
# 11, 3 saves 20K RAM, gives +6.5% size, +14.2% time
# 10, 3 saves 24K RAM, gives +12.8% size, +0.5% time .. +6.9% time
# NOTE: time delta is highly unstable; time is ~100ms
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore")
from NetUtils import encode
from worlds import network_data_package
return [encode(network_data_package).encode("utf-8")]
def generate_solo_release_corpus() -> list[bytes]:
# compared to default 12, 5:
# 11, 4 saves 16K RAM, gives +0.9% size, +3.9% time
# 10, 4 saves 20K RAM, gives +1.4% size, +3.4% time
# 11, 3 saves 20K RAM, gives +1.8% size, +13.9% time
# 10, 3 saves 24K RAM, gives +2.1% size, +4.8% time
# NOTE: time delta is highly unstable; time is ~0.4ms
from random import Random
from MultiServer import json_format_send_event
from NetUtils import encode, NetworkItem
r = Random()
r.seed(0)
solo_release = []
solo_release_locations = [r.randint(1000, 1999) for _ in range(200)]
solo_release_items = sorted([r.randint(1000, 1999) for _ in range(200)]) # currently sorted by item
solo_player = 1
for location, item in zip(solo_release_locations, solo_release_items):
flags = r.choice((0, 0, 0, 0, 0, 0, 0, 1, 2, 3))
network_item = NetworkItem(item, location, solo_player, flags)
solo_release.append(json_format_send_event(network_item, solo_player))
solo_release.append({
"cmd": "ReceivedItems",
"index": 0,
"items": solo_release_items,
})
solo_release.append({
"cmd": "RoomUpdate",
"hint_points": 200,
"checked_locations": solo_release_locations,
})
return [encode(solo_release).encode("utf-8")]
def generate_gameplay_corpus() -> list[bytes]:
# compared to default 12, 5:
# 11, 4 saves 16K RAM, gives +13.6% size, +4.1% time
# 10, 4 saves 20K RAM, gives +22.3% size, +2.2% time
# 10, 3 saves 24K RAM, gives +26.2% size, +1.6% time
# NOTE: time delta is highly unstable; time is 4ms
from copy import copy
from random import Random
from MultiServer import json_format_send_event
from NetUtils import encode, NetworkItem
r = Random()
r.seed(0)
gameplay = []
observer = 1
hint_points = 0
index = 0
players = list(range(1, 10))
player_locations = {player: [r.randint(1000, 1999) for _ in range(200)] for player in players}
player_items = {player: [r.randint(1000, 1999) for _ in range(200)] for player in players}
player_receiver = {player: [r.randint(1, len(players)) for _ in range(200)] for player in players}
for i in range(0, len(player_locations[1])):
player_sequence = copy(players)
r.shuffle(player_sequence)
for finder in player_sequence:
flags = r.choice((0, 0, 0, 0, 0, 0, 0, 1, 2, 3))
receiver = player_receiver[finder][i]
item = player_items[finder][i]
location = player_locations[finder][i]
network_item = NetworkItem(item, location, receiver, flags)
gameplay.append(json_format_send_event(network_item, observer))
if finder == observer:
hint_points += 1
gameplay.append({
"cmd": "RoomUpdate",
"hint_points": hint_points,
"checked_locations": [location],
})
if receiver == observer:
gameplay.append({
"cmd": "ReceivedItems",
"index": index,
"items": [item],
})
index += 1
return [encode(gameplay).encode("utf-8")]
def main() -> None:
#corpus = generate_data_package_corpus()
#corpus = generate_solo_release_corpus()
#corpus = generate_gameplay_corpus()
corpus = generate_data_package_corpus() + generate_solo_release_corpus() + generate_gameplay_corpus()
benchmark(corpus)
print(f"raw size: {sum(len(data) for data in corpus)}")
if __name__ == "__main__":
main()

View File

@@ -1,4 +1,12 @@
def run_locations_benchmark(): def run_locations_benchmark(freeze_gc: bool = True) -> None:
"""
Run a benchmark of location access rule performance against an empty_state and an all_state.
:param freeze_gc: Whether to freeze gc before benchmarking and unfreeze gc afterward. Freezing gc moves all objects
tracked by the garbage collector to a permanent generation, ignoring them in all future collections. Freezing
greatly reduces the duration of running gc.collect() within benchmarks, which otherwise often takes much longer
than running all iterations for the location rule being benchmarked.
"""
import argparse import argparse
import logging import logging
import gc import gc
@@ -34,6 +42,8 @@ def run_locations_benchmark():
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top)) return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float: def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
if freeze_gc:
gc.freeze()
with TimeIt(f"{test_location.game} {self.rule_iterations} " with TimeIt(f"{test_location.game} {self.rule_iterations} "
f"runs of {test_location}.access_rule({state_name})", logger) as t: f"runs of {test_location}.access_rule({state_name})", logger) as t:
for _ in range(self.rule_iterations): for _ in range(self.rule_iterations):
@@ -41,6 +51,8 @@ def run_locations_benchmark():
# if time is taken to disentangle complex ref chains, # if time is taken to disentangle complex ref chains,
# this time should be attributed to the rule. # this time should be attributed to the rule.
gc.collect() gc.collect()
if freeze_gc:
gc.unfreeze()
return t.dif return t.dif
def main(self): def main(self):
@@ -64,9 +76,13 @@ def run_locations_benchmark():
gc.collect() gc.collect()
for step in self.gen_steps: for step in self.gen_steps:
if freeze_gc:
gc.freeze()
with TimeIt(f"{game} step {step}", logger): with TimeIt(f"{game} step {step}", logger):
call_all(multiworld, step) call_all(multiworld, step)
gc.collect() gc.collect()
if freeze_gc:
gc.unfreeze()
locations = sorted(multiworld.get_unfilled_locations()) locations = sorted(multiworld.get_unfilled_locations())
if not locations: if not locations:

View File

@@ -6,9 +6,9 @@ from Utils import get_intended_text, get_input_text_from_response
class TestClient(unittest.TestCase): class TestClient(unittest.TestCase):
def test_autofill_hint_from_fuzzy_hint(self) -> None: def test_autofill_hint_from_fuzzy_hint(self) -> None:
tests = ( tests = (
("item", ["item1", "item2"]), # Multiple close matches ("item", ["item1", "item2"]), # Multiple close matches
("itm", ["item1", "item21"]), # No close match, multiple option ("itm", ["item1", "item21"]), # No close match, multiple option
("item", ["item1"]), # No close match, single option ("item", ["item1"]), # No close match, single option
("item", ["\"item\" 'item' (item)"]), # Testing different special characters ("item", ["\"item\" 'item' (item)"]), # Testing different special characters
) )
@@ -16,7 +16,7 @@ class TestClient(unittest.TestCase):
item_name, usable, response = get_intended_text(input_text, possible_answers) item_name, usable, response = get_intended_text(input_text, possible_answers)
self.assertFalse(usable, "This test must be updated, it seems get_fuzzy_results behavior changed") self.assertFalse(usable, "This test must be updated, it seems get_fuzzy_results behavior changed")
hint_command = get_input_text_from_response(response, "hint") hint_command = get_input_text_from_response(response, "!hint")
self.assertIsNotNone(hint_command, self.assertIsNotNone(hint_command,
"The response to fuzzy hints is no longer recognized by the hint autofill") "The response to fuzzy hints is no longer recognized by the hint autofill")
self.assertEqual(hint_command, f"!hint {item_name}", self.assertEqual(hint_command, f"!hint {item_name}",

View File

@@ -1,7 +1,7 @@
import unittest import unittest
from BaseClasses import PlandoOptions from BaseClasses import PlandoOptions
from Options import ItemLinks, Choice from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts
from Utils import restricted_dumps from Utils import restricted_dumps
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
@@ -72,8 +72,8 @@ class TestOptions(unittest.TestCase):
for link in item_links.values(): for link in item_links.values():
self.assertEqual(link.value[0], item_link_group[0]) self.assertEqual(link.value[0], item_link_group[0])
def test_pickle_dumps(self): def test_pickle_dumps_default(self):
"""Test options can be pickled into database for WebHost generation""" """Test that default option values can be pickled into database for WebHost generation"""
for gamename, world_type in AutoWorldRegister.world_types.items(): for gamename, world_type in AutoWorldRegister.world_types.items():
if not world_type.hidden: if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items(): for option_key, option in world_type.options_dataclass.type_hints.items():
@@ -81,3 +81,23 @@ class TestOptions(unittest.TestCase):
restricted_dumps(option.from_any(option.default)) restricted_dumps(option.from_any(option.default))
if issubclass(option, Choice) and option.default in option.name_lookup: if issubclass(option, Choice) and option.default in option.name_lookup:
restricted_dumps(option.from_text(option.name_lookup[option.default])) restricted_dumps(option.from_text(option.name_lookup[option.default]))
def test_pickle_dumps_plando(self):
"""Test that plando options using containers of a custom type can be pickled"""
# The base PlandoConnections class can't be instantiated directly, create a subclass and then cast it
class TestPlandoConnections(PlandoConnections):
entrances = {"An Entrance"}
exits = {"An Exit"}
plando_connection_value = PlandoConnections(
TestPlandoConnections.from_any([{"entrance": "An Entrance", "exit": "An Exit"}])
)
plando_values = {
"PlandoConnections": plando_connection_value,
"PlandoItems": PlandoItems.from_any([{"item": "Something", "location": "Somewhere"}]),
"PlandoTexts": PlandoTexts.from_any([{"text": "Some text.", "at": "text_box"}]),
}
for option_key, value in plando_values.items():
with self.subTest(option=option_key):
restricted_dumps(value)

View File

@@ -0,0 +1,102 @@
"""Check world sources' manifest files"""
import json
import unittest
from pathlib import Path
from typing import Any, ClassVar
import test
from Utils import home_path, local_path
from worlds.AutoWorld import AutoWorldRegister
from ..param import classvar_matrix
test_path = Path(test.__file__).parent
worlds_paths = [
Path(local_path("worlds")),
Path(local_path("custom_worlds")),
Path(home_path("worlds")),
Path(home_path("custom_worlds")),
]
# Only check source folders for now. Zip validation should probably be in the loader and/or installer.
source_world_names = [
k
for k, v in AutoWorldRegister.world_types.items()
if not v.zip_path and not Path(v.__file__).is_relative_to(test_path)
]
def get_source_world_manifest_path(game: str) -> Path | None:
"""Get path of archipelago.json in the world's root folder from game name."""
# TODO: add a feature to AutoWorld that makes this less annoying
world_type = AutoWorldRegister.world_types[game]
world_type_path = Path(world_type.__file__)
for worlds_path in worlds_paths:
if world_type_path.is_relative_to(worlds_path):
world_root = worlds_path / world_type_path.relative_to(worlds_path).parents[0]
manifest_path = world_root / "archipelago.json"
return manifest_path if manifest_path.exists() else None
assert False, f"{world_type_path} not found in any worlds path"
# TODO: remove the filter once manifests are mandatory.
@classvar_matrix(game=filter(get_source_world_manifest_path, source_world_names))
class TestWorldManifest(unittest.TestCase):
game: ClassVar[str]
manifest: ClassVar[dict[str, Any]]
@classmethod
def setUpClass(cls) -> None:
world_type = AutoWorldRegister.world_types[cls.game]
assert world_type.game == cls.game
manifest_path = get_source_world_manifest_path(cls.game)
assert manifest_path # make mypy happy
with manifest_path.open("r", encoding="utf-8") as f:
cls.manifest = json.load(f)
def test_game(self) -> None:
"""Test that 'game' will be correctly defined when generating APWorld manifest from source."""
self.assertIn(
"game",
self.manifest,
f"archipelago.json manifest exists for {self.game} but does not contain 'game'",
)
self.assertEqual(
self.manifest["game"],
self.game,
f"archipelago.json manifest for {self.game} specifies wrong game '{self.manifest['game']}'",
)
def test_world_version(self) -> None:
"""Test that world_version matches the requirements in apworld specification.md"""
if "world_version" in self.manifest:
world_version: str = self.manifest["world_version"]
self.assertIsInstance(
world_version,
str,
f"world_version in archipelago.json for '{self.game}' has to be string if provided.",
)
parts = world_version.split(".")
self.assertEqual(
len(parts),
3,
f"world_version in archipelago.json for '{self.game}' has to be in the form of 'major.minor.build'.",
)
for part in parts:
self.assertTrue(
part.isdigit(),
f"world_version in archipelago.json for '{self.game}' may only contain numbers.",
)
def test_no_container_version(self) -> None:
self.assertNotIn(
"version",
self.manifest,
f"archipelago.json for '{self.game}' must not define 'version', see apworld specification.md.",
)
self.assertNotIn(
"compatible_version",
self.manifest,
f"archipelago.json for '{self.game}' must not define 'compatible_version', see apworld specification.md.",
)

View File

@@ -3,6 +3,7 @@
# Run with `python test/hosting` instead, # Run with `python test/hosting` instead,
import logging import logging
import traceback import traceback
from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from time import sleep from time import sleep
from typing import Any from typing import Any
@@ -11,7 +12,7 @@ from test.hosting.client import Client
from test.hosting.generate import generate_local from test.hosting.generate import generate_local
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room, from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
stop_autohost, upload_multidata) stop_autogen, stop_autohost, upload_multidata, generate_remote)
from test.hosting.world import copy as copy_world, delete as delete_world from test.hosting.world import copy as copy_world, delete as delete_world
failure = False failure = False
@@ -56,35 +57,62 @@ else:
if __name__ == "__main__": if __name__ == "__main__":
import sys
import warnings import warnings
warnings.simplefilter("ignore", ResourceWarning) warnings.simplefilter("ignore", ResourceWarning)
warnings.simplefilter("ignore", UserWarning) warnings.simplefilter("ignore", UserWarning)
warnings.simplefilter("ignore", DeprecationWarning)
spacer = '=' * 80 spacer = '=' * 80
with TemporaryDirectory() as tempdir: with TemporaryDirectory() as tempdir:
empty_file = str(Path(tempdir) / "empty")
open(empty_file, "w").close()
sys.argv += ["--config_override", empty_file] # tests #5541
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]] multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
p1_games = [] p1_games: list[str] = []
data_paths = [] data_paths: list[Path | None] = []
rooms = [] rooms: list[str] = []
multidata: Path | None
copy_world("VVVVVV", "Temp World") copy_world("VVVVVV", "Temp World")
try: try:
for n, games in enumerate(multis, 1): for n, games in enumerate(multis, 1):
print(f"Generating [{n}] {', '.join(games)}") print(f"Generating [{n}] {', '.join(games)} offline")
multidata = generate_local(games, tempdir) multidata = generate_local(games, tempdir)
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n") print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
p1_games.append(games[0])
data_paths.append(multidata) data_paths.append(multidata)
p1_games.append(games[0])
finally: finally:
delete_world("Temp World") delete_world("Temp World")
webapp = get_app(tempdir) webapp = get_app(tempdir)
webhost_client = webapp.test_client() webhost_client = webapp.test_client()
for n, multidata in enumerate(data_paths, 1): for n, multidata in enumerate(data_paths, 1):
assert multidata
seed = upload_multidata(webhost_client, multidata) seed = upload_multidata(webhost_client, multidata)
print(f"Uploaded [{n}] {multidata} as {seed}\n")
room = create_room(webhost_client, seed) room = create_room(webhost_client, seed)
print(f"Uploaded [{n}] {multidata} as {room}\n") print(f"Started [{n}] {seed} as {room}\n")
rooms.append(room)
# Generate 1 extra game on WebHost
from WebHostLib.autolauncher import autogen
for n, games in enumerate(multis[:1], len(multis) + 1):
multis.append(games)
try:
print(f"Generating [{n}] {', '.join(games)} online")
autogen(webapp.config)
sleep(5) # until we have lazy loading of worlds, wait here for the process to start up
seed = generate_remote(webhost_client, games)
print(f"Generated [{n}] {', '.join(games)} as {seed}\n")
finally:
stop_autogen()
data_paths.append(None) # WebHost-only
room = create_room(webhost_client, seed)
print(f"Started [{n}] {seed} as {room}\n")
rooms.append(room) rooms.append(room)
print("Starting autohost") print("Starting autohost")
@@ -96,31 +124,10 @@ if __name__ == "__main__":
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1): for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
involved_games = {"Archipelago"} | set(multi_games) involved_games = {"Archipelago"} | set(multi_games)
for collected_items in range(3): for collected_items in range(3):
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
with LocalServeGame(multidata) as host:
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration
client.collect_any()
# TODO: Ctrl+C test here as well
for game_name in sorted(involved_games):
expect_true(game_name in local_data_packages,
f"{game_name} missing from MultiServer datap ackage")
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
for game_name in local_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from MultiServer")
assert_equal(local_collected_items, collected_items,
"MultiServer did not load or save correctly")
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected") print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
prev_host_adr: str prev_host_adr: str
with WebHostServeGame(webhost_client, room) as host: with WebHostServeGame(webhost_client, room) as host:
sleep(.1) # wait for the server to fully start before doing anything
prev_host_adr = host.address prev_host_adr = host.address
with Client(host.address, game, "Player1") as client: with Client(host.address, game, "Player1") as client:
web_data_packages = client.games_packages web_data_packages = client.games_packages
@@ -134,6 +141,7 @@ if __name__ == "__main__":
autohost(webapp.config) # this will spin the room right up again autohost(webapp.config) # this will spin the room right up again
sleep(1) # make log less annoying sleep(1) # make log less annoying
# if saving failed, the next iteration will fail below # if saving failed, the next iteration will fail below
sleep(2) # work around issue #5571
# verify server shut down # verify server shut down
try: try:
@@ -156,6 +164,31 @@ if __name__ == "__main__":
"customserver did not load or save correctly during/after " "customserver did not load or save correctly during/after "
+ ("Ctrl+C" if collected_items == 2 else "/exit")) + ("Ctrl+C" if collected_items == 2 else "/exit"))
if not multidata:
continue # games rolled on WebHost can not be tested against MultiServer
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
with LocalServeGame(multidata) as host:
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Don't collect anything on the last iteration
client.collect_any()
# TODO: Ctrl+C test here as well
for game_name in sorted(involved_games):
expect_true(game_name in local_data_packages,
f"{game_name} missing from MultiServer datapackage")
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
for game_name in local_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from MultiServer")
assert_equal(local_collected_items, collected_items,
"MultiServer did not load or save correctly")
# compare customserver to MultiServer # compare customserver to MultiServer
expect_equal(local_data_packages, web_data_packages, expect_equal(local_data_packages, web_data_packages,
"customserver datapackage differs from MultiServer") "customserver datapackage differs from MultiServer")
@@ -176,10 +209,12 @@ if __name__ == "__main__":
print(f"Restoring multidata for {room}") print(f"Restoring multidata for {room}")
set_multidata_for_room(webhost_client, room, old_data) set_multidata_for_room(webhost_client, room, old_data)
with WebHostServeGame(webhost_client, room) as host: with WebHostServeGame(webhost_client, room) as host:
sleep(.1) # wait for the server to fully start before doing anything
with Client(host.address, game, "Player1") as client: with Client(host.address, game, "Player1") as client:
assert_equal(len(client.checked_locations), 2, assert_equal(len(client.checked_locations), 2,
"Save was destroyed during exception in customserver") "Save was destroyed during exception in customserver")
print("Save file is not busted 🥳") print("Save file is not busted 🥳")
sleep(2) # work around issue #5571
finally: finally:
print("Stopping autohost") print("Stopping autohost")

View File

@@ -1,6 +1,10 @@
import io
import json
import re import re
import time
import zipfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Optional, cast from typing import TYPE_CHECKING, Iterable, Optional, cast
from WebHostLib import to_python from WebHostLib import to_python
@@ -10,6 +14,7 @@ if TYPE_CHECKING:
__all__ = [ __all__ = [
"get_app", "get_app",
"generate_remote",
"upload_multidata", "upload_multidata",
"create_room", "create_room",
"start_room", "start_room",
@@ -17,6 +22,7 @@ __all__ = [
"set_room_timeout", "set_room_timeout",
"get_multidata_for_room", "get_multidata_for_room",
"set_multidata_for_room", "set_multidata_for_room",
"stop_autogen",
"stop_autohost", "stop_autohost",
] ]
@@ -33,10 +39,43 @@ def get_app(tempdir: str) -> "Flask":
"TESTING": True, "TESTING": True,
"HOST_ADDRESS": "localhost", "HOST_ADDRESS": "localhost",
"HOSTERS": 1, "HOSTERS": 1,
"GENERATORS": 1,
"JOB_THRESHOLD": 1,
}) })
return get_app() return get_app()
def generate_remote(app_client: "FlaskClient", games: Iterable[str]) -> str:
data = io.BytesIO()
with zipfile.ZipFile(data, "a", zipfile.ZIP_DEFLATED, False) as zip_file:
for n, game in enumerate(games, 1):
name = f"{n}.yaml"
zip_file.writestr(name, json.dumps({
"name": f"Player{n}",
"game": game,
game: {},
"description": f"generate_remote slot {n} ('Player{n}'): {game}",
}))
data.seek(0)
response = app_client.post("/generate", content_type="multipart/form-data", data={
"file": (data, "yamls.zip"),
})
assert response.status_code < 400, f"Starting gen failed: status {response.status_code}"
assert "Location" in response.headers, f"Starting gen failed: no redirect"
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/wait/"), f"Starting WebHost gen failed: unexpected redirect to {location}"
for attempt in range(10):
response = app_client.get(location)
if "Location" in response.headers:
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/seed/"), f"Finishing WebHost gen failed: unexpected redirect to {location}"
return location[6:]
time.sleep(1)
raise TimeoutError("WebHost gen did not finish")
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str: def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
response = app_client.post("/uploads", data={ response = app_client.post("/uploads", data={
"file": multidata.open("rb"), "file": multidata.open("rb"),
@@ -188,7 +227,7 @@ def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: by
room.seed.multidata = data room.seed.multidata = data
def stop_autohost(graceful: bool = True) -> None: def _stop_webhost_mp(name_filter: str, graceful: bool = True) -> None:
import os import os
import signal import signal
@@ -198,13 +237,30 @@ def stop_autohost(graceful: bool = True) -> None:
stop() stop()
proc: multiprocessing.process.BaseProcess proc: multiprocessing.process.BaseProcess
for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()): for proc in filter(lambda child: child.name.startswith(name_filter), multiprocessing.active_children()):
# FIXME: graceful currently does not work on Windows because the signals are not properly emulated
# and ungraceful may not save the game
if proc.pid == os.getpid():
continue
if graceful and proc.pid: if graceful and proc.pid:
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT)) os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
else: else:
proc.kill() proc.kill()
try: try:
proc.join(30) try:
proc.join(30)
except TimeoutError:
raise
except KeyboardInterrupt:
# on Windows, the MP exception may be forwarded to the host, so ignore once and retry
proc.join(30)
except TimeoutError: except TimeoutError:
proc.kill() proc.kill()
proc.join() proc.join()
def stop_autogen(graceful: bool = True) -> None:
# FIXME: this name filter is jank, but there seems to be no way to add a custom prefix for a Pool
_stop_webhost_mp("SpawnPoolWorker-", graceful)
def stop_autohost(graceful: bool = True) -> None:
_stop_webhost_mp("MultiHoster", graceful)

View File

@@ -11,7 +11,7 @@ _new_worlds: dict[str, str] = {}
def copy(src: str, dst: str) -> None: def copy(src: str, dst: str) -> None:
from Utils import get_file_safe_name from Utils import get_file_safe_name
from worlds import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
assert dst not in _new_worlds, "World already created" assert dst not in _new_worlds, "World already created"
if '"' in dst or "\\" in dst: # easier to reject than to escape if '"' in dst or "\\" in dst: # easier to reject than to escape

View File

@@ -0,0 +1,14 @@
import unittest
from Utils import DaemonThreadPoolExecutor
class DaemonThreadPoolExecutorTest(unittest.TestCase):
def test_is_daemon(self) -> None:
def run() -> None:
pass
with DaemonThreadPoolExecutor(1) as executor:
executor.submit(run)
self.assertTrue(next(iter(executor._threads)).daemon)

View File

@@ -0,0 +1,78 @@
import os
import unittest
from tempfile import NamedTemporaryFile
from mistune import HTMLRenderer, Markdown
from WebHostLib.markdown import ImgUrlRewriteInlineParser, render_markdown
class ImgUrlRewriteTest(unittest.TestCase):
markdown: Markdown
base_url = "/static/generated/docs/some_game"
def setUp(self) -> None:
self.markdown = Markdown(
renderer=HTMLRenderer(escape=False),
inline=ImgUrlRewriteInlineParser(self.base_url),
)
def test_relative_img_rewrite(self) -> None:
html = self.markdown("![Image](image.png)")
self.assertIn(f'src="{self.base_url}/image.png"', html)
def test_absolute_img_no_rewrite(self) -> None:
html = self.markdown("![Image](/image.png)")
self.assertIn(f'src="/image.png"', html)
self.assertNotIn(self.base_url, html)
def test_remote_img_no_rewrite(self) -> None:
html = self.markdown("![Image](https://example.com/image.png)")
self.assertIn(f'src="https://example.com/image.png"', html)
self.assertNotIn(self.base_url, html)
def test_relative_link_no_rewrite(self) -> None:
# The parser is only supposed to update images, not links.
html = self.markdown("[Link](image.png)")
self.assertIn(f'href="image.png"', html)
self.assertNotIn(self.base_url, html)
def test_absolute_link_no_rewrite(self) -> None:
html = self.markdown("[Link](/image.png)")
self.assertIn(f'href="/image.png"', html)
self.assertNotIn(self.base_url, html)
def test_auto_link_no_rewrite(self) -> None:
html = self.markdown("<https://example.com/image.png>")
self.assertIn(f'href="https://example.com/image.png"', html)
self.assertNotIn(self.base_url, html)
def test_relative_img_to_other_game(self) -> None:
html = self.markdown("![Image](../../generic/docs/image.png)")
self.assertIn(f'src="{self.base_url}/../Archipelago/image.png"', html)
class RenderMarkdownTest(unittest.TestCase):
"""Tests that render_markdown does the right thing."""
base_url = "/static/generated/docs/some_game"
def test_relative_img_rewrite(self) -> None:
f = NamedTemporaryFile(delete=False)
try:
f.write("![Image](image.png)".encode("utf-8"))
f.close()
html = render_markdown(f.name, self.base_url)
self.assertIn(f'src="{self.base_url}/image.png"', html)
finally:
os.unlink(f.name)
def test_no_img_rewrite(self) -> None:
f = NamedTemporaryFile(delete=False)
try:
f.write("![Image](image.png)".encode("utf-8"))
f.close()
html = render_markdown(f.name)
self.assertIn(f'src="image.png"', html)
self.assertNotIn(self.base_url, html)
finally:
os.unlink(f.name)

View File

@@ -175,12 +175,12 @@ class APWorldContainer(APContainer):
maximum_ap_version: "Version | None" = None maximum_ap_version: "Version | None" = None
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]: def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
from Utils import tuplize_version, Version from Utils import tuplize_version
manifest = super().read_contents(opened_zipfile) manifest = super().read_contents(opened_zipfile)
self.game = manifest["game"] self.game = manifest["game"]
for version_key in ("world_version", "minimum_ap_version", "maximum_ap_version"): for version_key in ("world_version", "minimum_ap_version", "maximum_ap_version"):
if version_key in manifest: if version_key in manifest:
setattr(self, version_key, Version(*tuplize_version(manifest[version_key]))) setattr(self, version_key, tuplize_version(manifest[version_key]))
return manifest return manifest
def get_manifest(self) -> Dict[str, Any]: def get_manifest(self) -> Dict[str, Any]:

View File

@@ -180,7 +180,7 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
if found_already_loaded and is_kivy_running(): if found_already_loaded and is_kivy_running():
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded, " raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded, "
"so a Launcher restart is required to use the new installation.") "so a Launcher restart is required to use the new installation.")
world_source = worlds.WorldSource(str(target), is_zip=True) world_source = worlds.WorldSource(str(target), is_zip=True, relative=False)
bisect.insort(worlds.world_sources, world_source) bisect.insort(worlds.world_sources, world_source)
world_source.load() world_source.load()
@@ -217,8 +217,6 @@ components: List[Component] = [
description="Install an APWorld to play games not included with Archipelago by default."), description="Install an APWorld to play games not included with Archipelago by default."),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient, Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
description="Connect to a multiworld using the text client."), description="Connect to a multiworld using the text client."),
Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')),
Component('LttP Adjuster', 'LttPAdjuster'), Component('LttP Adjuster', 'LttPAdjuster'),
# Ocarina of Time # Ocarina of Time
Component('OoT Client', 'OoTClient', Component('OoT Client', 'OoTClient',
@@ -244,21 +242,46 @@ icon_paths = {
} }
if not is_frozen(): if not is_frozen():
def _build_apworlds(): def _build_apworlds(*launch_args: str):
import json import json
import os import os
import zipfile import zipfile
from worlds import AutoWorldRegister from worlds import AutoWorldRegister
from worlds.Files import APWorldContainer from worlds.Files import APWorldContainer
from Launcher import open_folder
import argparse
parser = argparse.ArgumentParser("Build script for APWorlds")
parser.add_argument("worlds", type=str, default=(), nargs="*", help="Names of APWorlds to build.")
args = parser.parse_args(launch_args)
if args.worlds:
games = [(game, AutoWorldRegister.world_types.get(game, None)) for game in args.worlds]
else:
games = [(worldname, worldtype) for worldname, worldtype in AutoWorldRegister.world_types.items()
if not worldtype.zip_path]
apworlds_folder = os.path.join("build", "apworlds") apworlds_folder = os.path.join("build", "apworlds")
os.makedirs(apworlds_folder, exist_ok=True) os.makedirs(apworlds_folder, exist_ok=True)
for worldname, worldtype in AutoWorldRegister.world_types.items(): for worldname, worldtype in games:
if not worldtype:
logging.error(f"Requested APWorld \"{worldname}\" does not exist.")
continue
file_name = os.path.split(os.path.dirname(worldtype.__file__))[1] file_name = os.path.split(os.path.dirname(worldtype.__file__))[1]
world_directory = os.path.join("worlds", file_name) world_directory = os.path.join("worlds", file_name)
if os.path.isfile(os.path.join(world_directory, "archipelago.json")): if os.path.isfile(os.path.join(world_directory, "archipelago.json")):
manifest = json.load(open(os.path.join(world_directory, "archipelago.json"))) with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
assert "game" in manifest, (
f"World directory {world_directory} has an archipelago.json manifest file, but it"
"does not define a \"game\"."
)
assert manifest["game"] == worldtype.game, (
f"World directory {world_directory} has an archipelago.json manifest file, but value of the"
f"\"game\" field ({manifest['game']} does not equal the World class's game ({worldtype.game})."
)
else: else:
manifest = {} manifest = {}
@@ -269,12 +292,15 @@ if not is_frozen():
apworld.manifest_path = f"{file_name}/archipelago.json" apworld.manifest_path = f"{file_name}/archipelago.json"
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED, with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED,
compresslevel=9) as zf: compresslevel=9) as zf:
for path in pathlib.Path(world_directory).rglob("*.*"): for path in pathlib.Path(world_directory).rglob("*"):
relative_path = os.path.join(*path.parts[path.parts.index("worlds") + 1:]) relative_path = os.path.join(*path.parts[path.parts.index("worlds") + 1:])
if "__MACOSX" in relative_path or ".DS_STORE" in relative_path or "__pycache__" in relative_path: if "__MACOSX" in relative_path or ".DS_STORE" in relative_path or "__pycache__" in relative_path:
continue continue
if not relative_path.endswith("archipelago.json"): if not relative_path.endswith("archipelago.json"):
zf.write(path, relative_path) zf.write(path, relative_path)
zf.writestr(apworld.manifest_path, json.dumps(manifest)) zf.writestr(apworld.manifest_path, json.dumps(manifest))
open_folder(apworlds_folder)
components.append(Component('Build apworlds', func=_build_apworlds, cli=True,))
components.append(Component('Build APWorlds', func=_build_apworlds, cli=True,
description="Build APWorlds from loose-file world folders."))

View File

@@ -122,14 +122,14 @@ for world_source in world_sources:
for dirpath, dirnames, filenames in os.walk(world_source.resolved_path): for dirpath, dirnames, filenames in os.walk(world_source.resolved_path):
for file in filenames: for file in filenames:
if file.endswith("archipelago.json"): if file.endswith("archipelago.json"):
manifest = json.load(open(os.path.join(dirpath, file), "r")) with open(os.path.join(dirpath, file), mode="r", encoding="utf-8") as manifest_file:
manifest = json.load(manifest_file)
break break
if manifest: if manifest:
break break
game = manifest.get("game") game = manifest.get("game")
if game in AutoWorldRegister.world_types: if game in AutoWorldRegister.world_types:
AutoWorldRegister.world_types[game].world_version = Version(*tuplize_version(manifest.get("world_version", AutoWorldRegister.world_types[game].world_version = tuplize_version(manifest.get("world_version", "0.0.0"))
"0.0.0")))
if apworlds: if apworlds:
# encapsulation for namespace / gc purposes # encapsulation for namespace / gc purposes

View File

@@ -1,4 +1,6 @@
import asyncio import asyncio
import time
import Utils import Utils
import websockets import websockets
import functools import functools
@@ -208,6 +210,9 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None):
if not ctx.is_proxy_connected(): if not ctx.is_proxy_connected():
break break
if msg["cmd"] == "Bounce" and msg.get("tags") == ["DeathLink"] and "data" in msg:
msg["data"]["time"] = time.time()
await ctx.send_msgs([msg]) await ctx.send_msgs([msg])
except Exception as e: except Exception as e:

View File

@@ -243,7 +243,7 @@ guaranteed_first_acts = [
"Time Rift - Mafia of Cooks", "Time Rift - Mafia of Cooks",
"Time Rift - Dead Bird Studio", "Time Rift - Dead Bird Studio",
"Time Rift - Sleepy Subcon", "Time Rift - Sleepy Subcon",
"Time Rift - Alpine Skyline" "Time Rift - Alpine Skyline",
"Time Rift - Tour", "Time Rift - Tour",
"Time Rift - Rumbi Factory", "Time Rift - Rumbi Factory",
] ]

View File

@@ -88,9 +88,8 @@ You only have to do these steps once.
1. Enter the RetroArch main menu screen. 1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default 3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355. Network Command Port at 55355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury 4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)". Performance)".

View File

@@ -88,9 +88,8 @@ Sólo hay que seguir estos pasos una vez.
1. Comienza en la pantalla del menú principal de RetroArch. 1. Comienza en la pantalla del menú principal de RetroArch.
2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON. 2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON.
3. Ve a Ajustes --> Red. Pon "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 el valor por defecto, 3. Ve a Ajustes --> Red. Pon "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 el valor por defecto,
el Puerto de comandos de red. el Puerto de comandos de red. \
![Captura de pantalla del ajuste Comandos de red](../../generic/docs/retroarch-network-commands-en.png)
![Captura de pantalla del ajuste Comandos de red](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES / 4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
SFC (bsnes-mercury Performance)". SFC (bsnes-mercury Performance)".

View File

@@ -89,9 +89,8 @@ Vous n'avez qu'à faire ces étapes qu'une fois.
1. Entrez dans le menu principal RetroArch 1. Entrez dans le menu principal RetroArch
2. Allez dans Réglages --> Interface utilisateur. Mettez "Afficher les réglages avancés" sur ON. 2. Allez dans Réglages --> Interface utilisateur. Mettez "Afficher les réglages avancés" sur ON.
3. Allez dans Réglages --> Réseau. Mettez "Commandes Réseau" sur ON. (trouvé sous Request Device 16.) Laissez le 3. Allez dans Réglages --> Réseau. Mettez "Commandes Réseau" sur ON. (trouvé sous Request Device 16.) Laissez le
Port des commandes réseau à 555355. Port des commandes réseau à 555355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-fr.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-fr.png)
4. Allez dans Menu Principal --> Mise à jour en ligne --> Téléchargement de cœurs. Descendez jusqu'a"Nintendo - SNES / SFC (bsnes-mercury Performance)" et 4. Allez dans Menu Principal --> Mise à jour en ligne --> Téléchargement de cœurs. Descendez jusqu'a"Nintendo - SNES / SFC (bsnes-mercury Performance)" et
sélectionnez le. sélectionnez le.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -23,7 +23,7 @@ game you play will make sure that every game has its own save game.
Unzip the Aquaria randomizer release and copy all unzipped files in the Aquaria game folder. The unzipped files are: Unzip the Aquaria randomizer release and copy all unzipped files in the Aquaria game folder. The unzipped files are:
- aquaria_randomizer.exe - aquaria_randomizer.exe
- OpenAL32.dll - OpenAL32.dll
- override (directory) - randomizer_files (directory)
- SDL2.dll - SDL2.dll
- usersettings.xml - usersettings.xml
- wrap_oal.dll - wrap_oal.dll
@@ -32,7 +32,10 @@ Unzip the Aquaria randomizer release and copy all unzipped files in the Aquaria
If there is a conflict between files in the original game folder and the unzipped files, you should overwrite If there is a conflict between files in the original game folder and the unzipped files, you should overwrite
the original files with the ones from the unzipped randomizer. the original files with the ones from the unzipped randomizer.
Finally, to launch the randomizer, you must use the command line interface (you can open the command line interface There is multiple way to start the game. The easiest one is using the launcher. To do that, just run
the `aquaria_randomizer.exe` file.
You can also launch the randomizer using the command line interface (you can open the command line interface
by typing `cmd` in the address bar of the Windows File Explorer). Here is the command line used to start the by typing `cmd` in the address bar of the Windows File Explorer). Here is the command line used to start the
randomizer: randomizer:
@@ -49,15 +52,17 @@ aquaria_randomizer.exe --name YourName --server theServer:thePort --password th
### Linux when using the AppImage ### Linux when using the AppImage
If you use the AppImage, just copy it into the Aquaria game folder. You then have to make it executable. You If you use the AppImage, just copy it into the Aquaria game folder. You then have to make it executable. You
can do that from command line by using: can do that from the command line by using:
```bash ```bash
chmod +x Aquaria_Randomizer-*.AppImage chmod +x Aquaria_Randomizer-*.AppImage
``` ```
or by using the Graphical Explorer of your system. or by using the Graphical file Explorer of your system (the permission can generally be set in the file properties).
To launch the randomizer, just launch in command line: To launch the randomizer using the integrated launcher, just execute the AppImage file.
You can also use command line arguments to set the server and slot of your game:
```bash ```bash
./Aquaria_Randomizer-*.AppImage --name YourName --server theServer:thePort ./Aquaria_Randomizer-*.AppImage --name YourName --server theServer:thePort
@@ -79,7 +84,7 @@ the original game will stop working. Copying the folder will guarantee that the
Untar the Aquaria randomizer release and copy all extracted files in the Aquaria game folder. The extracted files are: Untar the Aquaria randomizer release and copy all extracted files in the Aquaria game folder. The extracted files are:
- aquaria_randomizer - aquaria_randomizer
- override (directory) - randomizer_files (directory)
- usersettings.xml - usersettings.xml
- cacert.pem - cacert.pem
@@ -87,7 +92,7 @@ If there is a conflict between files in the original game folder and the extract
the original files with the ones from the extracted randomizer files. the original files with the ones from the extracted randomizer files.
Then, you should use your system package manager to install `liblua5`, `libogg`, `libvorbis`, `libopenal` and `libsdl2`. Then, you should use your system package manager to install `liblua5`, `libogg`, `libvorbis`, `libopenal` and `libsdl2`.
On Debian base system (like Ubuntu), you can use the following command: On Debian base systems (like Ubuntu), you can use the following command:
```bash ```bash
sudo apt install liblua5.1-0-dev libogg-dev libvorbis-dev libopenal-dev libsdl2-dev sudo apt install liblua5.1-0-dev libogg-dev libvorbis-dev libopenal-dev libsdl2-dev
@@ -97,7 +102,9 @@ Also, if there are certain `.so` files in the original Aquaria game folder (`lib
`libSDL-1.2.so.0` and `libstdc++.so.6`), you should remove them from the Aquaria Randomizer game folder. Those are `libSDL-1.2.so.0` and `libstdc++.so.6`), you should remove them from the Aquaria Randomizer game folder. Those are
old libraries that will not work on the recent build of the randomizer. old libraries that will not work on the recent build of the randomizer.
To launch the randomizer, just launch in command line: To launch the randomizer using the integrated launcher, just execute the `aquaria_randomizer` file.
You can also use command line arguments to set the server and slot of your game:
```bash ```bash
./aquaria_randomizer --name YourName --server theServer:thePort ./aquaria_randomizer --name YourName --server theServer:thePort
@@ -115,6 +122,20 @@ sure that your executable has executable permission:
```bash ```bash
chmod +x aquaria_randomizer chmod +x aquaria_randomizer
``` ```
### Steam deck
On the Steamdeck, go in desktop mode and follow the same procedure as the Linux Appimage.
### No sound on Linux/Steam deck
If your game play without problems, but with no sound, the game probably does not use the correct
driver for the sound system. To fix that, you can use `ALSOFT_DRIVERS=pulse` before your command
line to make it work. Something like this (depending on the way you launch the randomizer):
```bash
ALSOFT_DRIVERS=pulse ./Aquaria_Randomizer-*.AppImage --name YourName --server theServer:thePort
```
## Auto-Tracking ## Auto-Tracking

View File

@@ -2,12 +2,12 @@
## Logiciels nécessaires ## Logiciels nécessaires
- Une copie du jeu Aquaria non-modifiée (disponible sur la majorité des sites de ventes de jeux vidéos en ligne) - Une copie du jeu Aquaria non modifiée (disponible sur la majorité des sites de ventes de jeux vidéos en ligne)
- Le client du Randomizer d'Aquaria [Aquaria randomizer](https://github.com/tioui/Aquaria_Randomizer/releases/latest) - Le client du Randomizer d'Aquaria [Aquaria randomizer](https://github.com/tioui/Aquaria_Randomizer/releases/latest)
## Logiciels optionnels ## Logiciels optionnels
- De manière optionnel, pour pouvoir envoyer des [commandes](/tutorial/Archipelago/commands/en) comme `!hint`: utilisez le client texte de [la version la plus récente d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest) - De manière optionnelle, pour pouvoir envoyer des [commandes](/tutorial/Archipelago/commands/en) comme `!hint`: utilisez le client texte de [la version la plus récente d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest)
- [Aquaria AP Tracker](https://github.com/palex00/aquaria-ap-tracker/releases/latest), pour utiliser avec [PopTracker](https://github.com/black-sliver/PopTracker/releases/latest) - [Aquaria AP Tracker](https://github.com/palex00/aquaria-ap-tracker/releases/latest), pour utiliser avec [PopTracker](https://github.com/black-sliver/PopTracker/releases/latest)
## Procédures d'installation et d'exécution ## Procédures d'installation et d'exécution
@@ -25,7 +25,7 @@ Désarchiver le randomizer d'Aquaria et copier tous les fichiers de l'archive da
fichier d'archive devrait contenir les fichiers suivants: fichier d'archive devrait contenir les fichiers suivants:
- aquaria_randomizer.exe - aquaria_randomizer.exe
- OpenAL32.dll - OpenAL32.dll
- override (directory) - randomizer_files (directory)
- SDL2.dll - SDL2.dll
- usersettings.xml - usersettings.xml
- wrap_oal.dll - wrap_oal.dll
@@ -34,7 +34,10 @@ fichier d'archive devrait contenir les fichiers suivants:
S'il y a des conflits entre les fichiers de l'archive zip et les fichiers du jeu original, vous devez utiliser S'il y a des conflits entre les fichiers de l'archive zip et les fichiers du jeu original, vous devez utiliser
les fichiers contenus dans l'archive zip. les fichiers contenus dans l'archive zip.
Finalement, pour lancer le randomizer, vous devez utiliser la ligne de commande (vous pouvez ouvrir une interface de Il y a plusieurs manières de lancer le randomizer. Le plus simple consiste à utiliser le lanceur intégré en
exécutant simplement le fichier `aquaria_randomizer.exe`.
Il est également possible de lancer le randomizer en utilisant la ligne de commande (vous pouvez ouvrir une interface de
ligne de commande, entrez l'adresse `cmd` dans la barre d'adresse de l'explorateur de fichier de Windows). Voici ligne de commande, entrez l'adresse `cmd` dans la barre d'adresse de l'explorateur de fichier de Windows). Voici
la ligne de commande à utiliser pour lancer le randomizer: la ligne de commande à utiliser pour lancer le randomizer:
@@ -57,9 +60,12 @@ le mettre exécutable. Vous pouvez mettre le fichier exécutable avec la command
chmod +x Aquaria_Randomizer-*.AppImage chmod +x Aquaria_Randomizer-*.AppImage
``` ```
ou bien en utilisant l'explorateur graphique de votre système. ou bien en utilisant l'explorateur de fichier graphique de votre système (la permission d'exécution est
généralement dans les propriétés du fichier).
Pour lancer le randomizer, utiliser la commande suivante: Pour lancer le randomizer en utilisant le lanceur intégré, seulement exécuter le fichier AppImage.
Vous pouvez également lancer le randomizer en spécifiant les informations de connexion dans les arguments de la ligne de commande:
```bash ```bash
./Aquaria_Randomizer-*.AppImage --name VotreNom --server LeServeur:LePort ./Aquaria_Randomizer-*.AppImage --name VotreNom --server LeServeur:LePort
@@ -83,7 +89,7 @@ avant de déposer le randomizer à l'intérieur permet de vous assurer de garder
Désarchiver le fichier tar et copier tous les fichiers qu'il contient dans le répertoire du jeu d'origine d'Aquaria. Les Désarchiver le fichier tar et copier tous les fichiers qu'il contient dans le répertoire du jeu d'origine d'Aquaria. Les
fichiers extraient du fichier tar devraient être les suivants: fichiers extraient du fichier tar devraient être les suivants:
- aquaria_randomizer - aquaria_randomizer
- override (directory) - randomizer_files (directory)
- usersettings.xml - usersettings.xml
- cacert.pem - cacert.pem
@@ -102,7 +108,10 @@ Notez également que s'il y a des fichiers ".so" dans le répertoire d'Aquaria (
`libSDL-1.2.so.0` and `libstdc++.so.6`), vous devriez les retirer. Il s'agit de vieille version des librairies qui `libSDL-1.2.so.0` and `libstdc++.so.6`), vous devriez les retirer. Il s'agit de vieille version des librairies qui
ne sont plus fonctionnelles dans les systèmes modernes et qui pourrait empêcher le randomizer de fonctionner. ne sont plus fonctionnelles dans les systèmes modernes et qui pourrait empêcher le randomizer de fonctionner.
Pour lancer le randomizer, utiliser la commande suivante: Pour lancer le randomizer en utilisant le lanceur intégré, seulement exécuter le fichier `aquaria_randomizer`.
Vous pouvez également lancer le randomizer en spécifiant les information de connexion dans les arguments de la
ligne de commande:
```bash ```bash
./aquaria_randomizer --name VotreNom --server LeServeur:LePort ./aquaria_randomizer --name VotreNom --server LeServeur:LePort
@@ -120,6 +129,21 @@ pour vous assurer que votre fichier est exécutable:
```bash ```bash
chmod +x aquaria_randomizer chmod +x aquaria_randomizer
``` ```
### Steam Deck
Pour installer le randomizer sur la Steam Deck, seulement suivre la procédure pour les fichiers AppImage
indiquée précédemment.
### Aucun son sur Linux/Steam Deck
Si le jeu fonctionne sans problème, mais qu'il n'y a aucun son, c'est probablement parce que le jeu
n'arrive pas à utiliser le bon pilote de son. Généralement, le problème est réglé en ajoutant la
variable d'environnement `ALSOFT_DRIVERS=pulse`. Voici un exemple (peut varier en fonction de la manière
que le randomizer est lancé):
```bash
ALSOFT_DRIVERS=pulse ./Aquaria_Randomizer-*.AppImage --name VotreNom --server LeServeur:LePort
```
## Tracking automatique ## Tracking automatique

27
worlds/celeste64/LICENSE Normal file
View File

@@ -0,0 +1,27 @@
Modified MIT License
Copyright (c) 2025 PoryGone
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, and/or distribute copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to
the following conditions:
No copy or substantial portion of the Software shall be sublicensed or relicensed
without the express written permission of the copyright holder(s)
No copy or substantial portion of the Software shall be sold without the express
written permission of the copyright holder(s)
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,6 @@
{
"game": "Celeste 64",
"authors": [ "PoryGone" ],
"minimum_ap_version": "0.6.3",
"world_version": "1.3.1"
}

View File

@@ -0,0 +1,27 @@
Modified MIT License
Copyright (c) 2025 PoryGone
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, and/or distribute copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to
the following conditions:
No copy or substantial portion of the Software shall be sublicensed or relicensed
without the express written permission of the copyright holder(s)
No copy or substantial portion of the Software shall be sold without the express
written permission of the copyright holder(s)
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,6 @@
{
"game": "Celeste (Open World)",
"authors": [ "PoryGone" ],
"minimum_ap_version": "0.6.3",
"world_version": "1.0.5"
}

View File

@@ -20,6 +20,7 @@ class CivVIBoostData:
Prereq: List[str] Prereq: List[str]
PrereqRequiredCount: int PrereqRequiredCount: int
Classification: str Classification: str
EraRequired: bool = False
class GoodyHutRewardData(TypedDict): class GoodyHutRewardData(TypedDict):

View File

@@ -150,7 +150,10 @@ def generate_era_location_table() -> Dict[str, Dict[str, CivVILocationData]]:
location = CivVILocationData( location = CivVILocationData(
boost.Type, 0, 0, id_base, boost.EraType, CivVICheckType.BOOST boost.Type, 0, 0, id_base, boost.EraType, CivVICheckType.BOOST
) )
era_locations["ERA_ANCIENT"][boost.Type] = location # If EraRequired is True, place the boost in its actual era
# Otherwise, place it in ERA_ANCIENT for early access
target_era = boost.EraType if boost.EraRequired else "ERA_ANCIENT"
era_locations[target_era][boost.Type] = location
id_base += 1 id_base += 1
return era_locations return era_locations

View File

@@ -210,8 +210,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_SQUARE_RIGGING", "BOOST_TECH_SQUARE_RIGGING",
"ERA_RENAISSANCE", "ERA_RENAISSANCE",
["TECH_GUNPOWDER"], ["TECH_GUNPOWDER", "TECH_MILITARY_ENGINEERING", "TECH_MINING"],
1, 3,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -252,15 +252,15 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_BALLISTICS", "BOOST_TECH_BALLISTICS",
"ERA_INDUSTRIAL", "ERA_INDUSTRIAL",
["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING"], ["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING", "TECH_BRONZE_WORKING"],
2, 3,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_MILITARY_SCIENCE", "BOOST_TECH_MILITARY_SCIENCE",
"ERA_INDUSTRIAL", "ERA_INDUSTRIAL",
["TECH_STIRRUPS"], ["TECH_BRONZE_WORKING", "TECH_STIRRUPS", "TECH_MINING"],
1, 3,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -301,8 +301,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_REPLACEABLE_PARTS", "BOOST_TECH_REPLACEABLE_PARTS",
"ERA_MODERN", "ERA_MODERN",
["TECH_MILITARY_SCIENCE"], ["TECH_MILITARY_SCIENCE", "TECH_MINING"],
1, 2,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -343,8 +343,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_ADVANCED_FLIGHT", "BOOST_TECH_ADVANCED_FLIGHT",
"ERA_ATOMIC", "ERA_ATOMIC",
["TECH_FLIGHT"], ["TECH_FLIGHT", "TECH_REFINING", "TECH_MINING"],
1, 3,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -436,8 +436,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData( CivVIBoostData(
"BOOST_TECH_COMPOSITES", "BOOST_TECH_COMPOSITES",
"ERA_INFORMATION", "ERA_INFORMATION",
["TECH_COMBUSTION"], ["TECH_COMBUSTION", "TECH_REFINING", "TECH_MINING"],
1, 3,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -470,7 +470,7 @@ boosts: List[CivVIBoostData] = [
"TECH_ELECTRICITY", "TECH_ELECTRICITY",
"TECH_NUCLEAR_FISSION", "TECH_NUCLEAR_FISSION",
], ],
1, 4,
"DEFAULT", "DEFAULT",
), ),
CivVIBoostData( CivVIBoostData(
@@ -651,10 +651,11 @@ boosts: List[CivVIBoostData] = [
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_FEUDALISM", "BOOST_CIVIC_FEUDALISM",
"ERA_MEDIEVAL", "ERA_CLASSICAL",
[], [],
0, 0,
"DEFAULT", "DEFAULT",
True,
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_CIVIL_SERVICE", "BOOST_CIVIC_CIVIL_SERVICE",
@@ -662,6 +663,7 @@ boosts: List[CivVIBoostData] = [
[], [],
0, 0,
"DEFAULT", "DEFAULT",
True,
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_MERCENARIES", "BOOST_CIVIC_MERCENARIES",
@@ -790,6 +792,7 @@ boosts: List[CivVIBoostData] = [
[], [],
0, 0,
"DEFAULT", "DEFAULT",
True
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_CONSERVATION", "BOOST_CIVIC_CONSERVATION",
@@ -885,6 +888,7 @@ boosts: List[CivVIBoostData] = [
["TECH_ROCKETRY"], ["TECH_ROCKETRY"],
1, 1,
"DEFAULT", "DEFAULT",
True
), ),
CivVIBoostData( CivVIBoostData(
"BOOST_CIVIC_GLOBALIZATION", "BOOST_CIVIC_GLOBALIZATION",

View File

@@ -14,25 +14,27 @@ The following are required in order to play Civ VI in Archipelago:
- A copy of the game `Civilization VI` including the two expansions `Rise & Fall` and `Gathering Storm` (both the Steam and Epic version should work). - A copy of the game `Civilization VI` including the two expansions `Rise & Fall` and `Gathering Storm` (both the Steam and Epic version should work).
## Enabling the tuner
In the main menu, navigate to the "Game Options" page. On the "Game" menu, make sure that "Tuner (disables achievements)" is enabled.
## Mod Installation ## Mod Installation
1. Download and unzip the latest release of the mod from [GitHub](https://github.com/hesto2/civilization_archipelago_mod/releases/latest). 1. Download and unzip the latest release of the mod from [GitHub](https://github.com/hesto2/civilization_archipelago_mod/releases/latest).
2. Copy the folder containing the mod files to your Civ VI mods folder. On Windows, this is usually located at `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods`. If you use OneDrive, check if the folder is instead located in your OneDrive file structure, and use that path when relevant in future steps. 2. Copy the folder containing the mod files to your Civ VI mods folder. On Windows, this is usually located at `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods`. If you use OneDrive, check if the folder is instead located in your OneDrive file structure, and use that path when relevant in future steps.
3. After the Archipelago host generates a game, you should be given a `.apcivvi` file. Associate the file with the Archipelago Launcher and double click it. 3. After the Archipelago host generates a game, you should be given a `.apcivvi` file. You can open it as a zip file, you can do this by either right clicking it and opening it with a program that handles zip files (if you associate that file with the program it will open it with that program in the future by double clicking it), or by right clicking and renaming the file extension from `apcivvi` to `zip` (only works if you are displaying file extensions). You can also associate the file with the Archipelago Launcher and double click it and it will create a folder with the mod files inside of it.
4. Copy the contents of the new folder it generates (it will have the same name as the `.apcivvi` file) into your Civilization VI Archipelago Mod folder. If double clicking the `.apcivvi` file doesn't generate a folder, you can instead open it as a zip file. You can do this by either right clicking it and opening it with a program that handles zip files, or by right clicking and renaming the file extension from `apcivvi` to `zip`. 4. Copy the contents of the zip file or folder it generated (the name of the folder should be the same as the apcivvi file) into your Civilization VI Archipelago Mod folder (there should be five files placed there from the `.apcivvi` file, overwrite if asked).
5. Place the files generated from the `.apcivvi` in your archipelago mod folder (there should be five files placed there from the apcivvi file, overwrite if asked). Your mod path should look something like `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods\civilization_archipelago_mod`. 5. Your mod path should look something like `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods\civilization_archipelago_mod`. If everything was done correctly you can now connect to the game.
## Configuring your game ## Connecting to a game
Make sure you enable the mod in the main title under Additional Content > Mods. When configuring your game, make sure to start the game in the Ancient Era and leave all settings related to starting technologies and civics as the defaults. Other than that, configure difficulty, AI, etc. as you normally would. 1. In the main menu, navigate to the "Game Options" page. On the "Game" menu, make sure that "Tuner (disables achievements)" is enabled.
2. In the main menu, navigate to the "Additional Content" page, then go to "Mods" and make sure the Archipelago mod is enabled.
3. When starting the game make sure you are on the Gathering Storm ruleset in a Single Player game. Additionally you must start in the ancient era, other settings and game modes can be customised to your own liking. An important thing to note is that settings preset saves the mod list from when you created it, so if you want to use a setting preset with this you must create it after installing the Archipelago mod.
4. To connect to the room open the Archipelago Launcher, from within the launcher open the Civ6 client and connect to the room. Once connected to the room enter your slot name and if everything went right you should now be connected.
## Troubleshooting ## Troubleshooting
@@ -51,3 +53,8 @@ Make sure you enable the mod in the main title under Additional Content > Mods.
- If you still have any errors make sure the two expansions Rise & Fall and Gathering Storm are active in the mod selector (all the official DLC works without issues but Rise & Fall and Gathering Storm are required for the mod). - If you still have any errors make sure the two expansions Rise & Fall and Gathering Storm are active in the mod selector (all the official DLC works without issues but Rise & Fall and Gathering Storm are required for the mod).
- If boostsanity is enabled and those items are not being sent out but regular techs are, make sure you placed the files from your new room in the mod folder. - If boostsanity is enabled and those items are not being sent out but regular techs are, make sure you placed the files from your new room in the mod folder.
- If you are neither receiving or sending items, make sure you have the correct client open. The client should be the Civ6 and NOT the Text Client.
- This should be compatible with a lot of other mods, but if you are having issues try disabling all mods other than the Archipelago mod and see if the problem still persists.

View File

@@ -105,3 +105,78 @@ class TestBoostsanityExcluded(CivVITestBase):
if "BOOST" in location.name: if "BOOST" in location.name:
found_locations += 1 found_locations += 1
self.assertEqual(found_locations, 0) self.assertEqual(found_locations, 0)
class TestBoostsanityEraRequired(CivVITestBase):
options = {
"boostsanity": "true",
"progression_style": "none",
"shuffle_goody_hut_rewards": "false",
}
def test_era_required_boosts_not_accessible_early(self) -> None:
# BOOST_CIVIC_FEUDALISM has EraRequired=True and ERA_CLASSICAL
# It should NOT be accessible in Ancient era
self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
# BOOST_CIVIC_URBANIZATION has EraRequired=True and ERA_INDUSTRIAL
# It should NOT be accessible in Ancient era
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
# BOOST_CIVIC_SPACE_RACE has EraRequired=True and ERA_ATOMIC
# It should NOT be accessible in Ancient era
self.assertFalse(self.can_reach_location("BOOST_CIVIC_SPACE_RACE"))
# Regular boosts without EraRequired should be accessible
self.assertTrue(self.can_reach_location("BOOST_TECH_SAILING"))
self.assertTrue(self.can_reach_location("BOOST_CIVIC_MILITARY_TRADITION"))
def test_era_required_boosts_accessible_in_correct_era(self) -> None:
# Collect items to reach Classical era
self.collect_by_name(["Mining", "Bronze Working", "Astrology", "Writing",
"Irrigation", "Sailing", "Animal Husbandry",
"State Workforce", "Foreign Trade"])
# BOOST_CIVIC_FEUDALISM should now be accessible in Classical era
self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
# BOOST_CIVIC_URBANIZATION still not accessible (requires Industrial)
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
# Collect more items to reach Industrial era
self.collect_all_but(["TECH_ROCKETRY"])
# Now BOOST_CIVIC_URBANIZATION should be accessible
self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
class TestBoostsanityEraRequiredWithProgression(CivVITestBase):
options = {
"boostsanity": "true",
"progression_style": "eras_and_districts",
"shuffle_goody_hut_rewards": "false",
}
def test_era_required_with_progressive_eras(self) -> None:
# Collect all items except Progressive Era
self.collect_all_but(["Progressive Era"])
# Even with all other items, era-required boosts should not be accessible
self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
# Collect enough Progressive Era items to reach Classical (needs 2)
self.collect(self.get_item_by_name("Progressive Era"))
self.collect(self.get_item_by_name("Progressive Era"))
# BOOST_CIVIC_FEUDALISM should now be accessible
self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM"))
# But BOOST_CIVIC_URBANIZATION still requires Industrial era (needs 5 total)
self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))
# Collect 3 more Progressive Era items to reach Industrial
self.collect_by_name(["Progressive Era", "Progressive Era", "Progressive Era"])
# Now BOOST_CIVIC_URBANIZATION should be accessible
self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION"))

27
worlds/dkc3/LICENSE Normal file
View File

@@ -0,0 +1,27 @@
Modified MIT License
Copyright (c) 2025 PoryGone
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, and/or distribute copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to
the following conditions:
No copy or substantial portion of the Software shall be sublicensed or relicensed
without the express written permission of the copyright holder(s)
No copy or substantial portion of the Software shall be sold without the express
written permission of the copyright holder(s)
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,6 @@
{
"game": "Donkey Kong Country 3",
"authors": [ "PoryGone" ],
"minimum_ap_version": "0.6.3",
"world_version": "1.1.0"
}

View File

@@ -111,9 +111,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
1. Enter the RetroArch main menu screen. 1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default 3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355. Network Command Port at 55355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury 4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)". Performance)".

View File

@@ -2,6 +2,7 @@ import csv
import enum import enum
import math import math
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import reduce
from random import Random from random import Random
from typing import Dict, List, Set from typing import Dict, List, Set
@@ -61,7 +62,7 @@ def load_item_csv():
item_reader = csv.DictReader(file) item_reader = csv.DictReader(file)
for item in item_reader: for item in item_reader:
id = int(item["id"]) if item["id"] else None id = int(item["id"]) if item["id"] else None
classification = ItemClassification[item["classification"]] classification = reduce((lambda a, b: a | b), {ItemClassification[str_classification] for str_classification in item["classification"].split(",")})
groups = {Group[group] for group in item["groups"].split(",") if group} groups = {Group[group] for group in item["groups"].split(",") if group}
items.append(ItemData(id, item["name"], classification, groups)) items.append(ItemData(id, item["name"], classification, groups))
return items return items

View File

@@ -22,7 +22,7 @@ id,name,classification,groups
20,Wall Jump Pack,progression,"DLC,Freemium" 20,Wall Jump Pack,progression,"DLC,Freemium"
21,Health Bar Pack,useful,"DLC,Freemium" 21,Health Bar Pack,useful,"DLC,Freemium"
22,Parallax Pack,filler,"DLC,Freemium" 22,Parallax Pack,filler,"DLC,Freemium"
23,Harmless Plants Pack,progression,"DLC,Freemium" 23,Harmless Plants Pack,"progression,trap","DLC,Freemium"
24,Death of Comedy Pack,progression,"DLC,Freemium" 24,Death of Comedy Pack,progression,"DLC,Freemium"
25,Canadian Dialog Pack,filler,"DLC,Freemium" 25,Canadian Dialog Pack,filler,"DLC,Freemium"
26,DLC NPC Pack,progression,"DLC,Freemium" 26,DLC NPC Pack,progression,"DLC,Freemium"
1 id name classification groups
22 20 Wall Jump Pack progression DLC,Freemium
23 21 Health Bar Pack useful DLC,Freemium
24 22 Parallax Pack filler DLC,Freemium
25 23 Harmless Plants Pack progression progression,trap DLC,Freemium
26 24 Death of Comedy Pack progression DLC,Freemium
27 25 Canadian Dialog Pack filler DLC,Freemium
28 26 DLC NPC Pack progression DLC,Freemium

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 627 KiB

After

Width:  |  Height:  |  Size: 493 KiB

View File

@@ -92,7 +92,7 @@ appropriate to your operating system, and extract the folder to a convenient loc
Archipelago is to place the extracted game folder into the `Archipelago` directory and rename it to just be "Factorio". Archipelago is to place the extracted game folder into the `Archipelago` directory and rename it to just be "Factorio".
![Factorio Download Options](/static/generated/docs/Factorio/factorio-download.png) ![Factorio Download Options](factorio-download.png)
Next, you should launch your Factorio Server by running `factorio.exe`, which is located at: `bin/x64/factorio.exe`. You Next, you should launch your Factorio Server by running `factorio.exe`, which is located at: `bin/x64/factorio.exe`. You
will be asked to log in to your Factorio account using the same credentials you used on Factorio's website. After you will be asked to log in to your Factorio account using the same credentials you used on Factorio's website. After you
@@ -122,7 +122,7 @@ This allows you to host your own Factorio game.
Archipelago if you chose to include it during the installation process. Archipelago if you chose to include it during the installation process.
6. Enter `/connect [server-address]` into the input box at the bottom of the Archipelago Client and press "Enter" 6. Enter `/connect [server-address]` into the input box at the bottom of the Archipelago Client and press "Enter"
![Factorio Client for Archipelago Connection Command](/static/generated/docs/Factorio/connect-to-ap-server.png) ![Factorio Client for Archipelago Connection Command](connect-to-ap-server.png)
7. Launch your Factorio Client 7. Launch your Factorio Client
8. Click on "Multiplayer" in the main menu 8. Click on "Multiplayer" in the main menu

View File

@@ -16,6 +16,7 @@ logger = logging.getLogger("Client")
rom_name_location = 0x07FFE3 rom_name_location = 0x07FFE3
player_name_location = 0x07BCC0
locations_array_start = 0x200 locations_array_start = 0x200
locations_array_length = 0x100 locations_array_length = 0x100
items_obtained = 0x03 items_obtained = 0x03
@@ -111,6 +112,12 @@ class FF1Client(BizHawkClient):
return True return True
async def set_auth(self, ctx: "BizHawkClientContext") -> None:
auth_raw = (await bizhawk.read(
ctx.bizhawk_ctx,
[(player_name_location, 0x40, self.rom)]))[0]
ctx.auth = str(auth_raw, "utf-8").replace("\x00", "").strip()
async def game_watcher(self, ctx: "BizHawkClientContext") -> None: async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
if ctx.server is None: if ctx.server is None:
return return
@@ -204,7 +211,7 @@ class FF1Client(BizHawkClient):
write_list.append((location, [0], self.sram)) write_list.append((location, [0], self.sram))
elif current_item_name in no_overworld_items: elif current_item_name in no_overworld_items:
if current_item_name == "Sigil": if current_item_name == "Sigil":
location = 0x28 location = 0x2B
else: else:
location = 0x12 location = 0x12
write_list.append((location, [1], self.sram)) write_list.append((location, [1], self.sram))

View File

@@ -253,5 +253,17 @@
"CubeBot": 529, "CubeBot": 529,
"Sarda": 525, "Sarda": 525,
"Fairy": 531, "Fairy": 531,
"Lefein": 527 "Lefein": 527,
"DeepDungeon32B_Chest144": 401,
"DeepDungeon30B_Chest145": 402,
"DeepDungeon29B_Chest146": 403,
"DeepDungeon29B_Chest147": 404,
"DeepDungeon40B_Chest186": 443,
"DeepDungeon38B_Chest188": 445,
"DeepDungeon36B_Chest189": 446,
"DeepDungeon33B_Chest190": 447,
"DeepDungeon40B_Chest191": 448,
"DeepDungeon41B_Chest192": 449,
"DeepDungeon34B_Chest193": 450,
"DeepDungeon39B_Chest194": 451
} }

View File

@@ -115,9 +115,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
1. Enter the RetroArch main menu screen. 1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default 3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355. Network Command Port at 55355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury 4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)". Performance)".

View File

@@ -123,10 +123,8 @@ Vous ne devez faire ces étapes qu'une fois. À noter que RetroArch 1.9.x ne fon
1. Entrez dans le menu principal de RetroArch. 1. Entrez dans le menu principal de RetroArch.
2. Allez dans Settings --> User Interface. Activez l'option "Show Advanced Settings". 2. Allez dans Settings --> User Interface. Activez l'option "Show Advanced Settings".
3. Allez dans Settings --> Network. Activez l'option "Network Commands", qui se trouve sous "Request Device 16". 3. Allez dans Settings --> Network. Activez l'option "Network Commands", qui se trouve sous "Request Device 16".
Laissez le "Network Command Port" à sa valeur par defaut, qui devrait être 55355. Laissez le "Network Command Port" à sa valeur par defaut, qui devrait être 55355. \
![Capture d'écran du menu Network Commands setting](../../generic/docs/retroarch-network-commands-fr.png)
![Capture d'écran du menu Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Allez dans le Menu Principal --> Online Updater --> Core Downloader. Trouvez et sélectionnez "Nintendo - SNES / SFC (bsnes-mercury 4. Allez dans le Menu Principal --> Online Updater --> Core Downloader. Trouvez et sélectionnez "Nintendo - SNES / SFC (bsnes-mercury
Performance)". Performance)".

View File

@@ -1,4 +1,5 @@
from typing import NamedTuple, Union from typing import NamedTuple, Union
from typing_extensions import deprecated
import logging import logging
from BaseClasses import Item, Tutorial, ItemClassification from BaseClasses import Item, Tutorial, ItemClassification
@@ -49,7 +50,8 @@ class GenericWorld(World):
return Item(name, ItemClassification.filler, -1, self.player) return Item(name, ItemClassification.filler, -1, self.player)
raise InvalidItemError(name) raise InvalidItemError(name)
@deprecated("worlds.generic.PlandoItem is deprecated and will be removed in the next version. "
"Use Options.PlandoItem(s) instead.")
class PlandoItem(NamedTuple): class PlandoItem(NamedTuple):
item: str item: str
location: str location: str

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -136,6 +136,27 @@ are rolling locally, ensure this file is edited to your liking **before** rollin
when running the Archipelago Installation software. If you have changed settings in this file, and would like to retain when running the Archipelago Installation software. If you have changed settings in this file, and would like to retain
them, you may rename the file to `options.yaml`. them, you may rename the file to `options.yaml`.
### Playing with custom worlds
If you are generating locally, you can play with worlds that are not included in the Archipelago installation.
These worlds are packaged as `.apworld` files. To add a world to your installation, click the "Install APWorld" button
in the launcher and select the `.apworld` file you wish to install. Alternatively, you can drag the `.apworld` file
onto the launcher or double-click the file itself (if on Windows). Once the world is installed, it will function like
the worlds that are already packaged with Archipelago. Also note that while generation with custom worlds must be done
locally, these games can then be uploaded to the website for hosting and played as normal.
We strongly recommend that you ensure the source of the `.apworld` is safe and trustworthy before playing with a
custom world. Installed APWorlds are able to run custom code on your computer whenever you open Archipelago.
#### Alternate versions of included worlds
If you want to play with an alternate version of a game that is already included in Archipelago, you should also
remove the original APWorld after completing the above installation. To do so, go to your Archipelago installation
folder and navigate to the `lib/worlds` directory. Then move the `.apworld` or the folder corresponding to the game you
want to play an alternate version of to somewhere else as a backup. If you want to play this original again, then
restore the original version to `lib/worlds` and remove the alternate version, which is in the `custom_worlds` folder.
Note: Currently, this cannot be done on the Linux AppImage release.
## Hosting an Archipelago Server ## Hosting an Archipelago Server

View File

@@ -6,6 +6,8 @@
* Steam, Gog, and Xbox Game Pass versions of the game are supported. * Steam, Gog, and Xbox Game Pass versions of the game are supported.
* Windows, Mac, and Linux (including Steam Deck) are supported. * Windows, Mac, and Linux (including Steam Deck) are supported.
**Do NOT** install BepInEx, it is not required and is incompatible with most mods. Archipelago, along with the majority of mods use custom tooling pre-dating BepInEx, and they are only available through Lumafly and similar installers rather than sites like Nexus Mods.
## Installing the Archipelago Mod using Lumafly ## Installing the Archipelago Mod using Lumafly
1. Launch Lumafly and ensure it locates your Hollow Knight installation directory. 1. Launch Lumafly and ensure it locates your Hollow Knight installation directory.
2. Install the Archipelago mods by doing either of the following: 2. Install the Archipelago mods by doing either of the following:

View File

@@ -6,6 +6,10 @@
* Las versiones de Steam, GOG y Xbox Game Pass son compatibles * Las versiones de Steam, GOG y Xbox Game Pass son compatibles
* Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles * Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles
**NO** instales BepInEx, **no** es necesario y es incompatible con varios mods. Archipelago (y la mayoría de los mods)
usan herramientas más antiguas que BepInEx, que solo están disponibles por medio de instaladores de mods como Lumafly y
similares, en vez de sitios web como Nexus Mods.
## Instalación del mod de Archipelago con Lumafly ## Instalación del mod de Archipelago con Lumafly
1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight 1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight
2. Instala el mod de Archipiélago haciendo click en cualquiera de los siguientes: 2. Instala el mod de Archipiélago haciendo click en cualquiera de los siguientes:

View File

@@ -6,6 +6,10 @@
* Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas. * Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas.
* Windows, Mac, e Linux (incluindo Steam Deck) são suportados. * Windows, Mac, e Linux (incluindo Steam Deck) são suportados.
**NÃO** instale o BepInEx, ele **não** é necessário e é incompatível com vários mods. O Archipelago (e a maioria dos mods)
usam ferramentas mais antigas do que o BepInEx, disponíveis apenas a partir de gerenciadores como o Lumafly e semelhantes,
ao invés de sites como o Nexus Mods.
## Instalando o mod Archipelago Mod usando Lumafly ## Instalando o mod Archipelago Mod usando Lumafly
1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight. 1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight.
2. Clique em "Install (instalar)" perto da opção "Archipelago" mod. 2. Clique em "Install (instalar)" perto da opção "Archipelago" mod.

View File

@@ -134,13 +134,13 @@ class KH1Context(CommonContext):
os.makedirs(self.game_communication_path) os.makedirs(self.game_communication_path)
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
f.close() f.close()
# Handle Slot Data # Handle Slot Data
self.slot_data = args['slot_data'] self.slot_data = args['slot_data']
for key in list(args['slot_data'].keys()): for key in list(args['slot_data'].keys()):
with open(os.path.join(self.game_communication_path, key + ".cfg"), 'w') as f: with open(os.path.join(self.game_communication_path, key + ".cfg"), 'w', encoding='utf-8') as f:
f.write(str(args['slot_data'][key])) f.write(str(args['slot_data'][key]))
f.close() f.close()
if key == "remote_location_ids": if key == "remote_location_ids":
@@ -161,7 +161,7 @@ class KH1Context(CommonContext):
found = True found = True
if not found: if not found:
if (NetworkItem(*item).player == self.slot and (NetworkItem(*item).location in self.remote_location_ids) or (NetworkItem(*item).location < 0)) or NetworkItem(*item).player != self.slot: if (NetworkItem(*item).player == self.slot and (NetworkItem(*item).location in self.remote_location_ids) or (NetworkItem(*item).location < 0)) or NetworkItem(*item).player != self.slot:
with open(os.path.join(self.game_communication_path, item_filename), 'w') as f: with open(os.path.join(self.game_communication_path, item_filename), 'w', encoding='utf-8') as f:
f.write(str(NetworkItem(*item).item) + "\n" + str(NetworkItem(*item).location) + "\n" + str(NetworkItem(*item).player)) f.write(str(NetworkItem(*item).item) + "\n" + str(NetworkItem(*item).location) + "\n" + str(NetworkItem(*item).player))
f.close() f.close()
self.item_num += 1 self.item_num += 1
@@ -170,7 +170,7 @@ class KH1Context(CommonContext):
if "checked_locations" in args: if "checked_locations" in args:
for ss in self.checked_locations: for ss in self.checked_locations:
filename = f"send{ss}" filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
f.close() f.close()
if cmd in {"PrintJSON"} and "type" in args: if cmd in {"PrintJSON"} and "type" in args:
@@ -195,7 +195,7 @@ class KH1Context(CommonContext):
filename = "msg" filename = "msg"
if message != "": if message != "":
if not os.path.exists(self.game_communication_path + "/" + filename): if not os.path.exists(self.game_communication_path + "/" + filename):
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
f.write(message) f.write(message)
f.close() f.close()
if args["type"] == "ItemCheat": if args["type"] == "ItemCheat":
@@ -207,7 +207,7 @@ class KH1Context(CommonContext):
filename = "msg" filename = "msg"
message = "Received " + itemName + "\nfrom server" message = "Received " + itemName + "\nfrom server"
if not os.path.exists(self.game_communication_path + "/" + filename): if not os.path.exists(self.game_communication_path + "/" + filename):
with open(os.path.join(self.game_communication_path, filename), 'w') as f: with open(os.path.join(self.game_communication_path, filename), 'w', encoding='utf-8') as f:
f.write(message) f.write(message)
f.close() f.close()
@@ -218,7 +218,7 @@ class KH1Context(CommonContext):
logger.info(f"DeathLink: {text}") logger.info(f"DeathLink: {text}")
else: else:
logger.info(f"DeathLink: Received from {data['source']}") logger.info(f"DeathLink: Received from {data['source']}")
with open(os.path.join(self.game_communication_path, 'dlreceive'), 'w') as f: with open(os.path.join(self.game_communication_path, 'dlreceive'), 'w', encoding='utf-8') as f:
f.write(str(int(data["time"]))) f.write(str(int(data["time"])))
f.close() f.close()

View File

@@ -0,0 +1,6 @@
{
"game": "Kingdom Hearts 2",
"authors": [ "JaredWeakStrike" ],
"minimum_ap_version": "0.6.3",
"world_version": "2.0.0"
}

View File

@@ -57,6 +57,7 @@ from .patches import bingo as _
from .patches import multiworld as _ from .patches import multiworld as _
from .patches import tradeSequence as _ from .patches import tradeSequence as _
from . import hints from . import hints
from . import utils
from .patches import bank34 from .patches import bank34
from .roomEditor import RoomEditor, Object from .roomEditor import RoomEditor, Object
@@ -231,10 +232,10 @@ def generateRom(base_rom: bytes, args, patch_data: Dict):
rom.patch(0, 0x0003, "00", "01") rom.patch(0, 0x0003, "00", "01")
# Patch the sword check on the shopkeeper turning around. # Patch the sword check on the shopkeeper turning around.
#if ladxr_settings["steal"] == 'never': if options["stealing"] == Options.Stealing.option_disabled:
# rom.patch(4, 0x36F9, "FA4EDB", "3E0000") rom.patch(4, 0x36F9, "FA4EDB", "3E0000")
#elif ladxr_settings["steal"] == 'always': rom.texts[0x2E] = utils.formatText("Hey! Welcome! Did you know that I have eyes on the back of my head?")
# rom.patch(4, 0x36F9, "FA4EDB", "3E0100") rom.texts[0x2F] = utils.formatText("Nothing escapes my gaze! Your thieving ways shall never prosper!")
#if ladxr_settings["hpmode"] == 'inverted': #if ladxr_settings["hpmode"] == 'inverted':
# patches.health.setStartHealth(rom, 9) # patches.health.setStartHealth(rom, 9)
@@ -325,7 +326,7 @@ def generateRom(base_rom: bytes, args, patch_data: Dict):
0x1A9, 0x1AA, 0x1AB, 0x1AC, 0x1AD, 0x1A9, 0x1AA, 0x1AB, 0x1AC, 0x1AD,
# Prices # Prices
0x02C, 0x02D, 0x030, 0x031, 0x032, 0x033, # Shop items 0x02C, 0x02D, 0x02E, 0x02F, 0x030, 0x031, 0x032, 0x033, # Shop items
0x03B, # Trendy Game 0x03B, # Trendy Game
0x045, # Fisherman 0x045, # Fisherman
0x018, 0x019, # Crazy Tracy 0x018, 0x019, # Crazy Tracy

View File

@@ -43,8 +43,12 @@ class World:
self._addEntrance("start_house", mabe_village, start_house, None) self._addEntrance("start_house", mabe_village, start_house, None)
shop = Location("Shop") shop = Location("Shop")
Location().add(ShopItem(0)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 500)), SWORD)) if options.steal == "inlogic":
Location().add(ShopItem(1)).connect(shop, OR(AND(r.can_farm, COUNT("RUPEES", 1480)), SWORD)) Location().add(ShopItem(0)).connect(shop, OR(SWORD, AND(r.can_farm, COUNT("RUPEES", 500))))
Location().add(ShopItem(1)).connect(shop, OR(SWORD, AND(r.can_farm, COUNT("RUPEES", 1480))))
else:
Location().add(ShopItem(0)).connect(shop, AND(r.can_farm, COUNT("RUPEES", 500)))
Location().add(ShopItem(1)).connect(shop, AND(r.can_farm, COUNT("RUPEES", 1480)))
self._addEntrance("shop", mabe_village, shop, None) self._addEntrance("shop", mabe_village, shop, None)
dream_hut = Location("Dream Hut") dream_hut = Location("Dream Hut")

View File

@@ -162,8 +162,8 @@ Note, some entrances can lead into water, use the warp-to-home from the save&qui
[Hero] Switch version hero mode, double damage, no heart/fairy drops. [Hero] Switch version hero mode, double damage, no heart/fairy drops.
[One hit KO] You die on a single hit, always."""), [One hit KO] You die on a single hit, always."""),
Setting('steal', 'Gameplay', 't', 'Stealing from the shop', Setting('steal', 'Gameplay', 't', 'Stealing from the shop',
options=[('always', 'a', 'Always'), ('never', 'n', 'Never'), ('default', '', 'Normal')], default='default', options=[('inlogic', 'a', 'In logic'), ('disabled', 'n', 'Disabled'), ('outoflogic', '', 'Out of logic')], default='outoflogic',
description="""Effects when you can steal from the shop. Stealing is bad and never in logic. description="""Effects when you can steal from the shop and if it is in logic.
[Normal] requires the sword before you can steal. [Normal] requires the sword before you can steal.
[Always] you can always steal from the shop [Always] you can always steal from the shop
[Never] you can never steal from the shop."""), [Never] you can never steal from the shop."""),
@@ -286,7 +286,7 @@ Note, some entrances can lead into water, use the warp-to-home from the save&qui
if self.goal in ("bingo", "bingo-full"): if self.goal in ("bingo", "bingo-full"):
req("overworld", "normal", "Bingo goal does not work with dungeondive") req("overworld", "normal", "Bingo goal does not work with dungeondive")
req("accessibility", "all", "Bingo goal needs 'all' accessibility") req("accessibility", "all", "Bingo goal needs 'all' accessibility")
dis("steal", "never", "default", "With bingo goal, stealing should be allowed") dis("steal", "disabled", "default", "With bingo goal, stealing should be allowed")
dis("boss", "random", "shuffle", "With bingo goal, bosses need to be on normal or shuffle") dis("boss", "random", "shuffle", "With bingo goal, bosses need to be on normal or shuffle")
dis("miniboss", "random", "shuffle", "With bingo goal, minibosses need to be on normal or shuffle") dis("miniboss", "random", "shuffle", "With bingo goal, minibosses need to be on normal or shuffle")
if self.overworld == "dungeondive": if self.overworld == "dungeondive":

View File

@@ -3,9 +3,6 @@ ModuleUpdate.update()
import Utils import Utils
if __name__ == "__main__":
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
import asyncio import asyncio
import base64 import base64
import binascii import binascii
@@ -26,16 +23,14 @@ import typing
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger, from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop) server_loop)
from NetUtils import ClientStatus from NetUtils import ClientStatus
from worlds.ladx import LinksAwakeningWorld from . import LinksAwakeningWorld
from worlds.ladx.Common import BASE_ID as LABaseID from .Common import BASE_ID as LABaseID
from worlds.ladx.GpsTracker import GpsTracker from .GpsTracker import GpsTracker
from worlds.ladx.TrackerConsts import storage_key from .TrackerConsts import storage_key
from worlds.ladx.ItemTracker import ItemTracker from .ItemTracker import ItemTracker
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable from .LADXR.checkMetadata import checkMetadataTable
from worlds.ladx.Locations import get_locations_to_id, meta_to_name from .Locations import get_locations_to_id, meta_to_name
from worlds.ladx.Tracker import LocationTracker, MagpieBridge, Check from .Tracker import LocationTracker, MagpieBridge, Check
class GameboyException(Exception): class GameboyException(Exception):
pass pass
@@ -760,42 +755,44 @@ def run_game(romfile: str) -> None:
except FileNotFoundError: except FileNotFoundError:
logger.error(f"Couldn't launch ROM, {args[0]} is missing") logger.error(f"Couldn't launch ROM, {args[0]} is missing")
async def main(): def launch(*launch_args):
parser = get_base_parser(description="Link's Awakening Client.") async def main():
parser.add_argument("--url", help="Archipelago connection url") parser = get_base_parser(description="Link's Awakening Client.")
parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge") parser.add_argument("--url", help="Archipelago connection url")
parser.add_argument('diff_file', default="", type=str, nargs="?", parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge")
help='Path to a .apladx Archipelago Binary Patch file') parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apladx Archipelago Binary Patch file')
args = parser.parse_args() args = parser.parse_args(launch_args)
if args.diff_file: if args.diff_file:
import Patch import Patch
logger.info("patch file was supplied - creating rom...") logger.info("patch file was supplied - creating rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file) meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta and not args.connect: if "server" in meta and not args.connect:
args.connect = meta["server"] args.connect = meta["server"]
logger.info(f"wrote rom file to {rom_file}") logger.info(f"wrote rom file to {rom_file}")
ctx = LinksAwakeningContext(args.connect, args.password, args.magpie) ctx = LinksAwakeningContext(args.connect, args.password, args.magpie)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
# TODO: nothing about the lambda about has to be in a lambda # TODO: nothing about the lambda about has to be in a lambda
ctx.la_task = create_task_log_exception(ctx.run_game_loop()) ctx.la_task = create_task_log_exception(ctx.run_game_loop())
if gui_enabled: if gui_enabled:
ctx.run_gui() ctx.run_gui()
ctx.run_cli() ctx.run_cli()
# Down below run_gui so that we get errors out of the process # Down below run_gui so that we get errors out of the process
if args.diff_file: if args.diff_file:
run_game(rom_file) run_game(rom_file)
await ctx.exit_event.wait() await ctx.exit_event.wait()
await ctx.shutdown() await ctx.shutdown()
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
if __name__ == '__main__':
colorama.just_fix_windows_console() colorama.just_fix_windows_console()
asyncio.run(main()) asyncio.run(main())
colorama.deinit() colorama.deinit()

View File

@@ -3,7 +3,7 @@ from dataclasses import dataclass
import os.path import os.path
import typing import typing
import logging import logging
from Options import Choice, Toggle, DefaultOnToggle, Range, FreeText, PerGameCommonOptions, OptionGroup, Removed from Options import Choice, Toggle, DefaultOnToggle, Range, FreeText, PerGameCommonOptions, OptionGroup, Removed, StartInventoryPool
from collections import defaultdict from collections import defaultdict
import Utils import Utils
@@ -325,6 +325,18 @@ class HardMode(Choice, LADXROption):
default = option_none default = option_none
class Stealing(Choice, LADXROption):
"""
Puts stealing from the shop in logic if the player has a sword.
"""
display_name = "Stealing"
ladxr_name = "steal"
option_in_logic = 1
option_out_of_logic = 2
option_disabled = 3
default = option_out_of_logic
class Overworld(Choice, LADXROption): class Overworld(Choice, LADXROption):
""" """
**Open Mabe:** Replaces rock on the east side of Mabe Village with bushes, **Open Mabe:** Replaces rock on the east side of Mabe Village with bushes,
@@ -656,6 +668,7 @@ class LinksAwakeningOptions(PerGameCommonOptions):
nag_messages: NagMessages nag_messages: NagMessages
ap_title_screen: APTitleScreen ap_title_screen: APTitleScreen
boots_controls: BootsControls boots_controls: BootsControls
stealing: Stealing
quickswap: Quickswap quickswap: Quickswap
hard_mode: HardMode hard_mode: HardMode
low_hp_beep: LowHpBeep low_hp_beep: LowHpBeep
@@ -665,6 +678,7 @@ class LinksAwakeningOptions(PerGameCommonOptions):
tarins_gift: TarinsGift tarins_gift: TarinsGift
overworld: Overworld overworld: Overworld
stabilize_item_pool: StabilizeItemPool stabilize_item_pool: StabilizeItemPool
start_inventory_from_pool: StartInventoryPool
warp_improvements: Removed warp_improvements: Removed
additional_warp_points: Removed additional_warp_points: Removed

View File

@@ -97,7 +97,7 @@ def write_patch_data(world: "LinksAwakeningWorld", patch: LADXProcedurePatch):
"nag_messages", "nag_messages",
"ap_title_screen", "ap_title_screen",
"boots_controls", "boots_controls",
# "stealing", "stealing",
"quickswap", "quickswap",
"hard_mode", "hard_mode",
"low_hp_beep", "low_hp_beep",

View File

@@ -9,6 +9,7 @@ import settings
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Tutorial
from Fill import fill_restrictive from Fill import fill_restrictive
from worlds.AutoWorld import WebWorld, World from worlds.AutoWorld import WebWorld, World
from worlds.LauncherComponents import Component, components, SuffixIdentifier, Type, launch, icon_paths
from .Common import * from .Common import *
from . import ItemIconGuessing from . import ItemIconGuessing
from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData, from .Items import (DungeonItemData, DungeonItemType, ItemName, LinksAwakeningItem, TradeItemData,
@@ -29,6 +30,19 @@ from .Rom import LADXProcedurePatch, write_patch_data
DEVELOPER_MODE = False DEVELOPER_MODE = False
def launch_client(*args):
from .LinksAwakeningClient import launch as ladx_launch
launch(ladx_launch, name=f"{LINKS_AWAKENING} Client", args=args)
components.append(Component(f"{LINKS_AWAKENING} Client",
func=launch_client,
component_type=Type.CLIENT,
icon=LINKS_AWAKENING,
file_identifier=SuffixIdentifier('.apladx')))
icon_paths[LINKS_AWAKENING] = "ap:worlds.ladx/assets/MarinV-3_small.png"
class LinksAwakeningSettings(settings.Group): class LinksAwakeningSettings(settings.Group):
class RomFile(settings.UserFilePath): class RomFile(settings.UserFilePath):
"""File name of the Link's Awakening DX rom""" """File name of the Link's Awakening DX rom"""
@@ -211,8 +225,6 @@ class LinksAwakeningWorld(World):
def create_items(self) -> None: def create_items(self) -> None:
itempool = [] itempool = []
exclude = [item.name for item in self.multiworld.precollected_items[self.player]]
self.prefill_original_dungeon = [ [], [], [], [], [], [], [], [], [] ] self.prefill_original_dungeon = [ [], [], [], [], [], [], [], [], [] ]
self.prefill_own_dungeons = [] self.prefill_own_dungeons = []
self.pre_fill_items = [] self.pre_fill_items = []
@@ -229,50 +241,46 @@ class LinksAwakeningWorld(World):
continue continue
item_name = ladxr_item_to_la_item_name[ladx_item_name] item_name = ladxr_item_to_la_item_name[ladx_item_name]
for _ in range(count): for _ in range(count):
if item_name in exclude: item = self.create_item(item_name)
exclude.remove(item_name) # this is destructive. create unique list above
self.multiworld.itempool.append(self.create_item(self.get_filler_item_name()))
else:
item = self.create_item(item_name)
if not self.options.tradequest and isinstance(item.item_data, TradeItemData): if not self.options.tradequest and isinstance(item.item_data, TradeItemData):
location = self.multiworld.get_location(item.item_data.vanilla_location, self.player) location = self.multiworld.get_location(item.item_data.vanilla_location, self.player)
location.place_locked_item(item) location.place_locked_item(item)
location.show_in_spoiler = False location.show_in_spoiler = False
continue continue
if isinstance(item.item_data, DungeonItemData): if isinstance(item.item_data, DungeonItemData):
item_type = item.item_data.ladxr_id[:-1] item_type = item.item_data.ladxr_id[:-1]
shuffle_type = self.dungeon_item_types[item_type] shuffle_type = self.dungeon_item_types[item_type]
if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla: if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla:
# Find instrument, lock # Find instrument, lock
# TODO: we should be able to pinpoint the region we want, save a lookup table please # TODO: we should be able to pinpoint the region we want, save a lookup table please
found = False found = False
for r in self.multiworld.get_regions(self.player): for r in self.multiworld.get_regions(self.player):
if r.dungeon_index != item.item_data.dungeon_index: if r.dungeon_index != item.item_data.dungeon_index:
continue
for loc in r.locations:
if not isinstance(loc, LinksAwakeningLocation):
continue continue
for loc in r.locations: if not isinstance(loc.ladxr_item, Instrument):
if not isinstance(loc, LinksAwakeningLocation): continue
continue loc.place_locked_item(item)
if not isinstance(loc.ladxr_item, Instrument): found = True
continue break
loc.place_locked_item(item) if found:
found = True break
break
if found:
break
else:
if shuffle_type == DungeonItemShuffle.option_original_dungeon:
self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item)
self.pre_fill_items.append(item)
elif shuffle_type == DungeonItemShuffle.option_own_dungeons:
self.prefill_own_dungeons.append(item)
self.pre_fill_items.append(item)
else:
itempool.append(item)
else: else:
itempool.append(item) if shuffle_type == DungeonItemShuffle.option_original_dungeon:
self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item)
self.pre_fill_items.append(item)
elif shuffle_type == DungeonItemShuffle.option_own_dungeons:
self.prefill_own_dungeons.append(item)
self.pre_fill_items.append(item)
else:
itempool.append(item)
else:
itempool.append(item)
self.multi_key = self.generate_multi_key() self.multi_key = self.generate_multi_key()

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -73,9 +73,8 @@ You only have to do these steps once. Note, RetroArch 1.9.x will not work as it
1. Enter the RetroArch main menu screen. 1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default 3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355. Network Command Port at 55355. \
![Screenshot of Network Commands setting](../../generic/docs/retroarch-network-commands-en.png)
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - Gameboy / Color (SameBoy)". 4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - Gameboy / Color (SameBoy)".
#### BizHawk 2.8 or newer (older versions untested) #### BizHawk 2.8 or newer (older versions untested)

View File

@@ -48,7 +48,7 @@ A window will open with a few settings to enter:
- **Slot name**: Put the player name you specified in your YAML config file in this field. - **Slot name**: Put the player name you specified in your YAML config file in this field.
- **Password**: If the server has a password, put it there. - **Password**: If the server has a password, put it there.
![Landstalker Archipelago Client user interface](/static/generated/docs/Landstalker%20-%20The%20Treasures%20of%20King%20Nole/ls_guide_ap.png) ![Landstalker Archipelago Client user interface](ls_guide_ap.png)
Once all those fields were filled appropriately, click on the `Connect to Archipelago` button below to try connecting to Once all those fields were filled appropriately, click on the `Connect to Archipelago` button below to try connecting to
the Archipelago server. the Archipelago server.
@@ -67,7 +67,7 @@ You should see a window with settings to fill:
- **Output ROM directory**: This is where the randomized ROMs will be put. No need to change this unless you want them - **Output ROM directory**: This is where the randomized ROMs will be put. No need to change this unless you want them
to be created in a very specific folder. to be created in a very specific folder.
![Landstalker Archipelago Client user interface](/static/generated/docs/Landstalker%20-%20The%20Treasures%20of%20King%20Nole/ls_guide_rom.png) ![Landstalker Archipelago Client user interface](ls_guide_rom.png)
There also a few cosmetic options you can fill before clicking the `Build ROM` button which should create your There also a few cosmetic options you can fill before clicking the `Build ROM` button which should create your
randomized seed if everything went right. randomized seed if everything went right.
@@ -83,7 +83,7 @@ the items you have received from other players.
You should see the following window: You should see the following window:
![Landstalker Archipelago Client user interface](/static/generated/docs/Landstalker%20-%20The%20Treasures%20of%20King%20Nole/ls_guide_emu.png) ![Landstalker Archipelago Client user interface](ls_guide_emu.png)
As written, you have to open the newly generated ROM inside either Retroarch or Bizhawk using the Genesis Plus GX core. As written, you have to open the newly generated ROM inside either Retroarch or Bizhawk using the Genesis Plus GX core.
Be careful to select that core, because any other core (e.g. BlastEm) won't work. Be careful to select that core, because any other core (e.g. BlastEm) won't work.
@@ -116,6 +116,6 @@ The client is packaged with both an **automatic item tracker** and an **automati
If you don't know all checks in the game, don't be afraid: you can click the `Where is it?` button that will show If you don't know all checks in the game, don't be afraid: you can click the `Where is it?` button that will show
you a screenshot of where the location actually is. you a screenshot of where the location actually is.
![Landstalker Archipelago Client user interface](/static/generated/docs/Landstalker%20-%20The%20Treasures%20of%20King%20Nole/ls_guide_client.png) ![Landstalker Archipelago Client user interface](ls_guide_client.png)
Have fun! Have fun!

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