Compare commits

...

374 Commits

Author SHA1 Message Date
Fabian Dill
6d4ce04067 Core/Subnautica: demonstrate a way to on-demand specify WebWorld 2024-01-26 23:51:11 +01:00
black-sliver
aa72f671bc SoE: fix naming of atlas medallion (#2747)
In pyevermizer, it's called Atlas Medallion, not Amulet, leading to an
empty group and to code not considering them as an alchemy ingredient
when swapping out for a trap or an energy core fragment.

Also adds a test.
2024-01-21 19:34:24 +01:00
Scipio Wright
5f9ce2b7b6 Noita: Update to use new Options API (#2370)
Reworking the options to make it work with the new options API.
Also reworked stuff in several spots to use world: NoitaWorld instead of multiworld: MultiWorld
2024-01-19 21:31:45 +01:00
zig-for
1307754f02 LADX: music shuffle (#2101) 2024-01-19 21:14:26 +01:00
Bicoloursnake
ac7b707e3e OOT: Adjust the Logic Trick Keys to be an ordered object (#2736) 2024-01-18 02:18:03 +01:00
Star Rauchenberger
ec440b7785 Lingo: NORTH requires hint panels (#2732) 2024-01-18 01:58:48 +01:00
Danaël V
4c901dcfc0 TUNIC: Change Tunic to TUNIC (#2720) 2024-01-18 01:56:34 +01:00
Fabian Dill
834b6e35b4 Setup: auto update vc redist (#2502) 2024-01-18 01:52:33 +01:00
Fabian Dill
602c2966fc LttP: move _hint_text to SubClasses (#2532) 2024-01-16 17:23:18 +01:00
black-sliver
49ecd4b9c1 CI: flake8: max-complexity=14 (#2731)
The value of 10 does not really fit some of our world patterns and values
up to 15 may be acceptable. Looking at some worlds, 14 seems to be
achievable without too much work and reduces the noise in test output,
making it more usable.
2024-01-16 17:10:58 +01:00
black-sliver
de8fe21d4a Tests: create sane cov defaults (#2728) 2024-01-16 17:10:19 +01:00
NewSoupVi
4fdeec4f70 The Witness: Cleanup - Options Access, data version, snake_case for file name (#2631) 2024-01-16 15:33:34 +01:00
NewSoupVi
71a3e2230d The Witness: Allow Mountain Lasers to go up to 11 instead of 7. (#2618) 2024-01-16 15:27:09 +01:00
JaredWeakStrike
325a510ba7 KH2: Promise charm logic (#2635) 2024-01-16 15:26:18 +01:00
NewSoupVi
5dcaa6ca20 The Witness: Death Link Amnesty (#2646) 2024-01-16 15:24:10 +01:00
NewSoupVi
e15873e861 The Witness: Bonk trap support (#2645) 2024-01-16 15:23:30 +01:00
NewSoupVi
5c7bae7940 The Witness: Local Laser Shuffle + Option Presets (#2590)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-01-16 15:14:06 +01:00
NewSoupVi
e6f7ed5060 The Witness: Progressive Symmetry (#2644) 2024-01-16 15:13:04 +01:00
NewSoupVi
1c2dcb7b01 The Witness: Add Desert Control Panels (#2643) 2024-01-16 15:11:52 +01:00
Star Rauchenberger
5df7a8f686 Lingo: Disable forced good item when early color hallways is on (#2729) 2024-01-16 15:10:59 +01:00
Bryce Wilson
3a588099bd Pokemon Emerald: Automatically exclude locations based on goal (#2655) 2024-01-16 15:09:47 +01:00
Yussur Mustafa Oraji
d000b52ae0 V6: Use new options api (#2668)
* v6: Use new options API

* v6: Add display names for some options
2024-01-16 13:38:19 +01:00
NewSoupVi
fe3bc8d6be The Witness: Add Obelisk Side locations to always and priority hints (#2665) 2024-01-16 13:14:38 +01:00
NewSoupVi
7affb885ba The Witness: Add "Town Desert Laser Redirect Control (Panel)" as an item (#2669) 2024-01-16 13:13:44 +01:00
Star Rauchenberger
d390d2eff8 Lingo: Remove colors from Bearer SIXes (#2677) 2024-01-16 13:13:02 +01:00
JaredWeakStrike
0efc13fc8a KH2: Location Groups and Subclasses (#2700) 2024-01-16 13:12:33 +01:00
Star Rauchenberger
c6896c6af9 Lingo: Make The Colorful optionally progressive (#2711) 2024-01-16 13:11:20 +01:00
Star Rauchenberger
adad7b532d Lingo: Turn The Colorful into a countdown achievement (#2710)
The Colorful currently, in logic, does not expect you to solve the achievement panel until all of the doors are opened. This is not enforced by the client in complex door shuffle. It is also not typical of how achievements in Lingo usually work, and it ended up this way because of the fact that The Colorful is, uniquely, not a countdown panel. This change modifies logic so that solving each panel within The Colorful is required in order to access the achievement, rather than opening all of the doors. This will be accompanied by a change to the client that will turn the achievement panel into a countdown.
2024-01-16 13:09:54 +01:00
Held_der_Zeit
d756960a0b Worlds Docs: Translations German (Clique, BK Sudoku, OoT) (#2581)
* Sudoku German

* German OOT (+ Room Image)

* German Clique

* german translation

* translation flexibility - ff1

* german setup - oot

* Transaltion Flexibilty - SM64

* translation flexibilty - factorio

* translation flexibilty - kh2

* translation flexibility - Super Metroid

* translation flexibility - Stardew Valley

* german translation added - clique

* translation flexibility - terraria

* translation flexibilty - checksfinder

* Sudoku Setup - Grammar Fix

* Sudoku Main - Fix Grammar

* Revert "translation flexibility - ff1"

This reverts commit 6df434c682.

* Revert "Transaltion Flexibilty - SM64"

This reverts commit 754bf95d2f.

* Revert "translation flexibilty - factorio"

This reverts commit db1226a9de.

* Revert "translation flexibility - Super Metroid"

This reverts commit ca5bd9a64a.

* Revert "translation flexibilty - kh2"

This reverts commit 076534ee32.

* Revert "translation flexibility - Stardew Valley"

This reverts commit 4b13701394.

* Revert "translation flexibility - terraria"

This reverts commit a0abfc8a03.

* Revert "translation flexibilty - checksfinder"

This reverts commit a4de49961d.

* Sugesstion - Fixes in Grammar (and Typos)

One or two suggesstions need to be changed a bit further (such as an incomplete sentence)

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

* Update guide_de.md

* Update setup_de.md

* Update de_Sudoku.md

* Update __init__.py

* Update worlds/oot/docs/setup_de.md

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2024-01-16 06:54:48 +01:00
Alchav
30ec080449 FFMQ: Reset protection (#2727)
Bizhawk's "hard reset" option fills RAM with 0x55s. This causes game completion to be erroneously flagged, and likely many erroneous location checks with it. This fix checks for 0x55 and will not proceed to process anything if present.
2024-01-16 01:21:02 +01:00
Fabian Dill
79e2f7e357 Tests: test that World.options is not set on the class (#2725)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2024-01-15 20:50:16 +01:00
t3hf1gm3nt
b4077a0717 TLOZ: properly assign options (#2726)
whoops used a = instead of a :
mad that im doing a literal one character change PR :/
2024-01-15 20:19:18 +01:00
black-sliver
518b04c08e SoE: minor typing and style fixes (#2724)
* SoE: fix typing for tests

* SoE: explicitly export pyevermizer

To support loading the module from source (rather than module) we import
pyevermizer from `__init__.py` in other files. This has been an implicit export
and `mypy --strict` disables implicit exports, so we export it explicitly now.

* SoE: fix style in patch.py

* SoE: remove unused imports

* SoE: fix format mistakes

* SoE: cleaner typing in SoEOptions.flags

as suggested by beauxq
2024-01-15 09:17:46 +01:00
GodlFire
d10f8f66c7 Shivers: Fix rule logic for location 'Final Riddle: Guillotine Dropped' (#2706) 2024-01-15 04:48:44 +01:00
t3hf1gm3nt
6d393fe42b TLOZ: update to new options API (#2714) 2024-01-15 04:47:32 +01:00
agilbert1412
5b93db121f Stardew Valley: Added missing rule on the club card (#2722) 2024-01-15 04:29:30 +01:00
Fabian Dill
ad074490bc Test: add location access rule benchmark (#2433) 2024-01-14 21:30:00 +01:00
Fabian Dill
6ac3d5c651 Core: set consistent server defaults (#2566) 2024-01-14 21:24:34 +01:00
Doug Hoskisson
ed6b7b2670 Zillion: remove old option access from item link validation (#2673)
* Zillion: remove old option access from item link validation
and a little bit a cleaning in other stuff nearby

* one option access missed
2024-01-14 15:48:30 +01:00
Doug Hoskisson
6904bd5885 Typing: improve kivy type stubs (#2681) 2024-01-14 15:31:13 +01:00
black-sliver
962b9b28f0 Setup: don't install webhost dependencies (#2717)
also makes ModuleUpdate detect changed requirements for update()
2024-01-14 03:09:03 +01:00
Aaron Wagener
37b03807fd Core: Log the worlds still using the old options API (#2707) 2024-01-14 03:04:12 +01:00
Aaron Wagener
73e41cb701 Core: migrate start_inventory_from_pool to new options API (#2666)
* Core: migrate start_inventory_from_pool to new options API

* get the other spot too

* skip {}

* oops

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2024-01-14 02:57:53 +01:00
Aaron Wagener
cfd758168c Tests: add a test for worlds to not modify the itempool after create_items (#1460)
* Tests: add a test for worlds to only modify the itempool in `create_items`

* extend test multiworld setup instead of a new function

* cleanup the test a bit

* put more strict wording in `create_items` docstring

* list of shame

* Don't call `set_rules` before testing

* remove ChecksFinder from the list of shame

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2024-01-14 02:15:35 +01:00
Nicholas Saylor
01fb44c186 Docs: Added Disabled World information to README.md (#2705)
* Add rationale for OriBF being disabled

* Removed periods

* Added warning to README.md

* Apply suggestions from code review

Co-authored-by: Silvris <58583688+Silvris@users.noreply.github.com>

* Added disable date

Meant to provide context for any updates the world may need (For example, this world would need to change to the new options sstem in 0.4.4)

* Moved rationale to local README
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

---------

Co-authored-by: Silvris <58583688+Silvris@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2024-01-13 21:10:16 +01:00
Aaron Wagener
2725c0258f Docs: specify that deathlink cause should contain the player name (#2557)
* Docs: specify that the cause should contain the player name

* accidental whitespace moment

* fix table formatting
2024-01-13 19:23:14 +01:00
black-sliver
0c0adb0745 Core: update kivy (#2718) 2024-01-13 18:01:36 +01:00
Scipio Wright
4a85f21c25 TUNIC: Update game page for blurb about playing vanilla first (#2712)
* Update en_Tunic.md

* Change emphasis a bit

* Move the "haven't played before" section up

* settings -> options

* Update worlds/tunic/docs/en_Tunic.md

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

* Update setup as well with settings -> options and some recent changes to the in-game settings

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2024-01-13 15:12:43 +01:00
Silent
3933fd3929 TUNIC: Implement New Game (#2172) 2024-01-12 20:32:15 +01:00
Ame
b241644e54 Docs: add FR guide for DLCQuest (#2699)
* Docs: add Translate FR guide for DLCQuest

* Add Translate

* fix

* Update worlds/dlcquest/docs/fr_DLCQuest.md

Co-authored-by: Danaël V. <104455676+ReverM@users.noreply.github.com>

* Fix Translate

* Fix translate

* Update __init__.py

* Update worlds/dlcquest/__init__.py

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

* Update worlds/dlcquest/__init__.py

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

---------

Co-authored-by: Danaël V. <104455676+ReverM@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2024-01-12 20:26:50 +01:00
black-sliver
e00b5a7d17 SoE: use new AP API and naming and make APworld (#2701)
* SoE: new file naming

also fixes test base deprecation

* SoE: use options_dataclass

* SoE: moar typing

* SoE: no more multiworld.random

* SoE: replace LogicMixin by SoEPlayerLogic object

* SoE: add test that rocket parts always exist

* SoE: Even moar typing

* SoE: can haz apworld now

* SoE: pep up test naming

* SoE: use self.options for trap chances

* SoE: remove unused import with outdated comment

* SoE: move flag and trap extraction to dataclass

as suggested by beauxq

* SoE: test trap option parsing and item generation
2024-01-12 01:07:40 +01:00
Alchav
47dd36456e Pokémon R/B: Fix move intervention (#2687) 2024-01-12 00:49:54 +01:00
Fabian Dill
4ce8a7ec4d PyCharm: ship a working unittest run config (#2694) 2024-01-12 00:49:14 +01:00
Kory Dondzila
a99c1e15ad Shivers: Fixes issue with office elevator rule logic. (#2690)
Office elevator logic was written as
can reach Underground Tunnels OR can reach Office AND have Key for Office Elevator

Meaning that key for office elevator was not required if Underground Tunnels could be reached when it should be.

Changed to
(can reach Underground Tunnels OR can reach Office) AND have Key for Office Elevator
2024-01-12 00:48:22 +01:00
Fabian Dill
44de140add SC2: run download_data via concurrent.futures (#2704) 2024-01-12 00:40:33 +01:00
Doug Hoskisson
ac2387e17c Tests: remove deprecated option access from WorldTestBase (#2671)
* remove deprecated option access from `WorldTestBase`

* one in test_reachability
2024-01-12 00:22:04 +01:00
Danaël V
2760deb5b6 Docs: Fix broken link in Landstalker setup Guide (#2680)
* Cleaning up (#4)

Cleanup

* Update landstalker_setup_en.md

Fixed Redirect
2024-01-12 00:18:11 +01:00
Remy Jette
f530895c33 WebHost: Fix /api/generate (#2693) 2024-01-11 17:44:12 -05:00
Justus Lind
b6f3ccb8c5 Touhou Mugakudan 3 song update. (#2659)
- Adds all the songs from the Touhou Mugakudan -Ⅲ- update. 
- Increases the upper limit of additional songs to 508 due to there being 512 songs now.
- Finally fixes umpopoff. As it was the only song that had less than 3 difficulties but also didn't have proper difficulty values
2024-01-11 23:13:39 +01:00
Flori
388413fcdd Hollow Knight: Fix fragile/unbreakable charm variants counting as 2 distinct charms in logic (#2683)
Deletes CHARM of the 3 unbreakable charms, adds 0.5 CHARM to Queen_fragment, King_Fragment and Void_heart
2024-01-11 23:10:25 +01:00
JaredWeakStrike
4045c6a9cf KH2: Fix relative import (#2702) 2024-01-11 00:56:43 +01:00
JaredWeakStrike
e082c83dc7 KH2: Fix plando breaking because of keyblades (#2678) 2024-01-10 18:22:54 +01:00
Doug Hoskisson
82410fd554 Zillion: client win location check (#2682) 2024-01-10 17:52:43 +01:00
JaredWeakStrike
570ba28bee KH2: Fix Terra logic (#2676) 2024-01-10 06:22:04 +01:00
Alchav
b0638b993d FFMQ: Fix starting progressive gear (#2685) 2024-01-09 03:57:38 +01:00
lordlou
89f211f31e SMZ3: 0.4.4 backward compat client fix (#2667)
fixed broken client compatibility with any seed generated before 0.4.4 introduced with the recent change to the message queue.
2024-01-07 13:13:52 +01:00
Fabian Dill
70fdd6b90d Core: clean up MultiServer.py/auto_shutdown (#2552) 2024-01-07 01:42:57 +01:00
Fabian Dill
f22daca74e CommonClient: request datapackage per-game (#2563) 2024-01-07 01:42:16 +01:00
Danaël V
064a7bf01b Docs: Update regarding new launcher (#2627)
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2024-01-04 21:03:39 -06:00
JaredWeakStrike
02a9430ad5 KH2: Update docs and logic sheet to reflect 4.4 (#2610) 2024-01-04 08:58:41 -06:00
JaredWeakStrike
c19afa4f4e KH2: setup guide update to reflect main rando (#2633) 2024-01-04 08:57:40 -06:00
Zach Parks
c593a960f6 WebHost: Fix 500 Server errors relating to player/multi trackers. (#2664)
* WebHost: Fix player tracker issue with items missing from data package.

 Reported in https://discord.com/channels/731205301247803413/1192202112172576819

* WebHost: Fix multi-tracker error when item links are present.

 Reported in https://discord.com/channels/731205301247803413/1192104719959724062

* Use Utils.KeyedDefaultDict instead of checking for key

* formatted revert

* import tweak
2024-01-04 08:29:42 -06:00
Zach Parks
7406a1e512 WebHost: Copyright update time. (#2660) 2024-01-03 18:43:41 -06:00
Aaron Wagener
0df0955415 Core: check if a location is an event before excluding it (#2653)
* Core: check if a location is an event before excluding it

* log a warning

* put the warning in the right spot
2024-01-02 15:03:39 +01:00
Bryce Wilson
bf17582c55 BizHawkClient: Add some handling for non-string errors (#2656) 2024-01-02 11:32:03 +01:00
JaredWeakStrike
e5c739ee31 KH2: Ability dupe fix and stat increase fix (#2621)
Makes the client make sure the player has the correct amount of stat increase instead of letting the goa mod (apcompanion) do it

abilities: checks the slot where abilities could dupe unless that slot is being used for an actual abiliity given to the player
2024-01-02 11:19:57 +01:00
GodlFire
88c7484b3a Shivers: Fixes rule logic for location 'puzzle solved three floor elevator' (#2657)
Fixes rule logic for location 'puzzle solved three floor elevator'. Missing a parenthesis caused only the key requirement to be checked for the blue maze region.
2024-01-02 11:16:45 +01:00
Doug Hoskisson
c104e81145 Zillion: move client to worlds/zillion (#2649) 2024-01-01 13:42:41 -06:00
wildham
3d1be0c468 FF1: Fix terminated_event access_rule not getting set (#2648) 2024-01-01 18:13:35 +01:00
lordlou
e674e37e08 SMZ3: optimized message queues (#2611) 2023-12-28 16:43:16 -06:00
Jarno
d1a17a350d Docs: Add missing Get location_name_groups_* to network protocol (#2550) 2023-12-28 14:41:24 +01:00
Fabian Dill
24ac3de125 Factorio: "improve" default start items (#2588)
Makes it less likely that people kill themselves via pollution and gives them some healing items they may not even know about.
2023-12-28 14:30:10 +01:00
Scipio Wright
901201f675 Noita: Don't allow impossible slot names (#2608)
* Noita: Add note about allowable slot names

* Update character list

* Update init to raise an exception if a yaml has bad characters

* Slightly adjust exception message
2023-12-28 14:21:54 +01:00
t3hf1gm3nt
c7617f92dd TLOZ: Try accounting for non_local_items with the pool of starting weapons (#2620)
It was brought up that if you attempt to non_local any of the starting weapons, there is still a chance for it to get chosen as your starting weapon if you are on a StartingPosition value lower than very_dangerous. This fix will attempt to build the starting weapons list accounting for non_local items, but if all possible weapons have been set to non_local, force one of them to be your starting weapon anyway since the player is still expecting a starting weapon in their world if they have chosen one of the lower StartingPosition values.
2023-12-28 14:17:23 +01:00
NewSoupVi
8e708f829d The Witness: Fix an instance of multiworld.random being used (#2630)
o_o
2023-12-28 14:12:37 +01:00
Fabian Dill
7af654e619 WebHost: validate uploaded datapackage and calculate own checksum (#2639) 2023-12-28 13:57:41 +01:00
TheLynk
af1f6e9113 Oot : Update setup fr (#2394)
* add new translation

* Add translation for OOT Setup in french

* Update setup_fr.md

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/minecraft/docs/minecraft_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/minecraft/docs/minecraft_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/minecraft/docs/minecraft_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/minecraft/docs/minecraft_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/minecraft/docs/minecraft_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/minecraft/docs/minecraft_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/minecraft/docs/minecraft_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/minecraft/docs/minecraft_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update setup_fr.md

Fix treu to true

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Marech <marechal-l@gmx.com>

* Update OOT Init and Update Minecraft Init

* Fix formatting errors

* Fix wrong link in stardew valley randomizer setup guide

Fix wrong link in stardew valley randomizer setup guide

* Add new translation for Adventure and Archipidle in french

Add new translation for Adventure and Archipidle in french

* Add more store in setup page subnautica for more fairness

Add more store in setup page subnautica for more fairness

* tweak update merge #1685 for lua file

tweak update merge #1685 for lua file

* fix text

fix text

* fix wrong translation

fix wrong translation

* Yes it's better

Yes it's better

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

* Update OOT Setup FR

Update OOT Setup FR

* Tweak Text

Tweak Text

---------

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2023-12-28 13:43:42 +01:00
Bryce Wilson
04d194db74 Pokemon Emerald: Change "settings" to "options" in docs (#2517)
* Pokemon Emerald: Change "settings" to "options" in docs

* Pokemon Emerald: Fix two more usages of "setting" instead of "option"

* Pokemon Emerald: Minor rephrase in docs

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>

---------

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
2023-12-28 13:33:30 +01:00
Rosalie-A
70eb2b58f5 [TLOZ] Fix bug with item drops in non-expanded item pool (#2623)
There was a bug in non-expanded item pool where due to the base patch changes to accommodate more items in dungeons, some items were transformed into glitch items that removed bombs (this also happened in expanded item pool, but the item placement would overwrite the results of this bug so it didn't appear as frequently). Being a Zelda game, losing bombs is bad. This PR fixes the base patch process to avoid this bug, by properly carrying the value of a variable through a procedure.
2023-12-28 12:16:38 +01:00
Alchav
576c705106 Pokémon R/B: Badge plando fix (#2628)
Only attempt to place badges in badge locations if they are empty. Return unplaced badges to the item pool if fewer than 8 locations are being filled.
This should fix errors that occur when items are placed into badge locations via plando, or whatever other worlds may do.
2023-12-28 12:15:48 +01:00
lordlou
b99c734954 SM: strict rom validation fix (#2632)
added a more robust ROM tag validation to free oher games to use tag starting with "SM" followed by another letter (SMW, SMZ3, SMRPG, SMMR,...)
2023-12-28 12:14:13 +01:00
Yussur Mustafa Oraji
7c70b87f29 sm64ex: Fix randomizing Courses and Secrets separately (#2637)
Backported from #2569
2023-12-28 08:01:48 +01:00
Trevor L
2512eb7501 Hylics 2: Add missing logic (#2638) 2023-12-28 06:25:41 +01:00
CaitSith2
bb0a0f2aca Factorio: Fix unbeatable seeds where a science pack needs chemical plant (#2613) 2023-12-22 20:02:49 -08:00
Fabian Dill
0d929b81e8 Factorio: fix files from mod base directory not being grabbed correctly in non-apworld (#2603) 2023-12-21 04:26:41 +01:00
Fabian Dill
8842f5d5c7 Core: make update_reachable_regions local variables more wordy (#2522) 2023-12-21 04:11:11 +01:00
Star Rauchenberger
817197c14d Lingo: Tests no longer disable forced good item (#2602)
The static class with the "disable forced good item" field is gone. Now, certain tests that want to check for specific access progression can run a method that removes the forced good item and adds it back to the pool. Tests that don't care about this will collect the forced good item like normal. This should prevent the intermittent fill failures on complex doors unit tests, since the forced good item should provide enough locations to fill in.
2023-12-18 09:46:24 -06:00
Alchav
c8adadb08b Pokémon R/B: Fix Flash learnable logic (#2615) 2023-12-18 09:39:04 -06:00
Zach Parks
a549af8304 Hollow Knight: Add additional DeathLink option and add ExtraPlatforms option. (#2545) 2023-12-17 10:11:40 -06:00
Fabian Dill
4979314825 Webhost: open graph support for /room (#2580)
* WebHost: add Open Graph metadata to /room

* WebHost: Open Graph cleanup
2023-12-17 00:08:40 -05:00
Silvris
f958af4067 Adventure: Fix KeyError on Retrieved (#2560) 2023-12-16 22:22:51 +01:00
Aaron Wagener
7dff09dc1a Options: set old options api before the world is created (#2378) 2023-12-16 22:21:05 +01:00
lordlou
c56cbd0474 SM: item link replacement fix (#2597) 2023-12-16 04:28:54 +01:00
PoryGone
6c4fdc985d SA2B: Fix Weapons Bed - Omochao 2 Logic (#2605) 2023-12-16 04:16:36 +01:00
Alchav
b500cf600c FFMQ: Actually fix the spellbook option (#2594) 2023-12-16 04:16:13 +01:00
Alchav
394633558f ALTTP: Restore allow_excluded (#2607)
Restores allow_excluded to the dungeon fill_restrictive call, which was apparently removed by mistake during merge conflict resolution
2023-12-15 20:39:09 +01:00
Alchav
3e3af385fa Pokémon R/B: client locations import (#2596) 2023-12-13 23:57:14 +01:00
Yussur Mustafa Oraji
ff556bf4cc sm64ex: Fix server (#2599) 2023-12-13 23:46:46 +01:00
Alchav
a3b0476b4b LTTP: Boss rule fix (#2600) 2023-12-13 23:34:36 +01:00
Zach Parks
0eefe9e936 WebHost: Some refactors and additional checks when uploading files. (#2549) 2023-12-12 20:12:16 -06:00
Aaron Wagener
db1d195cb0 Hollow Knight: remove unused option check (#2595) 2023-12-12 20:11:10 -06:00
Bryce Wilson
45fa9a8f9e BizHawkClient: Add SGB to systems using explicit vblank callback (#2593) 2023-12-12 05:48:20 +01:00
Alchav
e9317d4031 FFMQR: Fix Empty Kaeli Companion Event Location and Spellbook option (#2591) 2023-12-12 02:39:38 +01:00
Aaron Wagener
d9d282c925 Tests: test that the datapackage after generation is still valid (#2575) 2023-12-12 02:14:44 +01:00
Fabian Dill
13122ab466 Core: remove start_inventory_from_pool from early_items (#2579) 2023-12-10 20:42:41 +01:00
Fabian Dill
e8f96dabe8 Core: faster prog balance (#2586)
* Core: rename world to multiworld in balance_multiworld_progression

* Core: small optimization to progression balance speed
2023-12-10 20:42:07 +01:00
Fabian Dill
1a05bad612 Core: update modules (#2551) 2023-12-10 20:38:49 +01:00
NewSoupVi
8142564156 The Witness: Fix non-deterministic hints (#2514) 2023-12-10 20:36:55 +01:00
NewSoupVi
e2109dba50 The Witness: Fix Logic Error for Keep Pressure Plates 2 EP in puzzle_randomization: none (#2515) 2023-12-10 20:35:46 +01:00
Yussur Mustafa Oraji
3a09677333 sm64ex: Fix generations (#2583) 2023-12-10 20:31:43 +01:00
Star Rauchenberger
d3b09bde12 Lingo: Fix entrance checking being broken on default settings (#2506)
The most serious issue this PR addresses is that entrances that use doors without items (a small subset of doors when door shuffle is on, but *every* door when door shuffle is off, which is the default) underestimate the requirements needed to use that entrance. The logic would calculate the panels needed to open the door, but would neglect to keep track of the rooms those panels were in, meaning that doors would be considered openable if you had the colors needed to solve a panel that's in a room you have no access to.

Another issue is that, previously, logic would always consider the "ANOTHER TRY" panel accessible for the purposes of the LEVEL 2 panel hunt. This could result in seeds where the player is expected to have exactly the correct number of solves to reach LEVEL 2, but in reality is short by one because ANOTHER TRY itself is not revealed until the panel hunt is complete. This change marks ANOTHER TRY as non-counting, because even though it is technically a counting panel in-game, it can never contribute to the LEVEL 2 panel hunt. This issue could also apply to THE MASTER, since it is the only other counting panel with special access rules, although it is much less likely. This change adds special handling for counting THE MASTER. These issues were possible to manifest whenever the LEVEL 2 panel hunt was enabled, which it is by default.

Smaller logic issues also fixed in this PR:

* The Orange Tower Basement MASTERY panel was marked as requiring the mastery doors to be opened, when it was actually possible to get it without them by using a painting to get into the room.
* The Pilgrim Room painting item was incorrectly being marked as a filler item, despite it being progression.
* There has been another update to the game that adds connections between areas that were previously not connected. These changes were additive, which is why they are not critical.
* The panel stacks in the rhyme room now require both colours on each panel.
2023-12-10 19:15:42 +01:00
Rjosephson
01d0c05259 RoR2: Remove begin with loop (#2518) 2023-12-10 19:12:46 +01:00
Fabian Dill
19b8624818 Factorio: remove staging folder for mod assembly (#2519) 2023-12-10 19:11:57 +01:00
Alchav
1312884fa2 Pokémon R/B: Fix Silph Co 6F Hostage (#2524)
Fixes an issue where the Silph Co 6F hostage check becomes unavailable if Giovanni has been defeated on 11F. This is due to the NPC having separate scripts depending on whether Giovanni was defeated. The code for the check has been moved to before the branch.
2023-12-10 19:10:09 +01:00
lordlou
6cd5abdc11 SMZ3: KeyTH check fix (#2574) 2023-12-10 19:07:56 +01:00
JaredWeakStrike
6b0eb7da79 KH2: RC1 Bug Fixes (#2530)
Changes the finished_game to new variable so now it only checks the game's memory and if it has sent the finished flag before
Fixed ag2 not requiring 1 of each black magic
Fix hitlist if you exclude summon level 7 and have summon levels option turned off
2023-12-10 18:58:52 +01:00
Doug Hoskisson
b0a09f67f4 Core: some typing and documentation in BaseClasses.py (#2589) 2023-12-10 06:43:17 +01:00
Fabian Dill
c3184e7b19 Factorio: fix wrong parent class for FactorioStartItems (#2587) 2023-12-10 00:10:01 -05:00
t3hf1gm3nt
3214cef6cf TLOZ: Fix starting weapon possibly getting overwritten by triforce fragments (#2578)
As discovered by this bug report https://discord.com/channels/731205301247803413/1182522267687731220 it's currently possible to accidentally have the starting weapon of a player overwritten by a triforce fragment if TriforceLocations is set to dungeons and StartingPosition is set to dangerous. This fix makes sure to remove the location of a placed starting weapon if said location is in a dungeon from the pool of possible locations that triforce fragments can be placed in this circumstance.
2023-12-10 04:23:40 +01:00
Alchav
f10431779b ALTTP: Ensure all Hyrule Castle keys are local in Standard (#2582) 2023-12-09 19:33:51 +01:00
JaredWeakStrike
a9a6c72d2c KH2: Fix events in datapackage (#2576) 2023-12-08 22:39:24 +01:00
PoryGone
9351fb45ca SA2B: Fix KeyError on Unexpected Characters in Slot Names (#2571)
There were no safeguards on characters being used as keys into a conversion dict. Now there are.
2023-12-08 07:17:12 +01:00
beauxq
abfc2ddfed Zillion: fix retrieved packet processing 2023-12-07 22:27:46 +01:00
NewSoupVi
bf801a1efe The Witness: Fix Symmetry Island Upper Panel logic (2nd try)
I got lazy and didn't properly test the last fix.

Big apologies, I got a bit panicked with all the logic errors that were being found.
2023-12-07 20:16:22 +01:00
Bryce Wilson
5bd022138b Pokemon Emerald: Fix missing rule for 2 items on Route 120 (#2570)
Two items on Route 120 are on the other side of a pond but were considered accessible in logic without Surf.


Creates a new separate region for these two items and adds a rule for being able to Surf to get to this region. Also adds the items to the existing surf test.
2023-12-07 20:15:38 +01:00
Aaron Wagener
69ae12823a The Messenger: bump required client version (#2544)
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2023-12-07 08:23:05 +01:00
Aaron Wagener
57001ced0f The Messenger: remove old links and update relevant ones (#2542) 2023-12-07 08:22:12 +01:00
NewSoupVi
3fa01a41cd The Witness: Fix unreachable locations on certain settings (Keep PP2 EP, Theater Flowers EP) (#2499)
Basically, the function for "checking entrances both ways" only checked one way. This resulted in unreachable locations.

This affects Expert seeds with (non-remote doors and specific types of EP Shuffle), as well as seeds with non-remote doors + specific types of disabled panels + specific types of EP Shuffle.

Also includes two changes that makes spoiler logs nicer (not creating unnecessary events).
2023-12-07 06:36:46 +01:00
Alchav
87252c14aa FFMQ: Update to FFMQR 1.5 (#2568)
FFMQR was just updated to 1.5, adding a number of new options. This brings these updates to AP.
2023-12-06 18:24:59 +01:00
Fabian Dill
56ac6573f1 WebHost: fix room shutdown (#2554)
Currently when a room shuts down while clients are connected it instantly spins back up. This fixes that behaviour categorically.
I still don't know why or when this problem started, but it's certainly wreaking havok on prod.
2023-12-06 18:24:13 +01:00
Doug Hoskisson
d8004f82ef Zillion: some typing fixes (#2534)
`colorama` has type stubs when it didn't before

`ZillionDeltaPatch.hash` annotated type could be `None` but md5s doesn't allow `None`

type of `CollectionState.prog_items` changed

`WorldTestBase` moved

all of the following are related to this issue:
https://github.com/python/typing/discussions/1486

CommonContext for `command_processor` (is invalid without specifying immutable - but I don't need it anyway)

ZillionWorld options and settings (is invalid without specifying immutable - but I do need it)
2023-12-06 18:23:43 +01:00
NewSoupVi
597f94dc22 The Witness: Add all the Challenge panels to Challenge exclusion list (#2564)
Just a small cleanup where right now, the logic still considers the entirety of the challenge "solvable" except for Challenge Vault Box
2023-12-06 18:22:11 +01:00
Aaron Wagener
49e1fd0b79 The Messenger: ease rule on key of strength a bit (#2541)
Makes the logic for accessing key of strength just a tiny bit easier since a few players said it was really difficult.
2023-12-06 18:20:18 +01:00
Yussur Mustafa Oraji
530617c9a7 sm64ex: Refactor Regions (#2546)
Refactors region code to remove references to course index.
There were bugs somewhere, but I dont know where tbh.
This fixes them but leaves logic otherwise intact, and much cleaner to look at as there's one list less to take care of.

Additionally, this fixes stopping the clock from Big Boos Haunt.
2023-12-06 18:19:03 +01:00
NewSoupVi
229a263131 The Witness: Fix logic error with Symmetry Island Upper in doors: panels (broken seed reported) (#2565)
Door entities think they can be solved without any other panels needing to be solved.

Usually, this is true, because they no longer need to be "powered on" by a previous panel.
However, there are some entities that need another entity to be powered/solved for a different reason.
In this case, Symmetry Island Lower Left set opens the latches that block your ability to solve the panel. The panel itself actually starts on. Playing doors: panels does not change this, unlike usually where dependencies like this get removed by playing that mode.

In the long term, I want to somehow be able to "mark" dependencies as "environmental" or "power based" so I can distinguish them properly.
2023-12-06 18:17:27 +01:00
NewSoupVi
a861ede8b3 The Witness: Fix various incorrect symbol requirements in Vanilla Puzzles (#2543)
* Fix Vanilla First Floor Left

* More vanilla logic fixes

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2023-12-04 16:26:00 +01:00
el-u
b7111eeccc lufia2ac: fix disappearing Ancient key (#2537)
Since the coop update, the Ancient key (which is always the reward for defeating the boss) would disappear when leaving the cave, making it impossible to open the locked door behind the Ancient Cave entrance counter. While this is basically cosmetic and has no adverse effects on the multiworld (as the door does not lead to any multiworld locations and is only accessible after defeating the final boss anyway), players may still want to enter this room as part of a ritual to celebrate their victory.

Why does this happen? The game keeps track of two different inventories, one for outside and another one for the cave dive. When entering or leaving the cave, important things such as blue chest items and Iris treasures are automatically copied to the other inventory. However, it turns out that the Ancient key doesn't participate in this mechanism. Instead, the script that runs when exiting the cave checks whether event flag 0xC3 is set, and if it is on, it calls a script action that adds the key item to the outside inventory. (Whether or not the player actually had the key item in their in-cave inventory is not checked at all; only the flag matters.)

In the unmodified game, that flag is set by the cutscene script that awards the key. It actually sets two event flags, 0xC3 and 0xD1. The latter is used by the game when trying to display the boss in the cafe basement and is used by AP as the indicator that the boss goal was completed. With the coop update, the event script method that created the key was intercepted and modified to send out a location check instead. That location always has the Ancient key as a fixed item placement; the benefit of handling it as a remote item is that in this way the key essentially serves as a signal that transmits the information of the boss' defeat to all clients cooping on the slot. When receiving the key, however, the custom ASM did only set flag 0xD1. As part of the bugfix, it is now changed to set flag 0xC3 as well.

But that alone is still not enough to make it work. The subroutine that is called by the game to create the key when exiting the cave with flag 0xC3 is the same subroutine that gets called in the cutscene that originally tried to award the key. But that's the one that has been rewritten to send the location check instead. So instead of creating the key when leaving the cave, it would just send the same location check again, effectively doing nothing. Therefore, the other part of the bugfix is to only intercept this subroutine if the player is currently on the Ancient Cave Final Floor (where the cutscene takes place), thus making it possible to recreate the key item when exiting.
2023-12-04 00:06:52 +01:00
Star Rauchenberger
39a92e98c6 Lingo: Default color shuffle to on (#2548)
* Lingo: Default color shuffle on

* Raise error if no progression in multiworld
2023-12-04 00:06:11 +01:00
zig-for
a83bf2f616 LADX: Fix bug with Webhost usage (#2556)
We were using data created in init when we never called init
2023-12-03 21:24:35 +01:00
Alchav
e8ceb12281 Pokémon RB: Fix connection names + missing connection (#2553) 2023-12-02 18:40:38 +01:00
Aaron Wagener
6e38126add Webhost: fix options page redirects (#2540) 2023-12-01 14:20:24 -06:00
Fabian Dill
5e5018dd64 WebHost: flash each message only once (#2547) 2023-12-01 21:19:41 +01:00
Aaron Wagener
c7d4c2f63c Docs: Add documentation on writing and running tests (#2348)
* Docs: Add documentation on writing and running tests

* review improvements

* sliver requests
2023-12-01 10:26:27 +01:00
agilbert1412
80fed1c6fb Stardew Valley: Fixed potential softlock with walnut purchases if Entrance Randomizer locks access to the field office (#2261)
* - Added logic rules for reaching, then completing, the field office in order to be allowed to spend significant amounts of walnuts

* - Revert moving a method for some reason
2023-11-30 09:32:32 +01:00
Brooty Johnson
b9ce2052c5 DS3: update setup guide to preserve downpatching instructions (#2531)
* update DS3 setup guide to preserve downpatching instructions

we want to preserve this on the AP site as the future of the speedsouls wiki is unknown and may disappear at any time.

* Update worlds/dark_souls_3/docs/setup_en.md

Co-authored-by: Danaël V. <104455676+ReverM@users.noreply.github.com>

* Update setup_en.md

---------

Co-authored-by: Danaël V. <104455676+ReverM@users.noreply.github.com>
2023-11-30 09:29:55 +01:00
Chris Wilson
a83501a2a0 Fix a bug in weighted-settings causing accepted range values to be exclusive of outer range (#2535) 2023-11-29 22:57:40 -05:00
t3hf1gm3nt
6c5f8250fb TLOZ: Use the proper location name lookup (#2529) 2023-11-29 00:19:42 -06:00
el-u
39969abd6a WebHostLib: fix NamedRange in options presets (#2528) 2023-11-28 17:11:17 -06:00
Bryce Wilson
737686a88d BizHawkClient: Use local_path when autolaunching BizHawk with lua script (#2526)
* BizHawkClient: Change autolaunch path to lua script to use local_path

* BizHawkClient: Remove unnecessary call to os.path.join and linting
2023-11-28 22:56:27 +01:00
Bryce Wilson
ce2f9312ca BizHawkClient: Change open_connection to use 127.0.0.1 instead of localhost (#2525)
When using localhost on mac, both ipv4 and ipv6 are tried and raise separate errors
which are combined by asyncio and difficult/inelegant to handle.

Python 3.12 adds the argument all_errors, which would make this easier.
2023-11-28 22:50:12 +01:00
Alchav
f54f8622bb Final Fantasy Mystic Quest: Implement new game (#1909)
FFMQR by @wildham0 
Uses an API created by wildham for Map Shuffle, Crest Shuffle and Battlefield Reward Shuffle, using a similar method of obtaining data from an external website to Super Metroid's Varia Preset option.
Generates a .apmq file which the user must bring to the FFMQR website https://www.ffmqrando.net/Archipelago to patch their rom. It is not an actual patch file but contains item placement and options data for the FFMQR website to generate a patched rom with for AP.
Some of the AP options may seem unusual, using Choice instead of Range where it may seem more appropriate, but these are options that are passed to FFMQR and I can only be as flexible as it is.

@wildham0 deserves the bulk of the credit for not only creating FFMQR in the first place but all the ASM work on the rom needed to make this possible, work on FFMQR to allow patching with the .apmq files, and creating the API that meant I did not have to recreate his map shuffle from scratch.
2023-11-26 17:17:59 +01:00
Justus Lind
65f47be511 Muse Dash: Presets and Song Updates (#2512) 2023-11-25 22:13:59 -06:00
Bryce Wilson
eec35ab1c3 Pokemon Emerald: Fix tracker flags being reset in menus (#2511) 2023-11-25 22:13:08 -06:00
PoryGone
7a46209259 SA2B: Add AP 0.4.4 Game Chao Names (#2510) 2023-11-25 22:12:38 -06:00
Aaron Wagener
cfe357eb71 The Messenger, LADX: use collect and remove as intended (#2093)
Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
2023-11-25 15:07:02 -06:00
Aaron Wagener
fe6a70a1de Docs: add documentation for options comparison (#2505) 2023-11-25 10:48:13 -06:00
Bryce Wilson
6718fa4e3b Installer: Add _bizhawk.apworld to installer deleted files (#2477) 2023-11-25 09:38:12 -06:00
Bryce Wilson
5475b04b90 Pokemon Emerald: Bump apworld version number (#2504) 2023-11-25 09:27:54 -06:00
Dinopony
d46e68cb5f Landstalker: implement new game (#1808)
Co-authored-by: Anthony Demarcy <anthony.demarcy@lumiplan.com>
Co-authored-by: Phar <zach@alliware.com>
2023-11-25 09:00:15 -06:00
JaredWeakStrike
2ccf11f3d7 KH2: Version 2 (#2009)
Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
Co-authored-by: Joe Prochaska <prochaska.joseph@gmail.com>
2023-11-25 08:46:00 -06:00
David St-Louis
c138918400 DOOM 1993: Added various new options (#2067) 2023-11-25 08:43:14 -06:00
Fabian Dill
59ed2602bd Pokemon: delete old files (#2501) 2023-11-25 15:42:03 +01:00
Brooty Johnson
dd47790c31 DS3: Added 'Early Banner' Setting (#2199)
Co-authored-by: Zach Parks <zach@alliware.com>
2023-11-25 08:38:18 -06:00
David St-Louis
9afca87045 Heretic: implement new game (#2256) 2023-11-25 15:22:30 +01:00
el-u
ba53278147 core: make option resolution in world tests deterministic (#2471)
Co-authored-by: Zach Parks <zach@alliware.com>
2023-11-25 13:53:02 +01:00
Star Rauchenberger
6dccf36f88 Lingo: Various generation optimizations (#2479)
Almost all of the events have been eradicated, which significantly improves both generation speed and playthrough calculation.

Previously, checking for access to a location involved checking for access to each panel in the location, as well as recursively checking for access to any panels required by those panels. This potentially performed the same check multiple times. The access requirements for locations are now calculated and flattened in generate_early, so that the access function can directly check for the required rooms, doors, and colors.

These flattened access requirements are also used for Entrance checking, and register_indirect_condition is used to make sure that can_reach(Region) is safe to use.

The Mastery and Level 2 rules now just run a bunch of access rules and count the number of them that succeed, instead of relying on event items.

Finally: the Level 2 panel hunt is now enabled even when Level 2 is not the victory condition, as I feel that generation is fast enough now for that to be acceptable.
2023-11-25 13:09:08 +01:00
Alchav
8a852abdc4 Pokémon R/B: Migrate support into Bizhawk Client (#2466)
- Removes the Pokémon Client, adding support for Red and Blue to the Bizhawk Client.
- Adds `/bank` commands that mirror SDV's, allowing transferring money into and out of the EnergyLink storage.
- Adds a fix to the base patch so that the progressive card key counter will not increment beyond 10, which would lead to receiving glitch items. This value is checked against and verified that it is not > 10 as part of crash detection by the client, to prevent erroneous location checks when the game crashes, so this is relevant to the new client (although shouldn't happen unless you're using !getitem, or putting progressive card keys as item link replacement items)
2023-11-25 11:57:02 +01:00
Fabian Dill
edb62004ef LttP: remove extra default = False (#2497)
* LttP: remove extra default = False
2023-11-25 11:12:13 +01:00
GodlFire
8d41430cc8 Shivers: Implement New Game (#1836)
Co-authored-by: Mathx2 <Mathx2@gmail.com>
Co-authored-by: Zach Parks <zach@alliware.com>
2023-11-24 17:23:45 -06:00
Zach Parks
8173fd54e7 DOOM II: Add to CODEOWNERS (#2492) 2023-11-24 17:16:19 -06:00
Zach Parks
e46420f4a9 MultiServer: Create read-only data storage key for client statuses. (#2412) 2023-11-24 17:14:07 -06:00
el-u
c944ecf628 Core: Introduce new Option class NamedRange (#2330)
Co-authored-by: Chris Wilson <chris@legendserver.info>
Co-authored-by: Zach Parks <zach@alliware.com>
2023-11-24 17:10:52 -06:00
Chris Wilson
e64c7b1cbb Fix player-options and weighted-options failing to validate settings if a payer's name is entirely numeric (#2496) 2023-11-24 16:50:32 -05:00
NewSoupVi
15797175c7 The Witness: New junk hints (#2495) 2023-11-24 13:38:46 -06:00
digiholic
4641456ba2 MMBN3: Small Bug Fixes (#2282)
Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
Co-authored-by: Zach Parks <zach@alliware.com>
2023-11-24 11:14:05 -06:00
Star Rauchenberger
a18fb0a14f Lingo: Move datafiles into a subdirectory (#2459) 2023-11-24 18:11:34 +01:00
David St-Louis
c5b0330223 DOOM II: implement new game (#2255) 2023-11-24 18:08:02 +01:00
Ishigh1
530e792c3c Core: Floor and ceil in datastorage (#2448) 2023-11-24 10:42:22 -06:00
Fabian Dill
d892622ab1 Plando: verify from_pool type (#2200) 2023-11-24 10:41:56 -06:00
Exempt-Medic
a8e03420ec Fill: Fix plando removing Usefuls first (#2445)
Co-authored-by: blastron <blastron@mac.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2023-11-24 10:33:59 -06:00
Star Rauchenberger
1ff8ed396b Lingo: Demote warpless painting items to filler (#2481) 2023-11-24 10:30:15 -06:00
NewSoupVi
e93842a52c The Witness: Big™ new™ content update™ (#2114)
Co-authored-by: blastron <blastron@mac.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2023-11-23 23:27:03 -06:00
el-u
205c6acb49 lufia2ac: fix client behavior at max blue chests combined with party member or capsule monster shuffle (#2478)
When option combinations at (or near) the maximum location count were used, the client could trip over a wrongly coded limit and stop sending checks.
2023-11-24 01:59:41 +01:00
Aaron Wagener
2f6b6838cd The Messenger: more optimizations (#2451)
More speed optimizations for The Messenger. Moves Figurines into their own region, so their complicated access rule only needs to be calculated once when doing a sweep. Removes a redundant loop for shop locations by just directly assigning the access rule in the class instead of retroactively. Reduces slot_data to only information that can't be derived, and removes some additional extraneous data. Removes some unused sets and lists. Removes a redundant event location, and increments the required_client_version to prevent clients that don't expect the new slot_data. Drops data version since it's going away soon anyways, to remove conflicts.
2023-11-24 00:38:57 +01:00
Fabian Dill
844481a002 Core: remove duplicate state.item_count (#2463) 2023-11-24 00:35:37 +01:00
Zach Parks
5d9896773d Generate: Add --skip_output flag to bypass assertion and output stages. (#2416) 2023-11-23 16:03:56 -06:00
JaredWeakStrike
9312ad9bfe KH2: Fix grammar to clarify which locations can have a bounty (#2488) 2023-11-23 16:02:20 -06:00
Fabian Dill
cb6467cfe6 Core: update modules, move orjson to core (#2489) 2023-11-23 21:36:20 +01:00
Fabian Dill
28ed786609 LttP: fix Ganons Tower - Compass Room - Bottom Left being listed twice in Ganons Tower location group and add missing Ganons Tower - Compass Room - Bottom Right (#2490) 2023-11-23 21:36:05 +01:00
Bryce Wilson
7efec64745 BizHawkClient: Restore use of ConnectorErrors (#2480) 2023-11-23 20:51:53 +01:00
Alchav
f840ed3a94 Pokémon R/B: Fix trainer regions (#2474)
* Fix Mt Moon B2F trainer regions

* Fix Trainer Party regions
2023-11-23 19:17:09 +01:00
Yussur Mustafa Oraji
286dfd84c0 sm64ex: Replace old launcher tutorial (#2383) 2023-11-23 12:10:32 -06:00
JaredWeakStrike
a1759ed7e1 KH2: Update Game Docs (#2188)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
2023-11-23 12:06:57 -06:00
Star Rauchenberger
ae8a81c0cb Lingo: Change docs to link to the client in the Steam Workshop (#2486) 2023-11-23 11:56:55 -06:00
Bryce Wilson
a7aed71fbe Pokemon Emerald: Fix opponent trainer moves sometimes being MOVE_NONE (#2487) 2023-11-23 11:55:50 -06:00
Bryce Wilson
0d38b41540 BizHawkClient: Add support for multiple concurrent instances (#2475)
This allows multiple client/connector pairs to run at the same time. It also includes a few other miscellaneous small changes that accumulated as I went. They can be split if desired

- Whatever the `client_socket:send` line (~440) was doing with that missing operator, it's no longer doing. Don't ask me how it was working before. Lua is witchcraft.
- Removed the `settimeout(2)` which causes the infamous emulator freeze (and replaced it with a `settimeout(0)` when the server socket is created). It appears to be unnecessary to set a timeout for discovering a client. Maybe at some point in time it was useful to keep the success rate for connecting high, but it seems to not be a problem if the timeout is 0 instead.
  - Also updated the Emerald setup to remove mention of the freezing.
- Connector script now picks the first port that's not in use in a range of 5 ports.
  - To summarize why I was previously under the impression that multiple running scripts would not detect when a port was in use:
    1. Calling `socket.bind` in the existing script will first create an ipv6 socket.
    2. A second concurrent script trying to bind to the same port would I think fail to create an ipv6 socket but then succeed in creating an ipv4 socket on the same port.
    3. That second socket could never communicate with a client; extra clients would just bounce off the first script.
    4. The third concurrent script will then fail on both and actually give an `address already in use` error.  
  - I'm not _really_ sure what's going on there. But forcing one or the other by calling `socket.tcp4()` or `socket.tcp6()` means that only one script will believe it has the port while any others will give `address already in use` as you'd expect.
  - As a side note, our `socket.lua` is much wonkier than I had previously thought. I understand some parts were added for LADX and when BizHawk 2.9 came out, but as far back as the file's history in this repo, it has provided a strange, modified interface as compared to the file it was originally derived from, to no benefit as far as I can tell.
- The connector script closes `server` once it finds a client and opens a new one if the connection drops. I'm not sure this ultimately has an effect, but it seems more proper.
- If the connector script's main function returns because of some error or refusal to proceed, the script no longer tries to resume the coroutine it was part of, which would flood the log with irrelevant errors.
- Creating `SyncError`s in `guarded_read` and `guarded_write` would raise its own error because the wrong variable was being used in its message.
- A call to `_bizhawk.connect` can take a while as the client tries the possible ports. There's a modification that will wait on either the `connect` or the exit event. And if the exit event fires while still looking for a connector script, this cancels the `connect` so the window can close.
  - Related: It takes 2-3 seconds for a call to `asyncio.open_connection` to come back with any sort of response on my machine, which can be significant now that we're trying multiple ports in sequence. I guess it could fire off 5 tasks at once. Might cause some weirdness if there exist multiple scripts and multiple clients looking for each other at the same time.
  - Also related: The first time a client attempts to connect to a script, they accept each other and start communicating as expected. The second client to try that port seems to believe it connects and will then time out on the first message. And then all subsequent attempts to connect to that port by any client will be refused (as expected) until the script shuts down or restarts. I haven't been able to explain this behavior. It adds more time to a client's search for a script, but doesn't ultimately cause problems.
2023-11-23 15:00:46 +01:00
Bryce Wilson
b2e7ce2c36 Pokemon Emerald: Fix using wrong key for extracted constant (#2484) 2023-11-22 12:21:15 -06:00
Remy Jette
af0d47b444 Core: Provide a better error message if only weights.yaml is provided with players: 0 (#2227) 2023-11-22 11:13:02 -06:00
Zach Parks
ee76cce1a3 Rogue Legacy: Fix a preset including an option that prevents generation. (#2473) 2023-11-22 10:42:21 -06:00
agilbert1412
0f98cf525f Stardew Valley: Generate proper filler for item links (#2069)
Co-authored-by: Zach Parks <zach@alliware.com>
2023-11-22 10:04:33 -06:00
Zach Parks
cfd2e9c47f Core: Increment Archipelago Version (#2483) 2023-11-22 10:04:10 -06:00
digiholic
4a9d075b77 MMBN3: Adds instructions for using the Legacy Collection ROM for setup (#2120)
Co-authored-by: Silvris <58583688+Silvris@users.noreply.github.com>
2023-11-22 09:45:32 -06:00
Rjosephson
79406faf27 RoR2: 1.3.0 content update (#2425) 2023-11-22 09:20:32 -06:00
zig-for
01b566b798 LADX: Text shuffle (#2051) 2023-11-22 08:29:33 -06:00
Jarno
d1b22935b4 Timespinner: New options from TS Rando v1.25 + Logic fix (#2090)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-11-22 08:17:33 -06:00
agilbert1412
3b357315ee Git: Added file type .smc to gitignore (#2476) 2023-11-22 08:15:35 -06:00
BadMagic100
f959819801 Hollow Knight: Don't force mimics local (#2482) 2023-11-22 08:15:09 -06:00
agilbert1412
e916b0d6b0 Stardew Valley: Add Options presets (#2470) 2023-11-18 12:35:57 -06:00
Zach Parks
790f192ded WebHost: Refactor tracker.py, removal of dead code, and tweaks to layouts of some tracker pages. (#2438) 2023-11-18 12:29:35 -06:00
Aaron Wagener
185a519248 Core: fix item links around core changes (#2452) 2023-11-16 04:55:18 -06:00
Zach Parks
79ad54623b WebHost, Core: Developer-defined game option presets. (#2143) 2023-11-16 04:37:06 -06:00
Bryce Wilson
3619abc7ca Pokemon Emerald: Fix scorched slab missing surf requirement (#2465) 2023-11-16 04:36:38 -06:00
FlySniper
cb0412e011 Wargroove: Fixed WargrooveClient retaining victory and location information and minor doc fix (#2464) 2023-11-16 04:35:20 -06:00
Justus Lind
e66ce6c05f Muse Dash: Rename some Trap Items to match the wider community name (#2180) 2023-11-16 04:33:56 -06:00
Star Rauchenberger
a4b625c3e3 Lingo: Sync config with game update (#2447) 2023-11-16 04:12:44 -06:00
PoryGone
85d02b2dc5 SA2B: v2.3 - The Chao Update (#2277)
Changelog:

Features:
- New goal
  - Chaos Chao
    - Raise a Chaos Chao to win!
- New optional Location Checks
  - Chao Animal Parts
    - Each body part from each type of animal is a location
  - Chao Stats
    - 0-99 levels of each of the 7 Chao stats can be locations
    - The frequency of Chao Stat locations can be set (every level, every 2nd level, etc)
  - Kindergartensanity
    - Classroom lessons are locations
      - Either all lessons or any one of each category can be set as locations
  - Shopsanity
    - A specified number of locations can be placed in the Chao Black Market
    - These locations are unlocked by acquiring `Chao Coin`s
    - Ring costs for these items can be adjusted 
  - Chao Karate can now be set to one location per fight, instead of one per tournament
- Items
  - If any Chao locations are active, the following will be in the item pool:
    - Chao Eggs
    - Garden Seeds
    - Garden Fruit
    - Chao Hats
    - Chaos Drives
- The starting eggs in the garden can be a random color
- Chao World entrances can be shuffled
- Chao are given default names
- New Traps
  - Reverse Trap

Quality of Life:
- Chao Save Data is now separate per-slot in addition to per-seed
  - This allows a single player to have multiple slots in the same seed, each having separate Chao progress
- Chao Race/Karate progress is now displayed on Stage Select (when hovering over Chao World)
- All Chao can now enter the Hero and Dark races
- Chao Karate difficulty can be set separately from Chao Race difficulty
- Chao Aging can be sped up at will, up to 15×
- New mod `config` option to fine-tune Chao Stat multiplication
  - Note: This does not mix well with the Mod Manager "`Chao Stat Multiplier`" code
- Pong Traps can now activate in Chao World
- Maximum range for possible number of Emblems is now 1000
- General APWorld cleanup and optimization
  - Option access has moved to the new options system
  - An item group now exists for trap items

Bug Fixes:
- Dry Lagoon now has all 11 Animals
- Eternal Engine - 2 (Standard and Hard Logic) now requires only `Tails - Booster`
- Lost Colony - 2 (Hard Logic) now requires no upgrades
- Lost Colony - Animal 9 (Hard Logic) now requires either `Eggman - Jet Engine` or `Eggman - Large Cannon`
2023-11-16 08:08:38 +01:00
Fabian Dill
829c664304 Core: check that location address is unique per player (#2429) 2023-11-15 20:50:00 +01:00
Danaël V
28a20391ab Docs: Rework of Contributing.md (#2278)
* Update contributing.md

* Update docs/contributing.md

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

* Update contributing.md

Separated the sentence specifically for web stuff as well as slight rephrasing of the first bullet point

* Update docs/contributing.md

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

* Update docs/contributing.md

Changed the order of two words

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* Update docs/contributing.md

Clarified "this document"

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2023-11-15 19:18:57 +01:00
Aaron Wagener
bf8432faa7 Docs: minor updates to recommend modern PEP8 (#2384)
* docs: update world api for modern PEP8 conventions

* docs: update options api for modern PEP8 styling

* missed a spot
2023-11-15 17:07:42 +01:00
el-u
2af5410301 core: fix item/location descriptions test (#2450) 2023-11-15 07:26:10 +01:00
Trevor L
41b6aef23c Hylics 2: Unique entrance names, fix APWorld on 3.8 (#2460)
* Blasphemous: Set rules for events later

* Blasphemous: More misc logic fixes

* Update worlds/blasphemous/Rules.py

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

* Update worlds/blasphemous/Rules.py

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

* Blasphemous: Some cleanup

* Hylics 2: Unique entrance names, fix APWorld on 3.8

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2023-11-15 07:03:40 +01:00
Fabian Dill
8ce073e355 Core: relax typing hints on has_all and has_any (#2462) 2023-11-15 06:53:37 +01:00
Fabian Dill
287a186ff6 CommonClient: make IncompatibleVersion more explicit. (#2350) 2023-11-15 06:13:41 +01:00
FaultBat
44a9bb59ec Factorio: Update icons to match vanilla style (#2449) 2023-11-15 03:15:37 +01:00
el-u
6c1ae77db4 lufia2ac: improve performance of access rules (#2456)
Modifies various access rules in the lufia2ac world with the aim of making them evaluate quicker.
Instead of having to determine the reachability of another location, they now only have to count items in state, which is faster.
(Also made it reuse the identical lambda for multiple locations, which might save a smidgen of memory.)
2023-11-15 03:11:02 +01:00
black-sliver
0239578a62 Setup: fix orjson import on frozen py3.8 (#2458)
orjson has a .py entry point that imports `from .orjson` (from the DLL), which does not work on 3.8 if the DLL is not in the same folder as the .py. The changed import system in 3.9+ seems to allow this. Excluding it from libraries.zip will put both files into the same folder.
2023-11-13 22:42:34 +01:00
Fabian Dill
81cc016267 LttP: write fairy bottle fill to spoiler and prevent fart in a bottle (#2424) 2023-11-13 06:50:45 +01:00
Fabian Dill
f63743f9a9 Core: limit perf logger to 4 post-point places (#2404) 2023-11-13 06:49:31 +01:00
Fabian Dill
b3a9d58e02 Core: update modules (#2440) 2023-11-13 06:48:50 +01:00
Fabian Dill
ef7d8a6b4f Core: limit parallel APContainer writing (#2443)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-11-13 06:46:40 +01:00
Star Rauchenberger
cc0ea6a9e9 Lingo: Made entrance names unique (#2454) 2023-11-12 19:22:05 -06:00
Remy Jette
4d711a0aa5 Installer: Fix invalid component error in inno_setup.iss (#2455) 2023-11-12 19:21:17 -06:00
Bryce Wilson
43041f7292 Pokemon Emerald: Implement New Game (#1813) 2023-11-12 15:39:34 -06:00
black-sliver
e670ca513b Fill: fix swap error found in CI (#2397)
* Fill: add test for swap error with item rules

https://discord.com/channels/731205301247803413/731214280439103580/1167195750082560121

* Fill: fix swap error found in CI

Swap now assumes the unplaced items can be placed before the to-be-swapped item.
Unsure if that is safe or unsafe.

* Test: clarify docstring and comments in fill swap test

* Test: clarify comments in fill swap test more
2023-11-11 10:54:51 +01:00
Remy Jette
df1e78c6f2 WebHost: Sort tracker last activity 'None' as maximum instead of -1 (#2446)
When managing an async, it can be useful to sort the tracker by Last
Activity to see who has potentially abandoned their slots. Today, if a
slot hasn't been started (last activity is None) then it is sorted as
if last activity is -1, that it is it has had more recent activity than
any other slot.

This change makes it so slots that haven't started are treated as if
they have last activity MAX_VALUE time ago. This way they get sorted
with slots that haven't been touched in a long time which should make
intuitive sense as the "last activity" is effectively inf time ago.
2023-11-11 01:13:32 -05:00
Natalie Weizenbaum
2dd904e758 Allow worlds to provide item and location descriptions (#2409)
These are displayed in the weighted options page as hoverable tooltips.
2023-11-11 01:06:54 -05:00
Aaron Wagener
64159a6d0f The Messenger: fix logic rule for spike darts and power seal hunt (#2414) 2023-11-11 05:49:55 +01:00
Fabian Dill
ac77666f2f Factorio: skip a bunch of file IO (#2444)
In a lot of cases, Factorio would write data to file first, then attach that file into zip. It now directly attaches the data to the zip and encapsulation was used to allow earlier GC in places (rendered templates especially).
2023-11-10 22:02:34 +01:00
Star Rauchenberger
7af7ef2dc7 Lingo: Removed "Reached" event items (#2442) 2023-11-10 13:19:05 -06:00
Star Rauchenberger
f444d570d3 Lingo: Fix edge case painting shuffle accessibility issues (#2441)
* Lingo: Fix painting shuffle logic issue in The Wise

* Lingo: More generic painting cycle prevention

* Lingo: okay how about now

* Lingo: Consider Owl Hallway blocked painting areas in vanilla doors

* Lingo: so honestly I should've seen this one coming

* Lingo: Refined req_blocked for vanilla doors

* Lingo: Orange Tower Basement is also owl-blocked

* Lingo: Rewrite randomize_paintings to eliminate rerolls

Now, mapping is done in two phases, rather than assigning everything at once and then rerolling if the mapping is non-viable.
2023-11-10 13:07:56 -06:00
NewSoupVi
b5bd95771d Raft: Use world.random instead of global random (#2439) 2023-11-09 08:47:36 +01:00
Star Rauchenberger
ea9c31392d Lingo: New game (#1806)
Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: Phar <zach@alliware.com>
2023-11-08 17:35:12 -06:00
Ziktofel
154e17f4ff SC2: 0.4.3 bugfixes (#2273)
Co-authored-by: Matthew <matthew.marinets@gmail.com>
2023-11-08 12:00:55 -06:00
Mewlif
504d09daf6 Undertale: Logic fixes (#2436) 2023-11-08 11:50:29 -06:00
Aaron Wagener
03e1c45d71 Tests: log the seed fo slot_data failures (#2402) 2023-11-08 09:15:06 +01:00
Silvris
ced35c5b78 CommonClient: Add a hints tab (#2392)
Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
2023-11-07 14:51:35 -06:00
Nicholas Saylor
779a312650 Docs, Undertale: Added Suggestions Missed in #2285 (#2435)
Co-authored-by: jonloveslegos <68133186+jonloveslegos@users.noreply.github.com>
Co-authored-by: kindasneaki <ryandj67@hotmail.com>
Co-authored-by: ScootyPuffJr1 <77215594+scootypuffjr1@users.noreply.github.com>
2023-11-07 14:41:13 -06:00
Fabian Dill
72cb8b7d60 Factorio: inflate location pool (#2422) 2023-11-07 21:02:28 +01:00
TheLynk
5a7d69c8b4 ChecksFinder: Tweak link in ChecksFinder (#2353)
Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>
Co-authored-by: Marech <marechal-l@gmx.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-11-07 11:31:06 -06:00
NewSoupVi
c984b48149 The Witness: Fix Town Tower 4th Door Logic (#2421) 2023-11-07 07:39:36 +01:00
axe-y
84fb2f58fa DLC Quest Stardew: bug (#2423) 2023-11-06 06:01:49 +01:00
Fabian Dill
e1f1bf83c2 Core: Running item Plando dot (#2405) 2023-11-05 00:15:39 -05:00
black-sliver
d2e9bfb196 AppImage: allow loading apworlds from ~/Archipelago and copy scripts (#2358)
also fixes some mypy and flake8 violations in worlds/__init__.py
2023-11-04 10:26:51 +01:00
black-sliver
880326c9a5 SM: fix missed SMWorld.spheres in #2400 (#2419) 2023-11-02 21:08:36 +01:00
espeon65536
ec70cfc798 OoT: fix incorrect calls to sweep_for_events (#2417) 2023-11-02 20:02:38 +01:00
Aaron Wagener
5669579374 Core: make state.prog_items a Dict[int, Counter[str]] (#2407) 2023-11-02 06:41:20 +01:00
espeon65536
19dc0720ba OoT: fix enhanced_map_compass generation failure (#2411) 2023-11-02 06:39:29 +01:00
dennisw100
f701b81308 Docs: Terraria Setup Guide added information about the Upgraded Research Mod (#2338)
Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
Co-authored-by: Seldom <38388947+Seldom-SE@users.noreply.github.com>
2023-11-01 16:08:04 -05:00
kindasneaki
d7ec722aba RoR2: update options (#2391) 2023-10-31 22:34:24 +01:00
Natalie Weizenbaum
dc80f59165 WebHost: Expose name groups through the weighted-settings UI (#2327)
* Factor out a common function for building lists

* Expose name groups through the weighted-settings UI

* Fix weighted-settings page

The request for the JSON file that provides the setting data was missed during the rename in #2037, so prior to this the weighted settings page wasn't rendering at all.
2023-10-31 17:25:07 -04:00
Natalie Weizenbaum
5726d2f962 Fix weighted-settings page (#2408)
The request for the JSON file that provides the setting data was missed during the rename in #2037, so prior to this the weighted settings page wasn't rendering at all.
2023-10-31 17:22:02 -04:00
Nicholas Saylor
560c57fedd Docs, Various Games: Add Unique Local Commands to Game Page (#2285)
* Add Unique Locals Commands to ChecksFinder

* Add Unique Locals Commands to MMBN3 Game Page

* Add Unique Locals Commands to Ocarina of Time Game Page

* Add Unique Locals Commands to Undertale Game Page

* Add Unique Locals Commands to Wargroove Game Page

* Add Unique Locals Commands to The Legend of Zelda Game Page

* Add Unique Locals Commands to Zillion Game Page

* Amend Unique Locals Commands on Final Fantasy 1 Game Page

* Add Unique Locals Commands to Pokemon R/B Game Page

* Grammar fix for FF1

* Corrected sections names to match

* Added commands to Starcraft 2 Wings of Liberty game page

Co-authored-by: Bicoloursnake <60069210+bicoloursnake@users.noreply.github.com>

---------

Co-authored-by: Bicoloursnake <60069210+bicoloursnake@users.noreply.github.com>
2023-10-31 17:20:24 -04:00
Remy Jette
3bff20a3cf WebHost: Round percentage of checks, fix possible 500 error (#2270)
* WebHost: Round percentage of checks, fix possible 500 error

* Round using str.format in the template

How the percentage of checks done should be displayed is a display
concern, so it makes sense to just always do it in the template. That
way, along with using .format() instead of .round, means we always get
exactly the same presentation regardless of whether it ends in .00
(which would not round to two decimal places), is an int (which
`round(2)` wouldn't touch at all), etc.

* Round percent_total_checks_done in lttp multitracker

* Fix non-LttP games showing as 0% done in LttP MultiTracker
2023-10-31 17:20:07 -04:00
Silvris
d2c541c51c SNIClient, ALttP: expose death_text to SNI client, add message to alttp (#1793) 2023-10-31 11:11:18 +01:00
black-sliver
5f5c48e17b Core: fix some memory leak sources without removing caching (#2400)
* Core: fix some memory leak sources

* Core: run gc before detecting memory leaks

* Core: restore caching in BaseClasses.MultiWorld

* SM: move spheres cache to MultiWorld._sm_spheres to avoid memory leak

* Test: add tests for world memory leaks

* Test: limit WorldTestBase leak-check to py>=3.11

---------

Co-authored-by: Fabian Dill <fabian.dill@web.de>
2023-10-31 02:08:56 +01:00
Aaron Wagener
d4498948f2 Core: return the created entrance when connecting regions (#2406) 2023-10-30 21:14:14 +01:00
Alchav
aa56383310 Pokémon R/B: Fix incompatible option combination (#2356) 2023-10-30 21:13:02 +01:00
Fabian Dill
d743d10b2c Core: log completion time if > 1.0 seconds per step (#2345) 2023-10-30 04:06:40 +01:00
espeon65536
db978aa48a OoT Time Optimization (#2401)
- Entrance randomizer no longer grows with multiworld
- Improved ER success rate again by prioritizing Temple of Time even more
- Prefill is faster, has slightly reduced failure rate when map/compass are in dungeon but previous items in any_dungeon (which consumed all available locations), no longer removes items from the main itempool; itemlinked prefill items removed to accomodate improvements
- Now triggers only one recache after `generate_basic` instead of one per oot world
- Avoids recaches during `create_regions`
- All ER temp entrances have unique names (so the entrance cache does not break)
2023-10-30 04:05:49 +01:00
Fabian Dill
f81e72686a Core: log fill progress (#2382)
* Core: log fill progress

* Add names to common fill steps

* Update Fill.py

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>

* cleanup default name

---------

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
2023-10-30 01:22:00 +01:00
Justus Lind
d5745d4051 Muse Dash: Adds the new songs in the Happy Otaku Pack Vol.18 update. (#2398) 2023-10-30 01:21:29 +01:00
Scipio Wright
36f95b0683 Noita: Fix rare item fill failure for single-player games (#2387) 2023-10-29 20:02:53 +01:00
Fabian Dill
9c80a7c4ec HK: skip for loop (#2390) 2023-10-29 19:53:57 +01:00
Fabian Dill
3e0d1d4e1c Core: change Region caching to on_change from on-miss-strategy (#2366) 2023-10-29 19:47:37 +01:00
black-sliver
d9b076a687 Stardew Valley: simplify in-place (#2393)
this allows skipping multiple simplifications of the same object, e.g. item_rules
also update the logic simplification tests to be a proper unittest.TestCase
2023-10-29 13:20:28 +01:00
Alchav
ff65de1464 Pokemon R/B: Reenable Rock tunnel location access rules (#2396) 2023-10-28 17:32:03 -05:00
Scipio Wright
b874febb1e Noita: Extra Life change (#2247)
* Item rate update, also removed unnecessary reverse region connections

* Converted sets into lists, removed empties
2023-10-28 22:27:57 +02:00
Bryce Wilson
acfc71b8c9 BizHawkClient: Add support for server passwords (#2306) 2023-10-28 21:48:31 +02:00
Trevor L
e8a7200740 Blasphemous: Include ranged attack in logic for all difficulties (#2271) 2023-10-28 21:47:14 +02:00
Yussur Mustafa Oraji
253f3e61f7 sm64ex: All Bowser Stages Goal (#2112) 2023-10-28 21:44:16 +02:00
el-u
2353346768 minecraft: avoid duplicate prefix in output file name (#2048) 2023-10-28 21:43:09 +02:00
t3hf1gm3nt
4b95065c47 TLOZ: Update setup doc to include what version of TLOZ is required (#2395) 2023-10-28 13:49:07 -05:00
eudaimonistic
f5e9fc9b34 Docs, WebHost: Update faq_en.md (#2313)
* Update faq_en.md

Reorganizing information and adding links to some of the various guides and website pages.  Even just adding the Getting Started, Supported Games, and Server Commands links seems like a hefty upgrade.  We have good resources, we should make them obvious.

I think more can probably be done here, but I already shuffled this around a lot.

* Reorganize information again, elaborate single player

Sneaki's suggestion makes way more sense organizationally.  Added more detail to the single player section to more clearly explain the easiest method.

* Usage of multi-world

Consistency

Co-authored-by: kindasneaki <ryandj67@hotmail.com>

* More multi-world

More consistency

Co-authored-by: kindasneaki <ryandj67@hotmail.com>

* Revert to multiworld

Makes more sense and is colloquially the preferred terminology.

* Rework "leaving early"

Changed the "What if a player needs to leave early" section into, "Does everyone need to be connected at the same time?"

This allows the FAQ to explain briefly what a sync multiworld and an async multiworld is.  This is probably good material for the Glossary, but it comes up so much in the Discord that we probably need to explain it here as briefly as possible.  This paragraph lends itself to the question of what to do if a player must leave early anyway.

* Grammatical, tensing, and voice updates for consistency with other pages I originally authored.

---------

Co-authored-by: kindasneaki <ryandj67@hotmail.com>
Co-authored-by: Chris Wilson <chris@legendserver.info>
2023-10-28 14:18:11 -04:00
black-sliver
bf46e0e60f Core: deprecate Utils.get_options and remove Utils.get_default_options (#2352)
* Core: deprecate Utils.get_options and remove Utils.get_default_options

* L2AC, Adventure: use settings instead of Utils.get_options
2023-10-28 19:32:12 +02:00
kindasneaki
7bddea3ee8 Hollow Knight: update item name groups (#2331)
* add missing groups

* remove set comprehensions

* fix boss essence

* reorganized them

* combine boss essence on creation instead of update

* rename to match option names

* Add missing groups

* add PoP totem
2023-10-28 13:30:18 +02:00
Alchav
bdc15186e7 Pokémon R/B: Fix cave surf bug (#2389) 2023-10-28 06:40:06 +02:00
Fabian Dill
20dd478fb5 OoT: create and copy less useless data state (#2379) 2023-10-28 03:13:08 +02:00
black-sliver
e3112e5d51 Stardew Valley: Cut tests by 3 minutes (#2375)
* Stardew Valley: Test: unify mods

* Stardew Valley: Test: don't use SVTestBase where setUp is unused

* Stardew Valley: Test: remove duplicate backpack test

* Stardew Valley: Test: remove 2,3,4 heart tests

assume the math is correct with just 2 points on the curve

* Stardew Valley: Test: reduce duplicate test/gen runs

* Stardew Valley: Test: Change 'long' tests to not use TestBase

TestBase' setUp is not being used in the changed TestCases

* Stardew Valley: Test: Use subtests and inheritance for backpacks

* Stardew Valley: Test: add flag to skip some of the extensive tests by default
2023-10-28 00:18:33 +02:00
Fabian Dill
c470849cee Core: remove custom_data (#2380) 2023-10-27 19:10:16 +02:00
black-sliver
fc2855ca6d Stardew Valley: speed up rules creation by 4% (#2371)
* Stardew Valley: speed up rules creation by 4%

No class should ever inherit from And, Or, False_ or True_ and isinstance is not free.
Sadly there is no cheap way to forbid inheritance, but it was tested using metaclass.

* Stardew Valley: save calls to type()

Local variable is a bit faster than fetching type again

* Stardew Valley: save calls to True_() and False_(), also use 'in' operator

* Stardew Valley: optimize And and Or simplification

* Stardew Valley: optimize logic constructors
2023-10-27 18:09:12 +02:00
ArashiKurobara
6a2407468a OoT: Update YAML Instructions (#1745)
Existing setup guide hard-coded in a YAML from 0.1.7
2023-10-27 15:43:36 +02:00
Aaron Wagener
9281011315 Tests: Add a unit test for slot_data (#2333)
* Tests: Add a unit test for slot_data

* use NetUtils.encode

* modern PEP8
2023-10-27 12:33:59 +02:00
Aaron Wagener
d595b1a67f Docs: slight adding games.md rework (#1192)
* begin reworking adding games.md

* make it presentable

* some doc cleanup

* style cleanup

* rework the "more on that later" section of SDV

* remove now unused images

* make the doc links consistent

* typo

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>

---------

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
2023-10-27 12:30:32 +02:00
Aaron Wagener
16fe66721f Stardew Valley: Use the pre-existing cache rather than ignoring it (#2368) 2023-10-27 12:12:17 +02:00
Jarno
3b5f9d1758 Timespinner: Fixed generation error caused by new options system (#2374) 2023-10-27 12:01:46 +02:00
Bryce Wilson
0f7ebe389e BizHawkClient: Add better launcher component suffix handling (#2367) 2023-10-27 06:14:25 +02:00
Justus Lind
6061bffbb6 Pokemon R/B: Avoid a case of repeatedly checking of state in ER (#2376) 2023-10-27 06:12:04 +02:00
Bryce Wilson
b16804102d BizHawkClient: Add lock for communicating with lua script (#2369) 2023-10-27 03:55:46 +02:00
Fabian Dill
88d69dba97 DLCQuest: logic speed up (#2323) 2023-10-26 00:51:32 +02:00
Fabian Dill
aa73dbab2d Subnautica: avoid cache recreation in create_regions call and clean up function. (#2365) 2023-10-26 00:03:14 +02:00
Fabian Dill
dab704df55 Core/LttP: remove initialize_regions (#2362) 2023-10-25 21:23:52 +02:00
Felix R
e5ca83b5db Bumper Stickers: add location rules (#2254)
* bumpstik: treasure/booster location rules

* bumpstik: oop missed a bit

* bumpstik: apply access rule to Hazards check

* bumpstik: move completion cond. to set_rules

* bumpstik: tests?
I have literally never written these before so 🤷

* bumpstik: oops

* bumpstik: how about this?

* bumpstik: fix some logic

* bumpstik: this almost works but not quite

* bumpstik: accurate region boundaries for BBs
since we're using rules now

* bumpstik: holy heck it works now
2023-10-25 10:22:09 +02:00
Aaron Wagener
be959c05a6 The Messenger: speed up generation for large multiworlds (#2359) 2023-10-25 09:56:56 +02:00
black-sliver
e5554f8630 SoE: create regions cleanup and speedup (#2361)
* SoE: create regions cleanup and speedup

keep local reference instead of hitting multiworld cache
also technically fixes a bug where all locations are in 'menu', not 'ingame'

* SoE: somplify region connection
2023-10-25 09:34:59 +02:00
black-sliver
e87d5d5ac2 SoE: update to v0.46.1
* install via pypi, pin hashes
* add OoB logic option
* add sequence break logic option
* fix turd ball texts
* add option to fix OoB
* better textbox handling when turning in energy core fragments
2023-10-25 00:52:57 +02:00
black-sliver
58642edc17 Core: allow multi-line and --hash in requirements.txt 2023-10-25 00:52:57 +02:00
Aaron Wagener
90c5f45a1f Options: have as_dict return set values as lists to reduce JSON footprint (#2354)
* Options: return set values as lists to reduce JSON footprint

* sorted()

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-10-24 22:50:53 +02:00
black-sliver
78a4b01db5 pytest: run tests on non-windows with new names (#2349) 2023-10-24 10:59:15 +02:00
Fabian Dill
426e9d3090 LttP: make Triforce Piece progression_skip_balancing (#2351) 2023-10-24 08:16:46 +02:00
Seldom
706a2b36db Terraria Old One's Army tier 2 and 3 missing Hardmode req (#2342) 2023-10-24 07:27:57 +02:00
Aaron Wagener
764128568e WebHost: consistent naming for player options (#2037)
* WebHost: unify references to options

* it was just an extra s the whole time...

* grammar

* redirect from old pages

* redirect stuff correctly

* use url_for

* use " for modified strings

* remove redirect cache

* player_settings

* update site map
2023-10-24 02:20:08 +02:00
Justus Lind
12c73acb20 Muse Dash: Make which .net to download more explicit in setup guides. (#2328) 2023-10-23 15:39:37 -05:00
el-u
8109d4a1af lufia2ac: prevent "door stairs" and "rare stairs" (#2341) 2023-10-23 22:20:27 +02:00
Fabian Dill
e394c316f5 Setup: new setup experience (read: torch almost all of it) (#2268) 2023-10-23 22:07:24 +02:00
Alchav
195cf60e8a Pokémon R/B: Door Shuffle efficiency improvement and crash fix (#2347)
Sweep only current player's locations so that more players does not slow it down.
Fix a slight possibility of Full door shuffle crash by only sorting for outdoor dead ends only when connecting from a non-dead end.
2023-10-23 19:28:16 +02:00
espeon65536
724999fc43 Ocarina of Time: long-awaited bugfixes (#2344)
- Added location name groups, so you can make your entire Water Temple priority to annoy everyone else
- Significant improvement to ER generation success rate (~80% to >99%)
- Changed `adult_trade_start` option to a choice option instead of a list (this shouldn't actually break any YAMLs though, due to the lesser-known property of lists parsing as a uniformly-weighted choice)
- Major improvements to the option tooltips where needed. (Possibly too much text now)
- Changed default hint distribution to `async` to help people's generation times. The tooltip explains that it removes WOTH hints so people hopefully don't get tripped up.
- Makes stick and nut capacity upgrades useful items
- Added shop prices and required trials to spoiler log
- Added Cojiro to adult trade item group, because it had been forgotten previously
- Fixed size-modified chests not being moved properly due to trap appearance changing the size
- Fixed Thieves Hideout keyring not being allowed in start inventory
- Fixed hint generation not accurately flagging barren locations on certain dungeon item shuffle settings
- Fixed bug where you could plando arbitrarily-named items into the world, breaking everything
2023-10-22 18:38:47 +02:00
BootsinSoots
50244342d9 Docs: Added Note Explaining BK and fix typo in advanced settings (#2316)
* Added Note Explaining BK

Added suggested change regarding BK mode from Issue #2295

* Changed to Glossary hyperlink

* Fix minor typo in exclude_locations

* Update worlds/generic/docs/advanced_settings_en.md

Co-authored-by: kindasneaki <ryandj67@hotmail.com>

* Docs: Reformat advanced_settings_en/progression_balancing

---------

Co-authored-by: kindasneaki <ryandj67@hotmail.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-10-22 13:11:19 +02:00
Aaron Wagener
30da81c390 Tests: modern PEP8-ify core test modules and methods (#2298)
* rename modules

* rename methods

* add docstrings to the general tests

* add base import stub

* test_base -> bases

* print deprecation warning

* redo 2346
2023-10-22 13:00:27 +02:00
Aaron Wagener
6e6fa13e44 Tests: add multiworld seed to fill subtest (#2346) 2023-10-22 12:12:26 +02:00
NewSoupVi
9f126ad0d0 The Witness: Fix random events not having the correct probabilities (#2340) 2023-10-22 06:48:06 +02:00
Fabian Dill
ee31051c43 WebHost: offer combined yaml file on /check if successful (#2337) 2023-10-22 02:02:30 +02:00
agilbert1412
a5022ccfc5 - Fix Stardew valley option that was accidentally renamed in 993 (#2336) 2023-10-21 23:28:07 +02:00
el-u
1c4303cce6 lufia2ac: add shops to the cave (#2103)
This PR adds a new, optional aspect to the Ancient Cave experience:
During their run, players can have the opportunity to purchase some additional items or spells to improve their party. If enabled, a shop will appear everytime a certain (configurable) number of floors in the dungeon has been completed. The shop inventories are generated randomly (taking into account player preference as well as a system to ensure that more expensive items can only become available deeper into the run).

For customization, 3 new options are introduced: 
- `shop_interval`: Determines by how many floors the shops are separated (or keeps them turned off entirely)
- `shop_inventory`: Determines what's possible to be for sale. (Players can specify weights for general categories of things such as "weapon" or "spell" or even adjust the probabilities of individual items)
- `gold_modifier`: Determines how much gold is dropped by enemies. This is the player's only source of income and thus controls how much money they will have available to spend in shops
2023-10-21 23:27:30 +02:00
Silvris
7c2cb34b45 Plando: prevent duplicate candidate locations (#2286) 2023-10-21 12:59:53 +02:00
Fabian Dill
1a1d607b10 Core: explicitly limit threadpool (#2334) 2023-10-20 05:14:12 +02:00
black-sliver
56796b7ee8 WebHost: minor css changes to make Supported Games page usable without js (#2266)
* WebHost: minor css changes to make Supported Games page usable without js

* Update JS to use querySelectorAll, remove most id attributes from elements, use relative element selectors

* Hide content when clearing search bar

* Remove `console.log`, remove TODO

---------

Co-authored-by: Chris Wilson <chris@legendserver.info>
2023-10-19 20:58:41 -04:00
espeon65536
b82f48fe4b Core: guard against plandoing items onto event locations (#2284) 2023-10-20 02:23:32 +02:00
Justus Lind
385803eb5c Muse Dash: Add support for specifying specific DLCs (#2329) 2023-10-20 02:13:17 +02:00
Aaron Wagener
fb6b66463d OC2: fix mistakes when moving to new options api (#2332) 2023-10-20 01:36:18 +02:00
Bryce Wilson
b707619aad BizHawkClient: Add autostart setting (#2322) 2023-10-19 07:07:15 +02:00
Natalie Weizenbaum
38c9ee146d WebHost: Refactor weighted-settings.js (#2318)
* Refactor weighted-settings.js

This moves most of the infrastructure into two classes:

* WeightedSettings covers the settings page as a whole. It tracks the
  user's current settings in local storage as well as the game data
  from the server so they don't need to be manually passed around from
  function to function.

* GameSettings covers the settings for a single game, and provides a
  view of the current settings and the game data just for that game.

* Fix item count updating
2023-10-18 18:26:52 -04:00
PsyMarth
1c7c83c69e OoT: Update Utils.py (#2310)
Removed optional maxsize parameter, setting it to the default of 128.
2023-10-18 23:53:54 +02:00
Aaron Wagener
e8a48da315 SM: fix missing option import (#2326) 2023-10-18 16:04:12 -05:00
Zach Parks
45e69f3d26 Docs: Triage role expectations documentation. (#2325)
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2023-10-18 15:11:25 -05:00
Alchav
7aab9d4439 Docs: Recommend Bizhawk Version 2.9.1 for Pokémon R/B (#2320) 2023-10-18 21:55:03 +02:00
agilbert1412
5ca1ababfd DLC Quest: Fix code structure, typos, poor code quality (#2066)
"Added a bunch of tests to make sure I don't break anything during refactoring
Huge cleanup in the Regions file, extract methods, remove code duplicate, fix typos, fix variable naming conventions, etc.
Small cleanup in other places, minor stuff just what was needed for Regions"
2023-10-18 21:53:12 +02:00
Trevor L
11ebc523a9 Hylics 2: Various fixes and APWorld support (#2324)
- Fix generation failing with certain gesture shuffle options
    - Fixed passing ItemDict to multidata instead of item code
    - Don't allow CHARGE UP to be placed at Foglast: TV
- APWorld support by removing LogicMixin from Rules.py
2023-10-18 21:50:57 +02:00
Alchav
13b68ecb15 Pokémon R/B: Door Shuffle fixes (#2314)
* Door shuffle fixes

* Add Rt 23's Victory Road exit door to list of unreachable outdoor entrances
2023-10-17 07:20:34 +02:00
Exempt-Medic
e27aeac2e5 HK: Update Setup Guide to use/mention Lumafly (#2308) 2023-10-16 19:59:07 -05:00
el-u
63c7f1deae lufia2ac: switch to new options system (#2289) 2023-10-15 04:53:28 +02:00
Fabian Dill
fffbe68428 Subnautica: cleanup pass (#2293) 2023-10-15 04:51:52 +02:00
Shiny
8fc304269e Docs: add Spanish guide for Muse Dash (#2297)
* adding setup_es

* Update setup_es.md

* Update setup_es.md

* Update __init__.py

referencing setup_es on init.py

* Update __init__.py

fixing a space
2023-10-12 19:51:10 -04:00
NewSoupVi
19d649f92b The Witness: Update docs (outdated information) (#2294)
* Update Witness Game Page

* Update outdated Witness Setup Guide

* Incorporate suggestions
2023-10-12 19:46:16 -04:00
Fabian Dill
1ef3bc78dc CommonClient: inherit Context tags (#2283) 2023-10-11 19:21:02 +02:00
Aaron Wagener
e1ee08a599 FFR: create items in create_items (#2291) 2023-10-11 01:51:13 +02:00
Remy Jette
88dfbd4087 WebHost: Show error instead of 500 for unexpected files in multidata zip (#2260)
* WebHost: Show error instead of 500 for unexpected files in multidata zip

* Add filename to error message

* Apply suggestions from code review

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-10-10 23:20:08 +02:00
Doug Hoskisson
d7475ddd73 Zillion: remove test detection hack (#2287) 2023-10-10 23:08:19 +02:00
Aaron Wagener
7193182294 Core: move option results to the World class instead of MultiWorld (#993)
🤞 

* map option objects to a `World.options` dict

* convert RoR2 to options dict system for testing

* add temp behavior for lttp with notes

* copy/paste bad

* convert `set_default_common_options` to a namespace property

* reorganize test call order

* have fill_restrictive use the new options system

* update world api

* update soe tests

* fix world api

* core: auto initialize a dataclass on the World class with the option results

* core: auto initialize a dataclass on the World class with the option results: small tying improvement

* add `as_dict` method to the options dataclass

* fix namespace issues with tests

* have current option updates use `.value` instead of changing the option

* update ror2 to use the new options system again

* revert the junk pool dict since it's cased differently

* fix begin_with_loop typo

* write new and old options to spoiler

* change factorio option behavior back

* fix comparisons

* move common and per_game_common options to new system

* core: automatically create missing options_dataclass from legacy option_definitions

* remove spoiler special casing and add back the Factorio option changing but in new system

* give ArchipIDLE the default options_dataclass so its options get generated and spoilered properly

* reimplement `inspect.get_annotations`

* move option info generation for webhost to new system

* need to include Common and PerGame common since __annotations__ doesn't include super

* use get_type_hints for the options dictionary

* typing.get_type_hints returns the bases too.

* forgot to sweep through generate

* sweep through all the tests

* swap to a metaclass property

* move remaining usages from get_type_hints to metaclass property

* move remaining usages from __annotations__ to metaclass property

* move remaining usages from legacy dictionaries to metaclass property

* remove legacy dictionaries

* cache the metaclass property

* clarify inheritance in world api

* move the messenger to new options system

* add an assert for my dumb

* update the doc

* rename o to options

* missed a spot

* update new messenger options

* comment spacing

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

* fix tests

* fix missing import

* make the documentation definition more accurate

* use options system for loc creation

* type cast MessengerWorld

* fix typo and use quotes for cast

* LTTP: set random seed in tests

* ArchipIdle: remove change here as it's default on AutoWorld

* Stardew: Need to set state because `set_default_common_options` used to

* The Messenger: update shop rando and helpers to new system; optimize imports

* Add a kwarg to `as_dict` to do the casing for you

* RoR2: use new kwarg for less code

* RoR2: revert some accidental reverts

* The Messenger: remove an unnecessary variable

* remove TypeVar that isn't used

* CommonOptions not abstract

* Docs: fix mistake in options api.md

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

* create options for item link worlds

* revert accidental doc removals

* Item Links: set default options on group

* change Zillion to new options dataclass

* remove unused parameter to function

* use TypeGuard for Literal narrowing

* move dlc quest to new api

* move overcooked 2 to new api

* fixed some missed code in oc2

* - Tried to be compliant with 993 (WIP?)

* - I think it all works now

* - Removed last trace of me touching core

* typo

* It now passes all tests!

* Improve options, fix all issues I hope

* - Fixed init options

* dlcquest: fix bad imports

* missed a file

* - Reduce code duplication

* add as_dict documentation

* - Use .items(), get option name more directly, fix slot data content

* - Remove generic options from the slot data

* improve slot data documentation

* remove `CommonOptions.get_value` (#21)

* better slot data description

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

---------

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
Co-authored-by: Doug Hoskisson <beauxq@yahoo.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
Co-authored-by: Alex Gilbert <alexgilbert@yahoo.com>
2023-10-10 22:30:20 +02:00
Fabian Dill
a7b4914bb7 WebHost: update flask (#2250)
* WebHost: update flask

* WebHost: update flask-caching

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-10-09 10:18:41 +02:00
Fabian Dill
0d8a868ed9 Utils: support messagebox on windows without dependencies (#2224) 2023-10-08 22:14:28 +02:00
Aaron Wagener
6f9484f375 The Messenger: Make modules and tests PEP8 (#2276)
* The Messenger: PEP8 module and test names

* fix dumb

* whitespace
2023-10-08 14:33:39 +02:00
Fabian Dill
cc2247bfa0 CommonClient: fix json prints not being logged in UI mode (#2253) 2023-10-08 13:26:14 +02:00
Aaron Wagener
5eeaf834cb Tests: Add a test for fill to WorldTestBase (#2049)
* Tests: Add a test for fill to WorldTestBase

* test items and minimal accessibility, only bailing out when no reachable locations exist.

* put egg shard max/goal at sane values
114 locations - 35 always-present progression items - 25 excluded locations from settings <= 74 egg shards
past me can't do arithmetic

* f

* i'm bad at git

* make fill import local to prevent circular imports

---------

Co-authored-by: espeon65536 <espeon65536@gmail.com>
2023-10-08 12:08:47 +02:00
Aaron Wagener
fd93f6e722 Tests: add can_reach_region method to WorldTestBase (#2274) 2023-10-08 11:46:30 +02:00
Fabian Dill
5591879547 WebHost/Factorio: use "better" jinja practices in web tracker (#2257) 2023-10-08 11:30:34 +02:00
Alex Nordstrom
c3c6a7eb86 LADX: Set display names in options (#2229)
* set display_name throughout Options.py

* drop whitespace changes

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-10-07 18:36:22 +02:00
Fabian Dill
b8fe3196e0 Setup: also delete old disabled worlds (#2267) 2023-10-07 16:44:21 +02:00
el-u
6028112e0e checksfinder: create items in create_items (#2056) 2023-10-07 16:44:01 +02:00
Fabian Dill
7df1b6f496 LttP: Adjuster no longer breaks when sprite path doesn't exist. 2023-10-07 15:57:05 +02:00
Shiny
debdd4c571 Docs: Pokemon RB Spanish Setup Guide: fixed a bunch of punctuation/grammar and fixed bold format in Configuring Bizhawk section (#2228)
* added setup_es.md

setup_en 100% translated (with a bit of adaptation to spanish linguistics)

* Update __init__.py

add reference to the spanish tutorial

* Update setup_es.md

removed temporary "wip translation" header

* Update setup_es.md

formatting cleanup

* Update setup_es.md

translated "alias for" on lines 73 and 74, which I just forgot to

* Update setup_es.md

fixed a bunch of punctuation/grammar and fixed bold format in Configuring Bizhawk section

* Update worlds/pokemon_rb/docs/setup_es.md

updated bold format as per nicholassaylor's suggestion

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

---------

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
2023-10-07 15:13:17 +02:00
kindasneaki
bb09811433 Docs: add info to turn on github actions (#2264)
* add info to turn on github actions

* add missing image

* add when pushing

* reduce picture size

* mention editing actions on your fork instead
2023-10-05 11:41:49 +02:00
kindasneaki
115a6b666c Webhost: random button presisted after being inactive (#2248)
* update game settings to get the proper attribute

* change to ternary operator
2023-10-04 23:53:59 -04:00
Nicholas Saylor
6c4a3959c3 Docs: Categorize Commands in Guide (#2213)
* Update commands_en.md

Commands re-ordered and put into categories

Some commands were better documented / explained more clearly

Other formatting changes

* Status command moved to General category and elaboration on getitem command

* "Multi-world" -> "Multiworld"

* Moved game-specific local commands to game pages
2023-10-04 16:52:34 -04:00
Aaron Wagener
f6e92a18de The Messenger: Fix items accessibility region rule (#2263) 2023-10-04 18:23:29 +02:00
677 changed files with 97471 additions and 22264 deletions

5
.coveragerc Normal file
View File

@@ -0,0 +1,5 @@
[report]
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
if typing.TYPE_CHECKING:

View File

@@ -71,7 +71,7 @@ jobs:
continue-on-error: true
if: env.diff != '' && matrix.task == 'flake8'
run: |
flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
flake8 --count --max-complexity=14 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }}
- name: "mypy: Type check modified files"
continue-on-error: true

2
.gitignore vendored
View File

@@ -9,12 +9,14 @@
*.apmc
*.apz5
*.aptloz
*.apemerald
*.pyc
*.pyd
*.sfc
*.z64
*.n64
*.nes
*.smc
*.sms
*.gb
*.gbc

View File

@@ -0,0 +1,18 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Archipelago Unittests" type="tests" factoryName="Unittests">
<module name="Archipelago" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<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" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="_new_pattern" value="&quot;&quot;" />
<option name="_new_additionalArguments" value="&quot;&quot;" />
<option name="_new_target" value="&quot;$PROJECT_DIR$/test&quot;" />
<option name="_new_targetType" value="&quot;PATH&quot;" />
<method v="2" />
</configuration>
</component>

View File

@@ -115,11 +115,12 @@ class AdventureContext(CommonContext):
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "Retrieved":
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
if self.freeincarnates_used is None:
self.freeincarnates_used = 0
self.freeincarnates_used += self.freeincarnate_pending
self.send_pending_freeincarnates()
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
if self.freeincarnates_used is None:
self.freeincarnates_used = 0
self.freeincarnates_used += self.freeincarnate_pending
self.send_pending_freeincarnates()
elif cmd == "SetReply":
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
self.freeincarnates_used = args["value"]

View File

@@ -1,14 +1,15 @@
from __future__ import annotations
import copy
import itertools
import functools
import logging
import random
import secrets
import typing # this can go away when Python 3.8 support is dropped
from argparse import Namespace
from collections import ChainMap, Counter, deque
from collections.abc import Collection
from collections import Counter, deque
from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag
from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \
Type, ClassVar
@@ -47,7 +48,6 @@ class ThreadBarrierProxy:
class MultiWorld():
debug_types = False
player_name: Dict[int, str]
_region_cache: Dict[int, Dict[str, Region]]
difficulty_requirements: dict
required_medallions: dict
dark_room_logic: Dict[int, str]
@@ -57,7 +57,7 @@ class MultiWorld():
plando_connections: List
worlds: Dict[int, auto_world]
groups: Dict[int, Group]
regions: List[Region]
regions: RegionManager
itempool: List[Item]
is_race: bool = False
precollected_items: Dict[int, List[Item]]
@@ -92,6 +92,39 @@ class MultiWorld():
def __getitem__(self, player) -> bool:
return self.rule(player)
class RegionManager:
region_cache: Dict[int, Dict[str, Region]]
entrance_cache: Dict[int, Dict[str, Entrance]]
location_cache: Dict[int, Dict[str, Location]]
def __init__(self, players: int):
self.region_cache = {player: {} for player in range(1, players+1)}
self.entrance_cache = {player: {} for player in range(1, players+1)}
self.location_cache = {player: {} for player in range(1, players+1)}
def __iadd__(self, other: Iterable[Region]):
self.extend(other)
return self
def append(self, region: Region):
self.region_cache[region.player][region.name] = region
def extend(self, regions: Iterable[Region]):
for region in regions:
self.region_cache[region.player][region.name] = region
def add_group(self, new_id: int):
self.region_cache[new_id] = {}
self.entrance_cache[new_id] = {}
self.location_cache[new_id] = {}
def __iter__(self) -> Iterator[Region]:
for regions in self.region_cache.values():
yield from regions.values()
def __len__(self):
return sum(len(regions) for regions in self.region_cache.values())
def __init__(self, players: int):
# world-local random state is saved for multiple generations running concurrently
self.random = ThreadBarrierProxy(random.Random())
@@ -100,16 +133,12 @@ class MultiWorld():
self.glitch_triforce = False
self.algorithm = 'balanced'
self.groups = {}
self.regions = []
self.regions = self.RegionManager(players)
self.shops = []
self.itempool = []
self.seed = None
self.seed_name: str = "Unavailable"
self.precollected_items = {player: [] for player in self.player_ids}
self._cached_entrances = None
self._cached_locations = None
self._entrance_cache = {}
self._location_cache: Dict[Tuple[str, int], Location] = {}
self.required_locations = []
self.light_world_light_cone = False
self.dark_world_light_cone = False
@@ -137,7 +166,6 @@ class MultiWorld():
def set_player_attr(attr, val):
self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('_region_cache', {})
set_player_attr('shuffle', "vanilla")
set_player_attr('logic', "noglitches")
set_player_attr('mode', 'open')
@@ -181,7 +209,6 @@ class MultiWorld():
set_player_attr('plando_connections', [])
set_player_attr('game', "A Link to the Past")
set_player_attr('completion_condition', lambda state: True)
self.custom_data = {}
self.worlds = {}
self.per_slot_randoms = {}
self.plando_options = PlandoOptions.none
@@ -198,10 +225,9 @@ class MultiWorld():
return group_id, group
new_id: int = self.players + len(self.groups) + 1
self.regions.add_group(new_id)
self.game[new_id] = game
self.custom_data[new_id] = {}
self.player_types[new_id] = NetUtils.SlotType.group
self._region_cache[new_id] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[game]
self.worlds[new_id] = world_type.create_group(self, new_id, players)
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
@@ -226,25 +252,28 @@ class MultiWorld():
range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None:
for option_key in Options.common_options:
setattr(self, option_key, getattr(args, option_key, {}))
for option_key in Options.per_game_common_options:
setattr(self, option_key, getattr(args, option_key, {}))
# TODO - remove this section once all worlds use options dataclasses
all_keys: Set[str] = {key for player in self.player_ids for key in
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
for option_key in all_keys:
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
f"Please use `self.options.{option_key}` instead.")
option.update(getattr(args, option_key, {}))
setattr(self, option_key, option)
for player in self.player_ids:
self.custom_data[player] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
for option_key in world_type.option_definitions:
setattr(self, option_key, getattr(args, option_key, {}))
self.worlds[player] = world_type(self, player)
self.worlds[player].random = self.per_slot_randoms[player]
options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
for option_key in options_dataclass.type_hints})
def set_item_links(self):
item_links = {}
replacement_prio = [False, True, None]
for player in self.player_ids:
for item_link in self.item_links[player].value:
for item_link in self.worlds[player].options.item_links.value:
if item_link["name"] in item_links:
if item_links[item_link["name"]]["game"] != self.game[player]:
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
@@ -299,14 +328,6 @@ class MultiWorld():
group["non_local_items"] = item_link["non_local_items"]
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
# intended for unittests
def set_default_common_options(self):
for option_key, option in Options.common_options.items():
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
for option_key, option in Options.per_game_common_options.items():
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
self.state = CollectionState(self)
def secure(self):
self.random = ThreadBarrierProxy(secrets.SystemRandom())
self.is_race = True
@@ -315,11 +336,15 @@ class MultiWorld():
def player_ids(self) -> Tuple[int, ...]:
return tuple(range(1, self.players + 1))
@functools.lru_cache()
@Utils.cache_self1
def get_game_players(self, game_name: str) -> Tuple[int, ...]:
return tuple(player for player in self.player_ids if self.game[player] == game_name)
@functools.lru_cache()
@Utils.cache_self1
def get_game_groups(self, game_name: str) -> Tuple[int, ...]:
return tuple(group_id for group_id in self.groups if self.game[group_id] == game_name)
@Utils.cache_self1
def get_game_worlds(self, game_name: str):
return tuple(world for player, world in self.worlds.items() if
player not in self.groups and self.game[player] == game_name)
@@ -337,50 +362,21 @@ class MultiWorld():
""" the base name (without file extension) for each player's output file for a seed """
return f"AP_{self.seed_name}_P{player}_{self.get_file_safe_player_name(player).replace(' ', '_')}"
def initialize_regions(self, regions=None):
for region in regions if regions else self.regions:
region.multiworld = self
self._region_cache[region.player][region.name] = region
@functools.cached_property
def world_name_lookup(self):
return {self.player_name[player_id]: player_id for player_id in self.player_ids}
def _recache(self):
"""Rebuild world cache"""
self._cached_locations = None
for region in self.regions:
player = region.player
self._region_cache[player][region.name] = region
for exit in region.exits:
self._entrance_cache[exit.name, player] = exit
for r_location in region.locations:
self._location_cache[r_location.name, player] = r_location
def get_regions(self, player: Optional[int] = None) -> Collection[Region]:
return self.regions if player is None else self._region_cache[player].values()
return self.regions if player is None else self.regions.region_cache[player].values()
def get_region(self, regionname: str, player: int) -> Region:
try:
return self._region_cache[player][regionname]
except KeyError:
self._recache()
return self._region_cache[player][regionname]
def get_region(self, region_name: str, player: int) -> Region:
return self.regions.region_cache[player][region_name]
def get_entrance(self, entrance: str, player: int) -> Entrance:
try:
return self._entrance_cache[entrance, player]
except KeyError:
self._recache()
return self._entrance_cache[entrance, player]
def get_entrance(self, entrance_name: str, player: int) -> Entrance:
return self.regions.entrance_cache[player][entrance_name]
def get_location(self, location: str, player: int) -> Location:
try:
return self._location_cache[location, player]
except KeyError:
self._recache()
return self._location_cache[location, player]
def get_location(self, location_name: str, player: int) -> Location:
return self.regions.location_cache[player][location_name]
def get_all_state(self, use_cache: bool) -> CollectionState:
cached = getattr(self, "_all_state", None)
@@ -441,28 +437,22 @@ class MultiWorld():
logging.debug('Placed %s at %s', item, location)
def get_entrances(self) -> List[Entrance]:
if self._cached_entrances is None:
self._cached_entrances = [entrance for region in self.regions for entrance in region.entrances]
return self._cached_entrances
def clear_entrance_cache(self):
self._cached_entrances = None
def get_entrances(self, player: Optional[int] = None) -> Iterable[Entrance]:
if player is not None:
return self.regions.entrance_cache[player].values()
return Utils.RepeatableChain(tuple(self.regions.entrance_cache[player].values()
for player in self.regions.entrance_cache))
def register_indirect_condition(self, region: Region, entrance: Entrance):
"""Report that access to this Region can result in unlocking this Entrance,
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
self.indirect_connections.setdefault(region, set()).add(entrance)
def get_locations(self, player: Optional[int] = None) -> List[Location]:
if self._cached_locations is None:
self._cached_locations = [location for region in self.regions for location in region.locations]
def get_locations(self, player: Optional[int] = None) -> Iterable[Location]:
if player is not None:
return [location for location in self._cached_locations if location.player == player]
return self._cached_locations
def clear_location_cache(self):
self._cached_locations = None
return self.regions.location_cache[player].values()
return Utils.RepeatableChain(tuple(self.regions.location_cache[player].values()
for player in self.regions.location_cache))
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]:
return [location for location in self.get_locations(player) if location.item is None]
@@ -484,16 +474,17 @@ class MultiWorld():
valid_locations = [location.name for location in self.get_unfilled_locations(player)]
else:
valid_locations = location_names
relevant_cache = self.regions.location_cache[player]
for location_name in valid_locations:
location = self._location_cache.get((location_name, player), None)
if location is not None and location.item is None:
location = relevant_cache.get(location_name, None)
if location and location.item is None:
yield location
def unlocks_new_location(self, item: Item) -> bool:
temp_state = self.state.copy()
temp_state.collect(item, True)
for location in self.get_unfilled_locations():
for location in self.get_unfilled_locations(item.player):
if temp_state.can_reach(location) and not self.state.can_reach(location):
return True
@@ -505,7 +496,7 @@ class MultiWorld():
else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
def can_beat_game(self, starting_state: Optional[CollectionState] = None):
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
if starting_state:
if self.has_beaten_game(starting_state):
return True
@@ -518,7 +509,7 @@ class MultiWorld():
and location.item.advancement and location not in state.locations_checked}
while prog_locations:
sphere = set()
sphere: Set[Location] = set()
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
for location in prog_locations:
@@ -538,12 +529,19 @@ class MultiWorld():
return False
def get_spheres(self):
def get_spheres(self) -> Iterator[Set[Location]]:
"""
yields a set of locations for each logical sphere
If there are unreachable locations, the last sphere of reachable
locations is followed by an empty set, and then a set of all of the
unreachable locations.
"""
state = CollectionState(self)
locations = set(self.get_filled_locations())
while locations:
sphere = set()
sphere: Set[Location] = set()
for location in locations:
if location.can_reach(state):
@@ -625,7 +623,7 @@ PathValue = Tuple[str, Optional["PathValue"]]
class CollectionState():
prog_items: typing.Counter[Tuple[str, int]]
prog_items: Dict[int, Counter[str]]
multiworld: MultiWorld
reachable_regions: Dict[int, Set[Region]]
blocked_connections: Dict[int, Set[Entrance]]
@@ -637,7 +635,7 @@ class CollectionState():
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
def __init__(self, parent: MultiWorld):
self.prog_items = Counter()
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
@@ -653,39 +651,39 @@ class CollectionState():
def update_reachable_regions(self, player: int):
self.stale[player] = False
rrp = self.reachable_regions[player]
bc = self.blocked_connections[player]
reachable_regions = self.reachable_regions[player]
blocked_connections = self.blocked_connections[player]
queue = deque(self.blocked_connections[player])
start = self.multiworld.get_region('Menu', player)
start = self.multiworld.get_region("Menu", player)
# init on first call - this can't be done on construction since the regions don't exist yet
if start not in rrp:
rrp.add(start)
bc.update(start.exits)
if start not in reachable_regions:
reachable_regions.add(start)
blocked_connections.update(start.exits)
queue.extend(start.exits)
# run BFS on all connections, and keep track of those blocked by missing items
while queue:
connection = queue.popleft()
new_region = connection.connected_region
if new_region in rrp:
bc.remove(connection)
if new_region in reachable_regions:
blocked_connections.remove(connection)
elif connection.can_reach(self):
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
rrp.add(new_region)
bc.remove(connection)
bc.update(new_region.exits)
reachable_regions.add(new_region)
blocked_connections.remove(connection)
blocked_connections.update(new_region.exits)
queue.extend(new_region.exits)
self.path[new_region] = (new_region.name, self.path.get(connection, None))
# Retry connections if the new region can unblock them
for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
if new_entrance in bc and new_entrance not in queue:
if new_entrance in blocked_connections and new_entrance not in queue:
queue.append(new_entrance)
def copy(self) -> CollectionState:
ret = CollectionState(self.multiworld)
ret.prog_items = self.prog_items.copy()
ret.prog_items = copy.deepcopy(self.prog_items)
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
self.reachable_regions}
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
@@ -728,37 +726,43 @@ class CollectionState():
assert isinstance(event.item, Item), "tried to collect Event with no Item"
self.collect(event.item, True, event)
# item name related
def has(self, item: str, player: int, count: int = 1) -> bool:
return self.prog_items[item, player] >= count
return self.prog_items[player][item] >= count
def has_all(self, items: Set[str], player: int) -> bool:
def has_all(self, items: Iterable[str], player: int) -> bool:
"""Returns True if each item name of items is in state at least once."""
return all(self.prog_items[item, player] for item in items)
return all(self.prog_items[player][item] for item in items)
def has_any(self, items: Set[str], player: int) -> bool:
def has_any(self, items: Iterable[str], player: int) -> bool:
"""Returns True if at least one item name of items is in state at least once."""
return any(self.prog_items[item, player] for item in items)
return any(self.prog_items[player][item] for item in items)
def count(self, item: str, player: int) -> int:
return self.prog_items[item, player]
return self.prog_items[player][item]
def item_count(self, item: str, player: int) -> int:
Utils.deprecate("Use count instead.")
return self.count(item, player)
# item name group related
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player]
found += player_prog_items[item_name]
if found >= count:
return True
return False
def count_group(self, item_name_group: str, player: int) -> int:
found: int = 0
player_prog_items = self.prog_items[player]
for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player]
found += player_prog_items[item_name]
return found
def item_count(self, item: str, player: int) -> int:
return self.prog_items[item, player]
# Item related
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
if location:
self.locations_checked.add(location)
@@ -766,7 +770,7 @@ class CollectionState():
changed = self.multiworld.worlds[item.player].collect(self, item)
if not changed and event:
self.prog_items[item.name, item.player] += 1
self.prog_items[item.player][item.name] += 1
changed = True
self.stale[item.player] = True
@@ -833,15 +837,83 @@ class Region:
locations: List[Location]
entrance_type: ClassVar[Type[Entrance]] = Entrance
class Register(MutableSequence):
region_manager: MultiWorld.RegionManager
def __init__(self, region_manager: MultiWorld.RegionManager):
self._list = []
self.region_manager = region_manager
def __getitem__(self, index: int) -> Location:
return self._list.__getitem__(index)
def __setitem__(self, index: int, value: Location) -> None:
raise NotImplementedError()
def __len__(self) -> int:
return self._list.__len__()
# This seems to not be needed, but that's a bit suspicious.
# def __del__(self):
# self.clear()
def copy(self):
return self._list.copy()
class LocationRegister(Register):
def __delitem__(self, index: int) -> None:
location: Location = self._list.__getitem__(index)
self._list.__delitem__(index)
del(self.region_manager.location_cache[location.player][location.name])
def insert(self, index: int, value: Location) -> None:
self._list.insert(index, value)
self.region_manager.location_cache[value.player][value.name] = value
class EntranceRegister(Register):
def __delitem__(self, index: int) -> None:
entrance: Entrance = self._list.__getitem__(index)
self._list.__delitem__(index)
del(self.region_manager.entrance_cache[entrance.player][entrance.name])
def insert(self, index: int, value: Entrance) -> None:
self._list.insert(index, value)
self.region_manager.entrance_cache[value.player][value.name] = value
_locations: LocationRegister[Location]
_exits: EntranceRegister[Entrance]
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
self.name = name
self.entrances = []
self.exits = []
self.locations = []
self._exits = self.EntranceRegister(multiworld.regions)
self._locations = self.LocationRegister(multiworld.regions)
self.multiworld = multiworld
self._hint_text = hint
self.player = player
def get_locations(self):
return self._locations
def set_locations(self, new):
if new is self._locations:
return
self._locations.clear()
self._locations.extend(new)
locations = property(get_locations, set_locations)
def get_exits(self):
return self._exits
def set_exits(self, new):
if new is self._exits:
return
self._exits.clear()
self._exits.extend(new)
exits = property(get_exits, set_exits)
def can_reach(self, state: CollectionState) -> bool:
if state.stale[self.player]:
state.update_reachable_regions(self.player)
@@ -863,19 +935,19 @@ class Region:
"""
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address.
:param locations: dictionary of locations to be created and added to this Region `{name: ID}`
:param location_type: Location class to be used to create the locations with"""
if location_type is None:
location_type = Location
for location, address in locations.items():
self.locations.append(location_type(self.player, location, address, self))
def connect(self, connecting_region: Region, name: Optional[str] = None,
rule: Optional[Callable[[CollectionState], bool]] = None) -> None:
rule: Optional[Callable[[CollectionState], bool]] = None) -> entrance_type:
"""
Connects this Region to another Region, placing the provided rule on the connection.
:param connecting_region: Region object to connect to path is `self -> exiting_region`
:param name: name of the connection being created
:param rule: callable to determine access of this connection to go from self to the exiting_region"""
@@ -883,11 +955,12 @@ class Region:
if rule:
exit_.access_rule = rule
exit_.connect(connecting_region)
return exit_
def create_exit(self, name: str) -> Entrance:
"""
Creates and returns an Entrance object as an exit of this region.
:param name: name of the Entrance being created
"""
exit_ = self.entrance_type(self.player, name, self)
@@ -983,9 +1056,6 @@ class Location:
@property
def hint_text(self) -> str:
hint_text = getattr(self, "_hint_text", None)
if hint_text:
return hint_text
return "at " + self.name.replace("_", " ").replace("-", " ")
@@ -1257,7 +1327,7 @@ class Spoiler:
def to_file(self, filename: str) -> None:
def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None:
res = getattr(self.multiworld, option_key)[player]
res = getattr(self.multiworld.worlds[player].options, option_key)
display_name = getattr(option_obj, "display_name", option_key)
outfile.write(f"{display_name + ':':33}{res.current_option_name}\n")
@@ -1275,8 +1345,7 @@ class Spoiler:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player])
options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions)
for f_option, option in options.items():
for f_option, option in self.multiworld.worlds[player].options_dataclass.type_hints.items():
write_option(f_option, option)
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)

View File

@@ -1,4 +1,6 @@
from __future__ import annotations
import copy
import logging
import asyncio
import urllib.parse
@@ -242,6 +244,7 @@ class CommonContext:
self.watcher_event = asyncio.Event()
self.jsontotextparser = JSONtoTextParser(self)
self.rawjsontotextparser = RawJSONtoTextParser(self)
self.update_data_package(network_data_package)
# execution
@@ -377,10 +380,13 @@ class CommonContext:
def on_print_json(self, args: dict):
if self.ui:
self.ui.print_json(args["data"])
else:
text = self.jsontotextparser(args["data"])
logger.info(text)
# send copy to UI
self.ui.print_json(copy.deepcopy(args["data"]))
logging.getLogger("FileLog").info(self.rawjsontotextparser(copy.deepcopy(args["data"])),
extra={"NoStream": True})
logging.getLogger("StreamLog").info(self.jsontotextparser(copy.deepcopy(args["data"])),
extra={"NoFile": True})
def on_package(self, cmd: str, args: dict):
"""For custom package handling in subclasses."""
@@ -454,7 +460,7 @@ class CommonContext:
else:
self.update_game(cached_game)
if needed_updates:
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
await self.send_msgs([{"cmd": "GetDataPackage", "games": [game_name]} for game_name in needed_updates])
def update_game(self, game_package: dict):
for item_name, item_id in game_package["item_name_to_id"].items():
@@ -471,6 +477,7 @@ class CommonContext:
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
current_cache.update(data_package["games"])
Utils.persistent_store("datapackage", "games", current_cache)
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data)
@@ -721,7 +728,6 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
logger.info("Got new ID/Name DataPackage")
ctx.consume_network_data_package(args['data'])
elif cmd == 'ConnectionRefused':
@@ -731,7 +737,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif 'InvalidGame' in errors:
ctx.event_invalid_game()
elif 'IncompatibleVersion' in errors:
raise Exception('Server reported your client version as incompatible')
raise Exception('Server reported your client version as incompatible. '
'This probably means you have to update.')
elif 'InvalidItemsHandling' in errors:
raise Exception('The item handling flags requested by the client are not supported')
# last to check, recoverable problem
@@ -752,6 +759,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")
msgs = []
if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks",
@@ -830,10 +838,14 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == "Retrieved":
ctx.stored_data.update(args["keys"])
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" in args["keys"]:
ctx.ui.update_hints()
elif cmd == "SetReply":
ctx.stored_data[args["key"]] = args["value"]
if args["key"].startswith("EnergyLink"):
if ctx.ui and f"_read_hints_{ctx.team}_{ctx.slot}" == args["key"]:
ctx.ui.update_hints()
elif args["key"].startswith("EnergyLink"):
ctx.current_energy_link_value = args["value"]
if ctx.ui:
ctx.ui.set_new_energy_link_value()
@@ -876,7 +888,7 @@ def get_base_parser(description: typing.Optional[str] = None):
def run_as_textclient():
class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry
tags = {"AP", "TextOnly"}
tags = CommonContext.tags | {"TextOnly"}
game = "" # empty matches any game since 0.3.2
items_handling = 0b111 # receive all items for /received
want_slot_data = False # Can't use game specific slot_data

126
Fill.py
View File

@@ -5,6 +5,8 @@ import typing
from collections import Counter, deque
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from Options import Accessibility
from worlds.AutoWorld import call_all
from worlds.generic.Rules import add_item_rule
@@ -13,6 +15,10 @@ class FillError(RuntimeError):
pass
def _log_fill_progress(name: str, placed: int, total_items: int) -> None:
logging.info(f"Current fill step ({name}) at {placed}/{total_items} items placed.")
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState:
new_state = base_state.copy()
for item in itempool:
@@ -24,7 +30,7 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False, allow_excluded: bool = False) -> None:
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
"""
:param world: Multiworld to be filled.
:param base_state: State assumed before fill.
@@ -36,16 +42,20 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
:param on_place: callback that is called when a placement happens
:param allow_partial: only place what is possible. Remaining items will be in the item_pool list.
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
:param name: name of this fill step for progress logging purposes
"""
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
cleanup_required = False
swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
for item in item_pool:
reachable_items.setdefault(item.player, deque()).append(item)
# for progress logging
total = min(len(item_pool), len(locations))
placed = 0
while any(reachable_items.values()) and locations:
# grab one item per player
items_to_place = [items.pop()
@@ -70,7 +80,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
spot_to_fill: typing.Optional[Location] = None
# if minimal accessibility, only check whether location is reachable if game not beatable
if world.accessibility[item_to_place.player] == 'minimal':
if world.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
item_to_place.player) \
if single_player_placement else not has_beaten_game
@@ -102,7 +112,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item] if unsafe else [])
swap_state = sweep_from_pool(base_state, [placed_item, *item_pool] if unsafe else item_pool)
# unsafe means swap_state assumes we can somehow collect placed_item before item_to_place
# by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic
# to clean that up later, so there is a chance generation fails.
@@ -150,9 +160,15 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
spot_to_fill.locked = lock
placements.append(spot_to_fill)
spot_to_fill.event = item_to_place.advancement
placed += 1
if not placed % 1000:
_log_fill_progress(name, placed, total)
if on_place:
on_place(spot_to_fill)
if total > 1000:
_log_fill_progress(name, placed, total)
if cleanup_required:
# validate all placements and remove invalid ones
state = sweep_from_pool(base_state, [])
@@ -196,6 +212,8 @@ def remaining_fill(world: MultiWorld,
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
total = min(len(itempool), len(locations))
placed = 0
while locations and itempool:
item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None
@@ -245,6 +263,12 @@ def remaining_fill(world: MultiWorld,
world.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill)
placed += 1
if not placed % 1000:
_log_fill_progress("Remaining", placed, total)
if total > 1000:
_log_fill_progress("Remaining", placed, total)
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
@@ -265,7 +289,7 @@ def fast_fill(world: MultiWorld,
def accessibility_corrections(world: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in world.player_ids if world.accessibility[player] == "minimal"}
minimal_players = {player for player in world.player_ids if world.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [location for location in world.get_locations() if location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
@@ -280,7 +304,7 @@ def accessibility_corrections(world: MultiWorld, state: CollectionState, locatio
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
fill_restrictive(world, state, locations, pool)
fill_restrictive(world, state, locations, pool, name="Accessibility Corrections")
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
@@ -288,7 +312,7 @@ def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locat
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and world.accessibility[item.player] != 'minimal')
return not ((item.classification & 0b0011) and world.worlds[item.player].options.accessibility != 'minimal')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
@@ -350,23 +374,25 @@ def distribute_early_items(world: MultiWorld,
player_local = early_local_rest_items[player]
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True)
player_local, lock=True, allow_partial=True, name=f"Local Early Items P{player}")
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_rest_items.extend(early_local_rest_items[player])
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True)
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True,
name="Early Items")
early_locations += early_priority_locations
for player in world.player_ids:
player_local = early_local_prog_items[player]
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True)
player_local, lock=True, allow_partial=True, name=f"Local Early Progression P{player}")
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_prog_items.extend(player_local)
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True)
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True,
name="Early Progression")
unplaced_early_items = early_rest_items + early_prog_items
if unplaced_early_items:
logging.warning("Ran out of early locations for early items. Failed to place "
@@ -420,13 +446,14 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
if prioritylocations:
# "priority fill"
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking,
name="Priority")
accessibility_corrections(world, world.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations
if progitempool:
# "progression fill"
fill_restrictive(world, world.state, defaultlocations, progitempool)
# "advancement/progression fill"
fill_restrictive(world, world.state, defaultlocations, progitempool, name="Progression")
if progitempool:
raise FillError(
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
@@ -444,7 +471,7 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
raise FillError(
f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
restitempool = usefulitempool + filleritempool
restitempool = filleritempool + usefulitempool
remaining_fill(world, defaultlocations, restitempool)
@@ -523,7 +550,7 @@ def flood_items(world: MultiWorld) -> None:
break
def balance_multiworld_progression(world: MultiWorld) -> None:
def balance_multiworld_progression(multiworld: MultiWorld) -> None:
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
# Overall progression balancing algorithm:
# Gather up all locations in a sphere.
@@ -531,28 +558,28 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players: typing.Dict[int, float] = {
player: world.progression_balancing[player] / 100
for player in world.player_ids
if world.progression_balancing[player] > 0
player: multiworld.worlds[player].options.progression_balancing / 100
for player in multiworld.player_ids
if multiworld.worlds[player].options.progression_balancing > 0
}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
else:
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.debug(balanceable_players)
state: CollectionState = CollectionState(world)
state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set()
unchecked_locations: typing.Set[Location] = set(world.get_locations())
unchecked_locations: typing.Set[Location] = set(multiworld.get_locations())
total_locations_count: typing.Counter[int] = Counter(
location.player
for location in world.get_locations()
for location in multiworld.get_locations()
if not location.locked
)
reachable_locations_count: typing.Dict[int, int] = {
player: 0
for player in world.player_ids
if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
for player in multiworld.player_ids
if total_locations_count[player] and len(multiworld.get_filled_locations(player)) != 0
}
balanceable_players = {
player: balanceable_players[player]
@@ -631,7 +658,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
balancing_unchecked_locations.remove(location)
if not location.locked:
balancing_reachables[location.player] += 1
if world.has_beaten_game(balancing_state) or all(
if multiworld.has_beaten_game(balancing_state) or all(
item_percentage(player, reachables) >= threshold_percentages[player]
for player, reachables in balancing_reachables.items()
if player in threshold_percentages):
@@ -648,7 +675,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
locations_to_test = unlocked_locations[player]
items_to_test = list(candidate_items[player])
items_to_test.sort()
world.random.shuffle(items_to_test)
multiworld.random.shuffle(items_to_test)
while items_to_test:
testing = items_to_test.pop()
reducing_state = state.copy()
@@ -660,8 +687,8 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
reducing_state.sweep_for_events(locations=locations_to_test)
if world.has_beaten_game(balancing_state):
if not world.has_beaten_game(reducing_state):
if multiworld.has_beaten_game(balancing_state):
if not multiworld.has_beaten_game(reducing_state):
items_to_replace.append(testing)
else:
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
@@ -669,33 +696,32 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
if p < threshold_percentages[player]:
items_to_replace.append(testing)
replaced_items = False
old_moved_item_count = moved_item_count
# sort then shuffle to maintain deterministic behaviour,
# while allowing use of set for better algorithm growth behaviour elsewhere
replacement_locations = sorted(l for l in checked_locations if not l.event and not l.locked)
world.random.shuffle(replacement_locations)
multiworld.random.shuffle(replacement_locations)
items_to_replace.sort()
world.random.shuffle(items_to_replace)
multiworld.random.shuffle(items_to_replace)
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
while replacement_locations and items_to_replace:
old_location = items_to_replace.pop()
for new_location in replacement_locations:
for i, new_location in enumerate(replacement_locations):
if new_location.can_fill(state, old_location.item, False) and \
old_location.can_fill(state, new_location.item, False):
replacement_locations.remove(new_location)
replacement_locations.pop(i)
swap_location_item(old_location, new_location)
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
f"displacing {old_location.item} into {old_location}")
moved_item_count += 1
state.collect(new_location.item, True, new_location)
replaced_items = True
break
else:
logging.warning(f"Could not Progression Balance {old_location.item}")
if replaced_items:
if old_moved_item_count < moved_item_count:
logging.debug(f"Moved {moved_item_count} items so far\n")
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
for location in get_sphere_locations(state, unlocked):
@@ -709,7 +735,7 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
state.collect(location.item, True, location)
checked_locations |= sphere_locations
if world.has_beaten_game(state):
if multiworld.has_beaten_game(state):
break
elif not sphere_locations:
logging.warning("Progression Balancing ran out of paths.")
@@ -765,6 +791,9 @@ def distribute_planned(world: MultiWorld) -> None:
block['force'] = 'silent'
if 'from_pool' not in block:
block['from_pool'] = True
elif not isinstance(block['from_pool'], bool):
from_pool_type = type(block['from_pool'])
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
if 'world' not in block:
target_world = False
else:
@@ -845,7 +874,7 @@ def distribute_planned(world: MultiWorld) -> None:
for target_player in worlds:
locations += non_early_locations[target_player]
block['locations'] = locations
block['locations'] = list(dict.fromkeys(locations))
if not block['count']:
block['count'] = (min(len(block['items']), len(block['locations'])) if
@@ -895,19 +924,22 @@ def distribute_planned(world: MultiWorld) -> None:
for item_name in items:
item = world.worlds[player].create_item(item_name)
for location in reversed(candidates):
if not location.item:
if location.item_rule(item):
if location.can_fill(world.state, item, False):
successful_pairs.append((item, location))
candidates.remove(location)
count = count + 1
break
if (location.address is None) == (item.code is None): # either both None or both not None
if not location.item:
if location.item_rule(item):
if location.can_fill(world.state, item, False):
successful_pairs.append((item, location))
candidates.remove(location)
count = count + 1
break
else:
err.append(f"Can't place item at {location} due to fill condition not met.")
else:
err.append(f"Can't place item at {location} due to fill condition not met.")
err.append(f"{item_name} not allowed at {location}.")
else:
err.append(f"{item_name} not allowed at {location}.")
err.append(f"Cannot place {item_name} into already filled location {location}.")
else:
err.append(f"Cannot place {item_name} into already filled location {location}.")
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
if count == maxcount:
break
if count < placement['count']['min']:

View File

@@ -7,8 +7,8 @@ import random
import string
import urllib.parse
import urllib.request
from collections import ChainMap, Counter
from typing import Any, Callable, Dict, Tuple, Union
from collections import Counter
from typing import Any, Dict, Tuple, Union
import ModuleUpdate
@@ -20,7 +20,7 @@ import Options
from BaseClasses import seeddigits, get_seed, PlandoOptions
from Main import main as ERmain
from settings import get_settings
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path
from Utils import parse_yamls, version_tuple, __version__, tuplize_version
from worlds.alttp import Options as LttPOptions
from worlds.alttp.EntranceRandomizer import parse_arguments
from worlds.alttp.Text import TextTable
@@ -53,6 +53,9 @@ def mystery_argparse():
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
parser.add_argument("--skip_prog_balancing", action="store_true",
help="Skip progression balancing step during generation.")
parser.add_argument("--skip_output", action="store_true",
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
"Intended for debugging and testing purposes.")
args = parser.parse_args()
if not os.path.isabs(args.weights_file_path):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
@@ -127,6 +130,13 @@ def main(args=None, callback=ERmain):
player_id += 1
args.multi = max(player_id - 1, args.multi)
if args.multi == 0:
raise ValueError(
"No individual player files found and number of players is 0. "
"Provide individual player files or specify the number of players via host.yaml or --multi."
)
logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, "
f"{seed_name} Seed {seed} with plando: {args.plando}")
@@ -143,6 +153,7 @@ def main(args=None, callback=ERmain):
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
@@ -157,7 +168,8 @@ def main(args=None, callback=ERmain):
for yaml in weights_cache[path]:
if category_name is None:
for category in yaml:
if category in AutoWorldRegister.world_types and key in Options.common_options:
if category in AutoWorldRegister.world_types and \
key in Options.CommonOptions.type_hints:
yaml[category][key] = option
elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
@@ -168,7 +180,7 @@ def main(args=None, callback=ERmain):
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter()
erargs.player_settings = {}
erargs.player_options = {}
player = 1
while player <= args.multi:
@@ -224,7 +236,7 @@ def main(args=None, callback=ERmain):
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
yaml.dump(important, f)
callback(erargs, seed)
return callback(erargs, seed)
def read_weights_yamls(path) -> Tuple[Any, ...]:
@@ -340,7 +352,7 @@ def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types:
game_world = AutoWorldRegister.world_types[game]
options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
options = game_world.options_dataclass.type_hints
if option_key in options:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
@@ -445,8 +457,8 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
f"which is not enabled.")
ret = argparse.Namespace()
for option_key in Options.per_game_common_options:
if option_key in weights and option_key not in Options.common_options:
for option_key in Options.PerGameCommonOptions.type_hints:
if option_key in weights and option_key not in Options.CommonOptions.type_hints:
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
ret.game = get_choice("game", weights)
@@ -466,16 +478,11 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
game_weights = weights[ret.game]
ret.name = get_choice('name', weights)
for option_key, option in Options.common_options.items():
for option_key, option in Options.CommonOptions.type_hints.items():
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
for option_key, option in world_type.option_definitions.items():
for option_key, option in world_type.options_dataclass.type_hints.items():
handle_option(ret, game_weights, option_key, option, plando_options)
for option_key, option in Options.per_game_common_options.items():
# skip setting this option if already set from common_options, defaulting to root option
if option_key not in world_type.option_definitions and \
(option_key not in Options.common_options or option_key in game_weights):
handle_option(ret, game_weights, option_key, option, plando_options)
if PlandoOptions.items in plando_options:
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
@@ -643,6 +650,15 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
if __name__ == '__main__':
import atexit
confirmation = atexit.register(input, "Press enter to close.")
main()
multiworld = main()
if __debug__:
import gc
import sys
import weakref
weak = weakref.ref(multiworld)
del multiworld
gc.collect() # need to collect to deref all hard references
assert not weak(), f"MultiWorld object was not de-allocated, it's referenced {sys.getrefcount(weak())} times." \
" This would be a memory leak."
# in case of error-free exit should not need confirmation
atexit.unregister(confirmation)

View File

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

View File

@@ -1004,6 +1004,7 @@ class SpriteSelector():
self.add_to_sprite_pool(sprite)
def icon_section(self, frame_label, path, no_results_label):
os.makedirs(path, exist_ok=True)
frame = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5)
frame.pack(side=TOP, fill=X)

View File

@@ -58,7 +58,7 @@ class MMBN3CommandProcessor(ClientCommandProcessor):
class MMBN3Context(CommonContext):
command_processor = MMBN3CommandProcessor
game = "MegaMan Battle Network 3"
items_handling = 0b001 # full local
items_handling = 0b101 # full local except starting items
def __init__(self, server_address, password):
super().__init__(server_address, password)

81
Main.py
View File

@@ -13,8 +13,8 @@ import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
from Options import StartInventoryPool
from settings import get_settings
from Utils import __version__, output_path, version_tuple
from settings import get_settings
from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -101,20 +101,35 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
del item_digits, location_digits, item_count, location_count
AutoWorld.call_stage(world, "assert_generate")
# This assertion method should not be necessary to run if we are not outputting any multidata.
if not args.skip_output:
AutoWorld.call_stage(world, "assert_generate")
AutoWorld.call_all(world, "generate_early")
logger.info('')
for player in world.player_ids:
for item_name, count in world.start_inventory[player].value.items():
for item_name, count in world.worlds[player].options.start_inventory.value.items():
for _ in range(count):
world.push_precollected(world.create_item(item_name, player))
for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
for item_name, count in getattr(world.worlds[player].options,
"start_inventory_from_pool",
StartInventoryPool({})).value.items():
for _ in range(count):
world.push_precollected(world.create_item(item_name, player))
# remove from_pool items also from early items handling, as starting is plenty early.
early = world.early_items[player].get(item_name, 0)
if early:
world.early_items[player][item_name] = max(0, early-count)
remaining_count = count-early
if remaining_count > 0:
local_early = world.early_local_items[player].get(item_name, 0)
if local_early:
world.early_items[player][item_name] = max(0, local_early - remaining_count)
del local_early
del early
logger.info('Creating World.')
AutoWorld.call_all(world, "create_regions")
@@ -122,23 +137,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info('Creating Items.')
AutoWorld.call_all(world, "create_items")
# All worlds should have finished creating all regions, locations, and entrances.
# Recache to ensure that they are all visible for locality rules.
world._recache()
logger.info('Calculating Access Rules.')
for player in world.player_ids:
# items can't be both local and non-local, prefer local
world.non_local_items[player].value -= world.local_items[player].value
world.non_local_items[player].value -= set(world.local_early_items[player])
world.worlds[player].options.non_local_items.value -= world.worlds[player].options.local_items.value
world.worlds[player].options.non_local_items.value -= set(world.local_early_items[player])
AutoWorld.call_all(world, "set_rules")
for player in world.player_ids:
exclusion_rules(world, player, world.exclude_locations[player].value)
world.priority_locations[player].value -= world.exclude_locations[player].value
for location_name in world.priority_locations[player].value:
exclusion_rules(world, player, world.worlds[player].options.exclude_locations.value)
world.worlds[player].options.priority_locations.value -= world.worlds[player].options.exclude_locations.value
for location_name in world.worlds[player].options.priority_locations.value:
try:
location = world.get_location(location_name, player)
except KeyError as e: # failed to find the given location. Check if it's a legitimate location
@@ -151,17 +162,21 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if world.players > 1:
locality_rules(world)
else:
world.non_local_items[1].value = set()
world.local_items[1].value = set()
world.worlds[1].options.non_local_items.value = set()
world.worlds[1].options.local_items.value = set()
AutoWorld.call_all(world, "generate_basic")
# remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible.
if any(world.start_inventory_from_pool[player].value for player in world.player_ids):
if any(getattr(world.worlds[player].options, "start_inventory_from_pool", None) for player in world.player_ids):
new_items: List[Item] = []
depletion_pool: Dict[int, Dict[str, int]] = {
player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids}
player: getattr(world.worlds[player].options,
"start_inventory_from_pool",
StartInventoryPool({})).value.copy()
for player in world.player_ids
}
for player, items in depletion_pool.items():
player_world: AutoWorld.World = world.worlds[player]
for count in items.values():
@@ -233,7 +248,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
region = Region("Menu", group_id, world, "ItemLink")
world.regions.append(region)
locations = region.locations = []
locations = region.locations
for item in world.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
@@ -267,10 +282,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
if any(world.item_links.values()):
world._recache()
world._all_state = None
logger.info("Running Item Plando")
logger.info("Running Item Plando.")
distribute_planned(world)
@@ -292,24 +306,28 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
else:
logger.info("Progression balancing skipped.")
logger.info(f'Beginning output...')
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
world.random.passthrough = False
if args.skip_output:
logger.info('Done. Skipped output/spoiler generation. Total Time: %s', time.perf_counter() - start)
return world
logger.info(f'Beginning output...')
outfilebase = 'AP_' + world.seed_name
output = tempfile.TemporaryDirectory()
with output as temp_dir:
with concurrent.futures.ThreadPoolExecutor(world.players + 2) as pool:
output_players = [player for player in world.player_ids if AutoWorld.World.generate_output.__code__
is not world.worlds[player].generate_output.__code__]
with concurrent.futures.ThreadPoolExecutor(len(output_players) + 2) as pool:
check_accessibility_task = pool.submit(world.fulfills_accessibility)
output_file_futures = [pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir)]
for player in world.player_ids:
for player in output_players:
# skip starting a thread for methods that say "pass".
if AutoWorld.World.generate_output.__code__ is not world.worlds[player].generate_output.__code__:
output_file_futures.append(
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
output_file_futures.append(
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
# collect ER hint info
er_hint_data: Dict[int, Dict[int, str]] = {}
@@ -358,13 +376,16 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None. Location: " \
f" {location}"
assert location.address not in locations_data[location.player], (
f"Locations with duplicate address. {location} and "
f"{locations_data[location.player][location.address]}")
locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags
if location.name in world.start_location_hints[location.player]:
if location.name in world.worlds[location.player].options.start_location_hints:
precollect_hint(location)
elif location.item.name in world.start_hints[location.item.player]:
elif location.item.name in world.worlds[location.item.player].options.start_hints:
precollect_hint(location)
elif any([location.item.name in world.start_hints[player]
elif any([location.item.name in world.worlds[player].options.start_hints
for player in world.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location)

View File

@@ -4,14 +4,29 @@ import subprocess
import multiprocessing
import warnings
local_dir = os.path.dirname(__file__)
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
if sys.version_info < (3, 8, 6):
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process()
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())
update_ran = _skip_update
class RequirementsSet(set):
def add(self, e):
global update_ran
update_ran &= _skip_update
super().add(e)
def update(self, *s):
global update_ran
update_ran &= _skip_update
super().update(*s)
local_dir = os.path.dirname(__file__)
requirements_files = RequirementsSet((os.path.join(local_dir, 'requirements.txt'),))
if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")):
@@ -67,14 +82,23 @@ def update(yes=False, force=False):
install_pkg_resources(yes=yes)
import pkg_resources
prev = "" # if a line ends in \ we store here and merge later
for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
if not os.path.exists(path):
path = os.path.join(os.path.dirname(__file__), req_file)
with open(path) as requirementsfile:
for line in requirementsfile:
if not line or line[0] == "#":
continue # ignore comments
if not line or line.lstrip(" \t")[0] == "#":
if not prev:
continue # ignore comments
line = ""
elif line.rstrip("\r\n").endswith("\\"):
prev = prev + line.rstrip("\r\n")[:-1] + " " # continue on next line
continue
line = prev + line
line = line.split("--hash=")[0] # remove hashes from requirement for version checking
prev = ""
if line.startswith(("https://", "git+https://")):
# extract name and version for url
rest = line.split('/')[-1]

View File

@@ -2,14 +2,15 @@ from __future__ import annotations
import argparse
import asyncio
import copy
import collections
import copy
import datetime
import functools
import hashlib
import inspect
import itertools
import logging
import math
import operator
import pickle
import random
@@ -67,21 +68,25 @@ def update_dict(dictionary, entries):
# functions callable on storable data on the server by clients
modify_functions = {
"add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append)
"mul": operator.mul,
"mod": operator.mod,
"max": max,
"min": min,
# generic:
"replace": lambda old, new: new,
"default": lambda old, new: old,
# numeric:
"add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append)
"mul": operator.mul,
"pow": operator.pow,
"mod": operator.mod,
"floor": lambda value, _: math.floor(value),
"ceil": lambda value, _: math.ceil(value),
"max": max,
"min": min,
# bitwise:
"xor": operator.xor,
"or": operator.or_,
"and": operator.and_,
"left_shift": operator.lshift,
"right_shift": operator.rshift,
# lists/dicts
# lists/dicts:
"remove": remove_from_list,
"pop": pop_from_container,
"update": update_dict,
@@ -412,6 +417,8 @@ class Context:
self.player_name_lookup[slot_info.name] = 0, slot_id
self.read_data[f"hints_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
list(self.get_rechecked_hints(local_team, local_player))
self.read_data[f"client_status_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
self.client_game_state[local_team, local_player]
self.seed_name = decoded_obj["seed_name"]
self.random.seed(self.seed_name)
@@ -707,6 +714,12 @@ class Context:
"hint_points": get_slot_points(self, team, slot)
}])
def on_client_status_change(self, team: int, slot: int):
key: str = f"_read_client_status_{team}_{slot}"
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
if targets:
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.client_game_state[team, slot]}])
def update_aliases(ctx: Context, team: int):
cmd = ctx.dumper([{"cmd": "RoomUpdate",
@@ -1814,6 +1827,7 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
ctx.on_goal_achieved(client)
ctx.client_game_state[client.team, client.slot] = new_status
ctx.on_client_status_change(client.team, client.slot)
ctx.save()
@@ -2196,25 +2210,24 @@ def parse_args() -> argparse.Namespace:
async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(ctx.auto_shutdown)
def inactivity_shutdown():
ctx.server.ws_server.close()
ctx.exit_event.set()
if to_cancel:
for task in to_cancel:
task.cancel()
logging.info("Shutting down due to inactivity.")
while not ctx.exit_event.is_set():
if not ctx.client_activity_timers.values():
ctx.server.ws_server.close()
ctx.exit_event.set()
if to_cancel:
for task in to_cancel:
task.cancel()
logging.info("Shutting down due to inactivity.")
inactivity_shutdown()
else:
newest_activity = max(ctx.client_activity_timers.values())
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
seconds = ctx.auto_shutdown - delta.total_seconds()
if seconds < 0:
ctx.server.ws_server.close()
ctx.exit_event.set()
if to_cancel:
for task in to_cancel:
task.cancel()
logging.info("Shutting down due to inactivity.")
inactivity_shutdown()
else:
await asyncio.sleep(seconds)

View File

@@ -2,6 +2,9 @@ from __future__ import annotations
import abc
import logging
from copy import deepcopy
from dataclasses import dataclass
import functools
import math
import numbers
import random
@@ -211,6 +214,12 @@ class NumericOption(Option[int], numbers.Integral, abc.ABC):
else:
return self.value > other
def __ge__(self, other: typing.Union[int, NumericOption]) -> bool:
if isinstance(other, NumericOption):
return self.value >= other.value
else:
return self.value >= other
def __bool__(self) -> bool:
return bool(self.value)
@@ -687,11 +696,19 @@ class Range(NumericOption):
return int(round(random.triangular(lower, end, tri), 0))
class SpecialRange(Range):
special_range_cutoff = 0
class NamedRange(Range):
special_range_names: typing.Dict[str, int] = {}
"""Special Range names have to be all lowercase as matching is done with text.lower()"""
def __init__(self, value: int) -> None:
if value < self.range_start and value not in self.special_range_names.values():
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}")
elif value > self.range_end and value not in self.special_range_names.values():
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}")
self.value = value
@classmethod
def from_text(cls, text: str) -> Range:
text = text.lower()
@@ -699,6 +716,19 @@ class SpecialRange(Range):
return cls(cls.special_range_names[text])
return super().from_text(text)
class SpecialRange(NamedRange):
special_range_cutoff = 0
# TODO: remove class SpecialRange, earliest 3 releases after 0.4.3
def __new__(cls, value: int) -> SpecialRange:
from Utils import deprecate
deprecate(f"Option type {cls.__name__} is a subclass of SpecialRange, which is deprecated and pending removal. "
"Consider switching to NamedRange, which supports all use-cases of SpecialRange, and more. In "
"NamedRange, range_start specifies the lower end of the regular range, while special values can be "
"placed anywhere (below, inside, or above the regular range).")
return super().__new__(cls, value)
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
@@ -882,7 +912,7 @@ class Accessibility(Choice):
default = 1
class ProgressionBalancing(SpecialRange):
class ProgressionBalancing(NamedRange):
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
A lower setting means more getting stuck. A higher setting means less getting stuck."""
default = 50
@@ -896,10 +926,58 @@ class ProgressionBalancing(SpecialRange):
}
common_options = {
"progression_balancing": ProgressionBalancing,
"accessibility": Accessibility
}
class OptionsMetaProperty(type):
def __new__(mcs,
name: str,
bases: typing.Tuple[type, ...],
attrs: typing.Dict[str, typing.Any]) -> "OptionsMetaProperty":
for attr_type in attrs.values():
assert not isinstance(attr_type, AssembleOptions),\
f"Options for {name} should be type hinted on the class, not assigned"
return super().__new__(mcs, name, bases, attrs)
@property
@functools.lru_cache(maxsize=None)
def type_hints(cls) -> typing.Dict[str, typing.Type[Option[typing.Any]]]:
"""Returns type hints of the class as a dictionary."""
return typing.get_type_hints(cls)
@dataclass
class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing
accessibility: Accessibility
def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
"""
Returns a dictionary of [str, Option.value]
:param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
"""
option_results = {}
for option_name in option_names:
if option_name in type(self).type_hints:
if casing == "snake":
display_name = option_name
elif casing == "camel":
split_name = [name.title() for name in option_name.split("_")]
split_name[0] = split_name[0].lower()
display_name = "".join(split_name)
elif casing == "pascal":
display_name = "".join([name.title() for name in option_name.split("_")])
elif casing == "kebab":
display_name = option_name.replace("_", "-")
else:
raise ValueError(f"{casing} is invalid casing for as_dict. "
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
option_results[display_name] = value
else:
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
return option_results
class LocalItems(ItemSet):
@@ -1020,17 +1098,16 @@ class ItemLinks(OptionList):
link.setdefault("link_replacement", None)
per_game_common_options = {
**common_options, # can be overwritten per-game
"local_items": LocalItems,
"non_local_items": NonLocalItems,
"start_inventory": StartInventory,
"start_hints": StartHints,
"start_location_hints": StartLocationHints,
"exclude_locations": ExcludeLocations,
"priority_locations": PriorityLocations,
"item_links": ItemLinks
}
@dataclass
class PerGameCommonOptions(CommonOptions):
local_items: LocalItems
non_local_items: NonLocalItems
start_inventory: StartInventory
start_hints: StartHints
start_location_hints: StartLocationHints
exclude_locations: ExcludeLocations
priority_locations: PriorityLocations
item_links: ItemLinks
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
@@ -1052,7 +1129,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
os.unlink(full_path)
def dictify_range(option: typing.Union[Range, SpecialRange]):
def dictify_range(option: Range):
data = {option.default: 50}
for sub_option in ["random", "random-low", "random-high"]:
if sub_option != option.default:
@@ -1071,10 +1148,7 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
all_options: typing.Dict[str, AssembleOptions] = {
**per_game_common_options,
**world.option_definitions
}
all_options: typing.Dict[str, AssembleOptions] = world.options_dataclass.type_hints
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()

View File

@@ -1,382 +0,0 @@
import asyncio
import json
import time
import os
import bsdiff4
import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
from worlds.pokemon_rb.locations import location_data
from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}}
location_bytes_bits = {}
for location in location_data:
if location.ram_address is not None:
if type(location.ram_address) == list:
location_map[type(location.ram_address).__name__][(location.ram_address[0].flag, location.ram_address[1].flag)] = location.address
location_bytes_bits[location.address] = [{'byte': location.ram_address[0].byte, 'bit': location.ram_address[0].bit},
{'byte': location.ram_address[1].byte, 'bit': location.ram_address[1].bit}]
else:
location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address
location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit}
location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item"
and location.address is not None}
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure pkmn_rb.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart pkmn_rb.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
SCRIPT_VERSION = 3
class GBCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_gb(self):
"""Check Gameboy Connection State"""
if isinstance(self.ctx, GBContext):
logger.info(f"Gameboy Status: {self.ctx.gb_status}")
class GBContext(CommonContext):
command_processor = GBCommandProcessor
game = 'Pokemon Red and Blue'
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.gb_streams: (StreamReader, StreamWriter) = None
self.gb_sync_task = None
self.messages = {}
self.locations_array = None
self.gb_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
self.deathlink_pending = False
self.set_deathlink = False
self.client_compatibility_mode = 0
self.items_handling = 0b001
self.sent_release = False
self.sent_collect = False
self.auto_hints = set()
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(GBContext, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to EmuHawk to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if 'death_link' in args['slot_data'] and args['slot_data']['death_link']:
self.set_deathlink = True
elif cmd == "RoomInfo":
self.seed_name = args['seed_name']
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_deathlink(self, data: dict):
self.deathlink_pending = True
super().on_deathlink(data)
def run_gui(self):
from kvui import GameManager
class GBManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Pokémon Client"
self.ui = GBManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: GBContext):
current_time = time.time()
ret = json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10},
"deathlink": ctx.deathlink_pending,
"options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled'))
}
)
ctx.deathlink_pending = False
return ret
async def parse_locations(data: List, ctx: GBContext):
locations = []
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E],
"Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]}
if len(data) > 0x140 + 0x20 + 0x0E + 0x01:
flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:]
else:
flags["DexSanityFlag"] = [0] * 19
for flag_type, loc_map in location_map.items():
for flag, loc_id in loc_map.items():
if flag_type == "list":
if (flags["EventFlag"][location_bytes_bits[loc_id][0]['byte']] & 1 << location_bytes_bits[loc_id][0]['bit']
and flags["Missable"][location_bytes_bits[loc_id][1]['byte']] & 1 << location_bytes_bits[loc_id][1]['bit']):
locations.append(loc_id)
elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']:
locations.append(loc_id)
hints = []
if flags["EventFlag"][280] & 16:
hints.append("Cerulean Bicycle Shop")
if flags["EventFlag"][280] & 32:
hints.append("Route 2 Gate - Oak's Aide")
if flags["EventFlag"][280] & 64:
hints.append("Route 11 Gate 2F - Oak's Aide")
if flags["EventFlag"][280] & 128:
hints.append("Route 15 Gate 2F - Oak's Aide")
if flags["EventFlag"][281] & 1:
hints += ["Celadon Prize Corner - Item Prize 1", "Celadon Prize Corner - Item Prize 2",
"Celadon Prize Corner - Item Prize 3"]
if (location_name_to_id["Fossil - Choice A"] in ctx.checked_locations and location_name_to_id["Fossil - Choice B"]
not in ctx.checked_locations):
hints.append("Fossil - Choice B")
elif (location_name_to_id["Fossil - Choice B"] in ctx.checked_locations and location_name_to_id["Fossil - Choice A"]
not in ctx.checked_locations):
hints.append("Fossil - Choice A")
hints = [
location_name_to_id[loc] for loc in hints if location_name_to_id[loc] not in ctx.auto_hints and
location_name_to_id[loc] in ctx.missing_locations and location_name_to_id[loc] not in ctx.locations_checked
]
if hints:
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": hints, "create_as_hint": 2}])
ctx.auto_hints.update(hints)
if flags["EventFlag"][280] & 1 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
if locations == ctx.locations_array:
return
ctx.locations_array = locations
if locations is not None:
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
async def gb_sync_task(ctx: GBContext):
logger.info("Starting GB connector. Use /gb for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.gb_streams:
(reader, writer) = ctx.gb_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
msg = "You are connecting with an incompatible Lua script version. Ensure your connector Lua " \
"and PokemonClient are from the same Archipelago installation."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.client_compatibility_mode = data_decoded['clientCompatibilityVersion']
if ctx.client_compatibility_mode == 0:
ctx.items_handling = 0b101 # old patches will not have local start inventory, must be requested
if ctx.seed_name and ctx.seed_name != ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]):
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.seed_name = ''.join([chr(i) for i in data_decoded['seedName'] if i != 0])
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
msg = "Invalid ROM detected. No player name built into the ROM."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
if ctx.awaiting_rom:
await ctx.server_auth(False)
if 'locations' in data_decoded and ctx.game and ctx.gb_status == CONNECTION_CONNECTED_STATUS \
and not error_status and ctx.auth:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx))
if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags:
await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!")
if 'options' in data_decoded:
msgs = []
if data_decoded['options'] & 4 and not ctx.sent_release:
ctx.sent_release = True
msgs.append({"cmd": "Say", "text": "!release"})
if data_decoded['options'] & 8 and not ctx.sent_collect:
ctx.sent_collect = True
msgs.append({"cmd": "Say", "text": "!collect"})
if msgs:
await ctx.send_msgs(msgs)
if ctx.set_deathlink:
await ctx.update_death_link(True)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.gb_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.gb_streams = None
if ctx.gb_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to Gameboy")
ctx.gb_status = CONNECTION_CONNECTED_STATUS
else:
ctx.gb_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.gb_status = error_status
logger.info("Lost connection to Gameboy and attempting to reconnect. Use /gb for status updates")
else:
try:
logger.debug("Attempting to connect to Gameboy")
ctx.gb_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 17242), timeout=10)
ctx.gb_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.gb_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.gb_status = CONNECTION_REFUSED_STATUS
continue
async def run_game(romfile):
auto_start = Utils.get_options()["pokemon_rb_options"].get("rom_start", True)
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def patch_and_run_game(game_version, patch_file, ctx):
base_name = os.path.splitext(patch_file)[0]
comp_path = base_name + '.gb'
if game_version == "blue":
delta_patch = BlueDeltaPatch
else:
delta_patch = RedDeltaPatch
try:
base_rom = delta_patch.get_source_data()
except Exception as msg:
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
with patch_archive.open('delta.bsdiff4', 'r') as stream:
patch = stream.read()
patched_rom_data = bsdiff4.patch(base_rom, patch)
with open(comp_path, "wb") as patched_rom_file:
patched_rom_file.write(patched_rom_data)
async_start(run_game(comp_path))
if __name__ == '__main__':
Utils.init_logging("PokemonClient")
options = Utils.get_options()
async def main():
parser = get_base_parser()
parser.add_argument('patch_file', default="", type=str, nargs="?",
help='Path to an APRED or APBLUE patch file')
args = parser.parse_args()
ctx = GBContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.gb_sync_task = asyncio.create_task(gb_sync_task(ctx), name="GB Sync")
if args.patch_file:
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
if ext == "apred":
logger.info("APRED file supplied, beginning patching process...")
async_start(patch_and_run_game("red", args.patch_file, ctx))
elif ext == "apblue":
logger.info("APBLUE file supplied, beginning patching process...")
async_start(patch_and_run_game("blue", args.patch_file, ctx))
else:
logger.warning(f"Unknown patch file extension {ext}")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.gb_sync_task:
await ctx.gb_sync_task
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -51,6 +51,14 @@ Currently, the following games are supported:
* Muse Dash
* DOOM 1993
* Terraria
* Lingo
* Pokémon Emerald
* DOOM II
* Shivers
* Heretic
* Landstalker: The Treasures of King Nole
* Final Fantasy Mystic Quest
* TUNIC
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -207,12 +207,12 @@ class SNIContext(CommonContext):
self.killing_player_task = asyncio.create_task(deathlink_kill_player(self))
super(SNIContext, self).on_deathlink(data)
async def handle_deathlink_state(self, currently_dead: bool) -> None:
async def handle_deathlink_state(self, currently_dead: bool, death_text: str = "") -> None:
# in this state we only care about triggering a death send
if self.death_state == DeathState.alive:
if currently_dead:
self.death_state = DeathState.dead
await self.send_death()
await self.send_death(death_text)
# in this state we care about confirming a kill, to move state to dead
elif self.death_state == DeathState.killing_player:
# this is being handled in deathlink_kill_player(ctx) already

View File

@@ -27,14 +27,14 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
self.ctx.syncing = True
def _cmd_patch(self):
"""Patch the game."""
"""Patch the game. Only use this command if /auto_patch fails."""
if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True)
self.ctx.patch_game()
self.output("Patched.")
def _cmd_savepath(self, directory: str):
"""Redirect to proper save data folder. (Use before connecting!)"""
"""Redirect to proper save data folder. This is necessary for Linux users to use before connecting."""
if isinstance(self.ctx, UndertaleContext):
self.ctx.save_game_folder = directory
self.output("Changed to the following directory: " + self.ctx.save_game_folder)
@@ -67,7 +67,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
self.output("Patching successful!")
def _cmd_online(self):
"""Makes you no longer able to see other Undertale players."""
"""Toggles seeing other Undertale players."""
if isinstance(self.ctx, UndertaleContext):
self.ctx.update_online_mode(not ("Online" in self.ctx.tags))
if "Online" in self.ctx.tags:

106
Utils.py
View File

@@ -5,6 +5,7 @@ import json
import typing
import builtins
import os
import itertools
import subprocess
import sys
import pickle
@@ -46,7 +47,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.4.3"
__version__ = "0.4.4"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -73,6 +74,8 @@ def snes_to_pc(value: int) -> int:
RetType = typing.TypeVar("RetType")
S = typing.TypeVar("S")
T = typing.TypeVar("T")
def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[], RetType]:
@@ -90,6 +93,31 @@ def cache_argsless(function: typing.Callable[[], RetType]) -> typing.Callable[[]
return _wrap
def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[S, T], RetType]:
"""Specialized cache for self + 1 arg. Does not keep global ref to self and skips building a dict key tuple."""
assert function.__code__.co_argcount == 2, "Can only cache 2 argument functions with this cache."
cache_name = f"__cache_{function.__name__}__"
@functools.wraps(function)
def wrap(self: S, arg: T) -> RetType:
cache: Optional[Dict[T, RetType]] = typing.cast(Optional[Dict[T, RetType]],
getattr(self, cache_name, None))
if cache is None:
res = function(self, arg)
setattr(self, cache_name, {arg: res})
return res
try:
return cache[arg]
except KeyError:
res = function(self, arg)
cache[arg] = res
return res
return wrap
def is_frozen() -> bool:
return typing.cast(bool, getattr(sys, 'frozen', False))
@@ -146,12 +174,16 @@ def user_path(*path: str) -> str:
if user_path.cached_path != local_path():
import filecmp
if not os.path.exists(user_path("manifest.json")) or \
not os.path.exists(local_path("manifest.json")) or \
not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True):
import shutil
for dn in ("Players", "data/sprites"):
for dn in ("Players", "data/sprites", "data/lua"):
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
for fn in ("manifest.json",):
shutil.copy2(local_path(fn), user_path(fn))
if not os.path.exists(local_path("manifest.json")):
warnings.warn(f"Upgrading {user_path()} from something that is not a proper install")
else:
shutil.copy2(local_path("manifest.json"), user_path("manifest.json"))
os.makedirs(user_path("worlds"), exist_ok=True)
return os.path.join(user_path.cached_path, *path)
@@ -257,15 +289,13 @@ def get_public_ipv6() -> str:
return ip
OptionsType = Settings # TODO: remove ~2 versions after 0.4.1
OptionsType = Settings # TODO: remove when removing get_options
@cache_argsless
def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1
return Settings(None)
get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported
def get_options() -> Settings:
# TODO: switch to Utils.deprecate after 0.4.4
warnings.warn("Utils.get_options() is deprecated. Use the settings API instead.", DeprecationWarning)
return get_settings()
def persistent_store(category: str, key: typing.Any, value: typing.Any):
@@ -459,11 +489,21 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
write_mode,
encoding="utf-8-sig")
file_handler.setFormatter(logging.Formatter(log_format))
class Filter(logging.Filter):
def __init__(self, filter_name, condition):
super().__init__(filter_name)
self.condition = condition
def filter(self, record: logging.LogRecord) -> bool:
return self.condition(record)
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
root_logger.addHandler(file_handler)
if sys.stdout:
root_logger.addHandler(
logging.StreamHandler(sys.stdout)
)
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
root_logger.addHandler(stream_handler)
# Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
@@ -670,6 +710,11 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
if zenity:
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
elif is_windows:
import ctypes
style = 0x10 if error else 0x0
return ctypes.windll.user32.MessageBoxW(0, text, title, style)
# fall back to tk
try:
import tkinter
@@ -734,6 +779,25 @@ def deprecate(message: str):
import warnings
warnings.warn(message)
class DeprecateDict(dict):
log_message: str
should_error: bool
def __init__(self, message, error: bool = False) -> None:
self.log_message = message
self.should_error = error
super().__init__()
def __getitem__(self, item: Any) -> Any:
if self.should_error:
deprecate(self.log_message)
elif __debug__:
import warnings
warnings.warn(self.log_message)
return super().__getitem__(item)
def _extend_freeze_support() -> None:
"""Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn."""
# upstream issue: https://github.com/python/cpython/issues/76327
@@ -890,3 +954,17 @@ def visualize_regions(root_region: Region, file_name: str, *,
with open(file_name, "wt", encoding="utf-8") as f:
f.write("\n".join(uml))
class RepeatableChain:
def __init__(self, iterable: typing.Iterable):
self.iterable = iterable
def __iter__(self):
return itertools.chain.from_iterable(self.iterable)
def __bool__(self):
return any(sub_iterable for sub_iterable in self.iterable)
def __len__(self):
return sum(len(iterable) for iterable in self.iterable)

View File

@@ -113,6 +113,9 @@ class WargrooveContext(CommonContext):
async def connection_closed(self):
await super(WargrooveContext, self).connection_closed()
self.remove_communication_files()
self.checked_locations.clear()
self.server_locations.clear()
self.finished_game = False
@property
def endpoints(self):
@@ -124,6 +127,9 @@ class WargrooveContext(CommonContext):
async def shutdown(self):
await super(WargrooveContext, self).shutdown()
self.remove_communication_files()
self.checked_locations.clear()
self.server_locations.clear()
self.finished_game = False
def remove_communication_files(self):
for root, dirs, files in os.walk(self.game_communication_path):
@@ -402,8 +408,10 @@ async def game_watcher(ctx: WargrooveContext):
if file.find("send") > -1:
st = file.split("send", -1)[1]
sending = sending+[(int(st))]
os.remove(os.path.join(ctx.game_communication_path, file))
if file.find("victory") > -1:
victory = True
os.remove(os.path.join(ctx.game_communication_path, file))
ctx.locations_checked = sending
message = [{"cmd": 'LocationChecks', "locations": sending}]
await ctx.send_msgs(message)

View File

@@ -50,7 +50,6 @@ app.config["PONY"] = {
}
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "SimpleCache"
app.config["JSON_AS_ASCII"] = False
app.config["HOST_ADDRESS"] = ""
cache = Cache()

View File

@@ -20,8 +20,8 @@ def generate_api():
race = False
meta_options_source = {}
if 'file' in request.files:
file = request.files['file']
options = get_yaml_data(file)
files = request.files.getlist('file')
options = get_yaml_data(files)
if isinstance(options, Markup):
return {"text": options.striptags()}, 400
if isinstance(options, str):

View File

@@ -1,17 +1,13 @@
import os
import zipfile
from typing import *
import base64
from typing import Union, Dict, Set, Tuple
from flask import request, flash, redirect, url_for, render_template
from markupsafe import Markup
from WebHostLib import app
banned_zip_contents = (".sfc",)
def allowed_file(filename):
return filename.endswith(('.txt', ".yaml", ".zip"))
from WebHostLib.upload import allowed_options, allowed_options_extensions, banned_file
from Generate import roll_settings, PlandoOptions
from Utils import parse_yamls
@@ -30,7 +26,15 @@ def check():
flash(options)
else:
results, _ = roll_options(options)
return render_template("checkResult.html", results=results)
if len(options) > 1:
# offer combined file back
combined_yaml = "---\n".join(f"# original filename: {file_name}\n{file_content.decode('utf-8-sig')}"
for file_name, file_content in options.items())
combined_yaml = base64.b64encode(combined_yaml.encode("utf-8-sig")).decode()
else:
combined_yaml = ""
return render_template("checkResult.html",
results=results, combined_yaml=combined_yaml)
return render_template("check.html")
@@ -41,33 +45,42 @@ def mysterycheck():
def get_yaml_data(files) -> Union[Dict[str, str], str, Markup]:
options = {}
for file in files:
# if user does not select file, browser also
# submit an empty part without filename
if file.filename == '':
return 'No selected file'
elif file.filename in options:
return f'Conflicting files named {file.filename} submitted'
elif file and allowed_file(file.filename):
if file.filename.endswith(".zip"):
for uploaded_file in files:
if banned_file(uploaded_file.filename):
return ("Uploaded data contained a rom file, which is likely to contain copyrighted material. "
"Your file was deleted.")
# If the user does not select file, the browser will still submit an empty string without a file name.
elif uploaded_file.filename == "":
return "No selected file."
elif uploaded_file.filename in options:
return f"Conflicting files named {uploaded_file.filename} submitted."
elif uploaded_file and allowed_options(uploaded_file.filename):
if uploaded_file.filename.endswith(".zip"):
if not zipfile.is_zipfile(uploaded_file):
return f"Uploaded file {uploaded_file.filename} is not a valid .zip file and cannot be opened."
with zipfile.ZipFile(file, 'r') as zfile:
infolist = zfile.infolist()
uploaded_file.seek(0) # offset from is_zipfile check
with zipfile.ZipFile(uploaded_file, "r") as zfile:
for file in zfile.infolist():
# Remove folder pathing from str (e.g. "__MACOSX/" folder paths from archives created by macOS).
base_filename = os.path.basename(file.filename)
if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
for file in infolist:
if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted."
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
if base_filename.endswith(".archipelago"):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
elif base_filename.endswith(".zip"):
return "Nested .zip files inside a .zip are not supported."
elif banned_file(base_filename):
return ("Uploaded data contained a rom file, which is likely to contain copyrighted "
"material. Your file was deleted.")
# Ignore dot-files.
elif not base_filename.startswith(".") and allowed_options(base_filename):
options[file.filename] = zfile.open(file, "r").read()
else:
options[file.filename] = file.read()
options[uploaded_file.filename] = uploaded_file.read()
if not options:
return "Did not find a .yaml file to process."
return f"Did not find any valid files to process. Accepted formats: {allowed_options_extensions}"
return options

View File

@@ -27,8 +27,10 @@ from .models import Command, GameDataPackage, Room, db
class CustomClientMessageProcessor(ClientMessageProcessor):
ctx: WebHostContext
def _cmd_video(self, platform, user):
"""Set a link for your name in the WebHostLib tracker pointing to a video stream"""
def _cmd_video(self, platform: str, user: str):
"""Set a link for your name in the WebHostLib tracker pointing to a video stream.
Currently, only YouTube and Twitch platforms are supported.
"""
if platform.lower().startswith("t"): # twitch
self.ctx.video[self.client.team, self.client.slot] = "Twitch", user
self.ctx.save()
@@ -203,6 +205,12 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
# ensure auto launch is on the same page in regard to room activity.
with db_session:
room: Room = Room.get(id=ctx.room_id)
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(seconds=room.timeout + 60)
logging.info("Shutting down")
with Locker(room_id):

View File

@@ -90,6 +90,8 @@ def download_slot_file(room_id, player_id: int):
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
elif slot_data.game == "Kingdom Hearts 2":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
elif slot_data.game == "Final Fantasy Mystic Quest":
fname = f"AP+{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmq"
else:
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)

View File

@@ -1,18 +1,18 @@
import concurrent.futures
import json
import os
import pickle
import random
import tempfile
import zipfile
import concurrent.futures
from collections import Counter
from typing import Dict, Optional, Any, Union, List
from typing import Any, Dict, List, Optional, Union
from flask import request, flash, redirect, url_for, session, render_template
from flask import flash, redirect, render_template, request, session, url_for
from pony.orm import commit, db_session
from BaseClasses import seeddigits, get_seed
from Generate import handle_name, PlandoOptions
from BaseClasses import get_seed, seeddigits
from Generate import PlandoOptions, handle_name
from Main import main as ERmain
from Utils import __version__
from WebHostLib import app
@@ -131,6 +131,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False
erargs.skip_output = False
name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):

View File

@@ -37,17 +37,29 @@ def start_playing():
return render_template(f"startPlaying.html")
@app.route('/weighted-settings')
@cache.cached()
# TODO for back compat. remove around 0.4.5
@app.route("/weighted-settings")
def weighted_settings():
return render_template(f"weighted-settings.html")
return redirect("weighted-options", 301)
# Player settings pages
@app.route('/games/<string:game>/player-settings')
@app.route("/weighted-options")
@cache.cached()
def player_settings(game):
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
def weighted_options():
return render_template("weighted-options.html")
# TODO for back compat. remove around 0.4.5
@app.route("/games/<string:game>/player-settings")
def player_settings(game: str):
return redirect(url_for("player_options", game=game), 301)
# Player options pages
@app.route("/games/<string:game>/player-options")
@cache.cached()
def player_options(game: str):
return render_template("player-options.html", game=game, theme=get_world_theme(game))
# Game Info Pages
@@ -181,6 +193,6 @@ def get_sitemap():
available_games: List[Dict[str, Union[str, bool]]] = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
has_settings: bool = isinstance(world.web.settings_page, bool) and world.web.settings_page
has_settings: bool = isinstance(world.web.options_page, bool) and world.web.options_page
available_games.append({ 'title': game, 'has_settings': has_settings })
return render_template("siteMap.html", games=available_games)

View File

@@ -3,11 +3,8 @@ import logging
import os
import typing
import yaml
from jinja2 import Template
import Options
from Utils import __version__, local_path
from Utils import local_path
from worlds.AutoWorld import AutoWorldRegister
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
@@ -25,10 +22,10 @@ def create():
return "Please document me!"
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
weighted_settings = {
weighted_options = {
"baseOptions": {
"description": "Generated by https://archipelago.gg/",
"name": "Player",
"name": "",
"game": {},
},
"games": {},
@@ -36,17 +33,14 @@ def create():
for game_name, world in AutoWorldRegister.world_types.items():
all_options: typing.Dict[str, Options.AssembleOptions] = {
**Options.per_game_common_options,
**world.option_definitions
}
all_options: typing.Dict[str, Options.AssembleOptions] = world.options_dataclass.type_hints
# Generate JSON files for player-settings pages
player_settings = {
# Generate JSON files for player-options pages
player_options = {
"baseOptions": {
"description": f"Generated by https://archipelago.gg/ for {game_name}",
"game": game_name,
"name": "Player",
"name": "",
},
}
@@ -87,8 +81,8 @@ def create():
"max": option.range_end,
}
if issubclass(option, Options.SpecialRange):
game_options[option_name]["type"] = 'special_range'
if issubclass(option, Options.NamedRange):
game_options[option_name]["type"] = 'named_range'
game_options[option_name]["value_names"] = {}
for key, val in option.special_range_names.items():
game_options[option_name]["value_names"][key] = val
@@ -120,17 +114,53 @@ def create():
}
else:
logging.debug(f"{option} not exported to Web Settings.")
logging.debug(f"{option} not exported to Web Options.")
player_settings["gameOptions"] = game_options
player_options["gameOptions"] = game_options
os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True)
player_options["presetOptions"] = {}
for preset_name, preset in world.web.options_presets.items():
player_options["presetOptions"][preset_name] = {}
for option_name, option_value in preset.items():
# Random range type settings are not valid.
assert (not str(option_value).startswith("random-")), \
f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. Special random " \
f"values are not supported for presets."
with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
json.dump(player_settings, f, indent=2, separators=(',', ': '))
# Normal random is supported, but needs to be handled explicitly.
if option_value == "random":
player_options["presetOptions"][preset_name][option_name] = option_value
continue
if not world.hidden and world.web.settings_page is True:
# Add the random option to Choice, TextChoice, and Toggle settings
option = world.options_dataclass.type_hints[option_name].from_any(option_value)
if isinstance(option, Options.NamedRange) and isinstance(option_value, str):
assert option_value in option.special_range_names, \
f"Invalid preset value '{option_value}' for '{option_name}' in '{preset_name}'. " \
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
# Still use the true value for the option, not the name.
player_options["presetOptions"][preset_name][option_name] = option.value
elif isinstance(option, Options.Range):
player_options["presetOptions"][preset_name][option_name] = option.value
elif isinstance(option_value, str):
# For Choice and Toggle options, the value should be the name of the option. This is to prevent
# setting a preset for an option with an overridden from_text method that would normally be okay,
# but would not be okay for the webhost's current implementation of player options UI.
assert option.name_lookup[option.value] == option_value, \
f"Invalid option value '{option_value}' for '{option_name}' in preset '{preset_name}'. " \
f"Values must not be resolved to a different option via option.from_text (or an alias)."
player_options["presetOptions"][preset_name][option_name] = option.current_key
else:
# int and bool values are fine, just resolve them to the current key for webhost.
player_options["presetOptions"][preset_name][option_name] = option.current_key
os.makedirs(os.path.join(target_folder, 'player-options'), exist_ok=True)
with open(os.path.join(target_folder, 'player-options', game_name + ".json"), "w") as f:
json.dump(player_options, f, indent=2, separators=(',', ': '))
if not world.hidden and world.web.options_page is True:
# Add the random option to Choice, TextChoice, and Toggle options
for option in game_options.values():
if option["type"] == "select":
option["options"].append({"name": "Random", "value": "random"})
@@ -138,11 +168,21 @@ def create():
if not option["defaultValue"]:
option["defaultValue"] = "random"
weighted_settings["baseOptions"]["game"][game_name] = 0
weighted_settings["games"][game_name] = {}
weighted_settings["games"][game_name]["gameSettings"] = game_options
weighted_settings["games"][game_name]["gameItems"] = tuple(world.item_names)
weighted_settings["games"][game_name]["gameLocations"] = tuple(world.location_names)
weighted_options["baseOptions"]["game"][game_name] = 0
weighted_options["games"][game_name] = {
"gameSettings": game_options,
"gameItems": tuple(world.item_names),
"gameItemGroups": [
group for group in world.item_name_groups.keys() if group != "Everything"
],
"gameItemDescriptions": world.item_descriptions,
"gameLocations": tuple(world.location_names),
"gameLocationGroups": [
group for group in world.location_name_groups.keys() if group != "Everywhere"
],
"gameLocationDescriptions": world.location_descriptions,
}
with open(os.path.join(target_folder, 'weighted-options.json'), "w") as f:
json.dump(weighted_options, f, indent=2, separators=(',', ': '))
with open(os.path.join(target_folder, 'weighted-settings.json'), "w") as f:
json.dump(weighted_settings, f, indent=2, separators=(',', ': '))

View File

@@ -1,9 +1,9 @@
flask>=2.2.3
flask>=3.0.0
pony>=0.7.17
waitress>=2.1.2
Flask-Caching>=2.0.2
Flask-Caching>=2.1.0
Flask-Compress>=1.14
Flask-Limiter>=3.5.0
bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.2.2; python_version >= '3.9'
bokeh>=3.3.2; python_version >= '3.9'
markupsafe>=2.1.3

View File

@@ -2,13 +2,62 @@
## What is a randomizer?
A randomizer is a modification of a video game which reorganizes the items required to progress through the game. A
normal play-through of a game might require you to use item A to unlock item B, then C, and so forth. In a randomized
A randomizer is a modification of a game which reorganizes the items required to progress through that game. A
normal play-through might require you to use item A to unlock item B, then C, and so forth. In a randomized
game, you might first find item C, then A, then B.
This transforms games from a linear experience into a puzzle, presenting players with a new challenge each time they
play a randomized game. Putting items in non-standard locations can require the player to think about the game world and
the items they encounter in new and interesting ways.
This transforms the game from a linear experience into a puzzle, presenting players with a new challenge each time they
play. Putting items in non-standard locations can require the player to think about the game world and the items they
encounter in new and interesting ways.
## What is a multiworld?
While a randomizer shuffles a game, a multiworld randomizer shuffles that game for multiple players. For example, in a
two player multiworld, players A and B each get their own randomized version of a game, called a world. In each
player's game, they may find items which belong to the other player. If player A finds an item which belongs to
player B, the item will be sent to player B's world over the internet. This creates a cooperative experience, requiring
players to rely upon each other to complete their game.
## What does multi-game mean?
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
players to randomize any of the supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
Here is a list of our [Supported Games](https://archipelago.gg/games).
## Can I generate a single-player game with Archipelago?
Yes. All of our supported games can be generated as single-player experiences both on the website and by installing
the Archipelago generator software. The fastest way to do this is on the website. Find the Supported Game you wish to
play, open the Settings Page, pick your settings, and click Generate Game.
## How do I get started?
We have a [Getting Started](https://archipelago.gg/tutorial/Archipelago/setup/en) guide that will help you get the
software set up. You can use that guide to learn how to generate multiworlds. There are also basic instructions for
including multiple games, and hosting multiworlds on the website for ease and convenience.
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
any questions you might have.
## What are some common terms I should know?
As randomizers and multiworld randomizers have been around for a while now, there are quite a few common terms used
by the communities surrounding them. A list of Archipelago jargon and terms commonly used by the community can be
found in the [Glossary](/glossary/en).
## Does everyone need to be connected at the same time?
There are two different play-styles that are common for Archipelago multiworld sessions. These sessions can either
be considered synchronous (or "sync"), where everyone connects and plays at the same time, or asynchronous (or "async"),
where players connect and play at their own pace. The setup for both is identical. The difference in play-style is how
you and your friends choose to organize and play your multiworld. Most groups decide on the format before creating
their multiworld.
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all items
in that game belonging to other players are sent out automatically. This allows other players to continue to play
uninterrupted. Here is a list of all of our [Server Commands](https://archipelago.gg/tutorial/Archipelago/commands/en).
## What happens if an item is placed somewhere it is impossible to get?
@@ -17,53 +66,15 @@ 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
comfortable exploiting certain glitches in the game.
## What is a multi-world?
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a
two player multi-world, players A and B each get their own randomized version of a game, called a world. In each player's
game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the
item will be sent to player B's world over the internet.
This creates a cooperative experience during multi-world games, requiring players to rely upon each other to complete
their game.
## What happens if a person has to leave early?
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all the
items in that game which belong to other players are sent out automatically, so other players can continue to play.
## What does multi-game mean?
While a multi-world game traditionally requires all players to be playing the same game, a multi-game multi-world allows
players to randomize any of a number of supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment.
## Can I generate a single-player game with Archipelago?
Yes. All our supported games can be generated as single-player experiences, and so long as you download the software,
the website is not required to generate them.
## How do I get started?
If you are ready to start randomizing games, or want to start playing your favorite randomizer with others, please join
our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
any questions you might have.
## What are some common terms I should know?
As randomizers and multiworld randomizers have been around for a while now there are quite a lot of common terms
and jargon that is used in conjunction by the communities surrounding them. For a lot of the terms that are more common
to Archipelago and its specific systems please see the [Glossary](/glossary/en).
## I want to add a game to the Archipelago randomizer. How do I do that?
The best way to get started is to take a look at our code on GitHub
at [Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
The best way to get started is to take a look at our code on GitHub:
[Archipelago GitHub Page](https://github.com/ArchipelagoMW/Archipelago).
There you will find examples of games in the worlds folder
at [/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
There, you will find examples of games in the `worlds` folder:
[/worlds Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/worlds).
You may also find developer documentation in the docs folder
at [/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
You may also find developer documentation in the `docs` folder:
[/docs Folder in Archipelago Code](https://github.com/ArchipelagoMW/Archipelago/tree/main/docs).
If you have more questions, feel free to ask in the **#archipelago-dev** channel on our Discord.

View File

@@ -0,0 +1,523 @@
let gameName = null;
window.addEventListener('load', () => {
gameName = document.getElementById('player-options').getAttribute('data-game');
// Update game name on page
document.getElementById('game-name').innerText = gameName;
fetchOptionData().then((results) => {
let optionHash = localStorage.getItem(`${gameName}-hash`);
if (!optionHash) {
// If no hash data has been set before, set it now
optionHash = md5(JSON.stringify(results));
localStorage.setItem(`${gameName}-hash`, optionHash);
localStorage.removeItem(gameName);
}
if (optionHash !== md5(JSON.stringify(results))) {
showUserMessage(
'Your options are out of date! Click here to update them! Be aware this will reset them all to default.'
);
document.getElementById('user-message').addEventListener('click', resetOptions);
}
// Page setup
createDefaultOptions(results);
buildUI(results);
adjustHeaderWidth();
// Event listeners
document.getElementById('export-options').addEventListener('click', () => exportOptions());
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
document.getElementById('generate-game').addEventListener('click', () => generateGame());
// Name input field
const playerOptions = JSON.parse(localStorage.getItem(gameName));
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseOption(event));
nameInput.value = playerOptions.name;
// Presets
const presetSelect = document.getElementById('game-options-preset');
presetSelect.addEventListener('change', (event) => setPresets(results, event.target.value));
for (const preset in results['presetOptions']) {
const presetOption = document.createElement('option');
presetOption.innerText = preset;
presetSelect.appendChild(presetOption);
}
presetSelect.value = localStorage.getItem(`${gameName}-preset`);
results['presetOptions']['__default'] = {};
}).catch((e) => {
console.error(e);
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
});
const resetOptions = () => {
localStorage.removeItem(gameName);
localStorage.removeItem(`${gameName}-hash`);
localStorage.removeItem(`${gameName}-preset`);
window.location.reload();
};
const fetchOptionData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject(ajax.responseText);
return;
}
try{ resolve(JSON.parse(ajax.responseText)); }
catch(error){ reject(error); }
};
ajax.open('GET', `${window.location.origin}/static/generated/player-options/${gameName}.json`, true);
ajax.send();
});
const createDefaultOptions = (optionData) => {
if (!localStorage.getItem(gameName)) {
const newOptions = {
[gameName]: {},
};
for (let baseOption of Object.keys(optionData.baseOptions)){
newOptions[baseOption] = optionData.baseOptions[baseOption];
}
for (let gameOption of Object.keys(optionData.gameOptions)){
newOptions[gameName][gameOption] = optionData.gameOptions[gameOption].defaultValue;
}
localStorage.setItem(gameName, JSON.stringify(newOptions));
}
if (!localStorage.getItem(`${gameName}-preset`)) {
localStorage.setItem(`${gameName}-preset`, '__default');
}
};
const buildUI = (optionData) => {
// Game Options
const leftGameOpts = {};
const rightGameOpts = {};
Object.keys(optionData.gameOptions).forEach((key, index) => {
if (index < Object.keys(optionData.gameOptions).length / 2) {
leftGameOpts[key] = optionData.gameOptions[key];
} else {
rightGameOpts[key] = optionData.gameOptions[key];
}
});
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
};
const buildOptionsTable = (options, romOpts = false) => {
const currentOptions = JSON.parse(localStorage.getItem(gameName));
const table = document.createElement('table');
const tbody = document.createElement('tbody');
Object.keys(options).forEach((option) => {
const tr = document.createElement('tr');
// td Left
const tdl = document.createElement('td');
const label = document.createElement('label');
label.textContent = `${options[option].displayName}: `;
label.setAttribute('for', option);
const questionSpan = document.createElement('span');
questionSpan.classList.add('interactive');
questionSpan.setAttribute('data-tooltip', options[option].description);
questionSpan.innerText = '(?)';
label.appendChild(questionSpan);
tdl.appendChild(label);
tr.appendChild(tdl);
// td Right
const tdr = document.createElement('td');
let element = null;
const randomButton = document.createElement('button');
switch(options[option].type) {
case 'select':
element = document.createElement('div');
element.classList.add('select-container');
let select = document.createElement('select');
select.setAttribute('id', option);
select.setAttribute('data-key', option);
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
options[option].options.forEach((opt) => {
const optionElement = document.createElement('option');
optionElement.setAttribute('value', opt.value);
optionElement.innerText = opt.name;
if ((isNaN(currentOptions[gameName][option]) &&
(parseInt(opt.value, 10) === parseInt(currentOptions[gameName][option]))) ||
(opt.value === currentOptions[gameName][option]))
{
optionElement.selected = true;
}
select.appendChild(optionElement);
});
select.addEventListener('change', (event) => updateGameOption(event.target));
element.appendChild(select);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
}
element.appendChild(randomButton);
break;
case 'range':
element = document.createElement('div');
element.classList.add('range-container');
let range = document.createElement('input');
range.setAttribute('id', option);
range.setAttribute('type', 'range');
range.setAttribute('data-key', option);
range.setAttribute('min', options[option].min);
range.setAttribute('max', options[option].max);
range.value = currentOptions[gameName][option];
range.addEventListener('change', (event) => {
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
element.appendChild(range);
let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${option}-value`);
rangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
currentOptions[gameName][option] : options[option].defaultValue;
element.appendChild(rangeVal);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
}
element.appendChild(randomButton);
break;
case 'named_range':
element = document.createElement('div');
element.classList.add('named-range-container');
// Build the select element
let namedRangeSelect = document.createElement('select');
namedRangeSelect.setAttribute('data-key', option);
Object.keys(options[option].value_names).forEach((presetName) => {
let presetOption = document.createElement('option');
presetOption.innerText = presetName;
presetOption.value = options[option].value_names[presetName];
const words = presetOption.innerText.split('_');
for (let i = 0; i < words.length; i++) {
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
}
presetOption.innerText = words.join(' ');
namedRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');
customOption.innerText = 'Custom';
customOption.value = 'custom';
customOption.selected = true;
namedRangeSelect.appendChild(customOption);
if (Object.values(options[option].value_names).includes(Number(currentOptions[gameName][option]))) {
namedRangeSelect.value = Number(currentOptions[gameName][option]);
}
// Build range element
let namedRangeWrapper = document.createElement('div');
namedRangeWrapper.classList.add('named-range-wrapper');
let namedRange = document.createElement('input');
namedRange.setAttribute('type', 'range');
namedRange.setAttribute('data-key', option);
namedRange.setAttribute('min', options[option].min);
namedRange.setAttribute('max', options[option].max);
namedRange.value = currentOptions[gameName][option];
// Build rage value element
let namedRangeVal = document.createElement('span');
namedRangeVal.classList.add('range-value');
namedRangeVal.setAttribute('id', `${option}-value`);
namedRangeVal.innerText = currentOptions[gameName][option] !== 'random' ?
currentOptions[gameName][option] : options[option].defaultValue;
// Configure select event listener
namedRangeSelect.addEventListener('change', (event) => {
if (event.target.value === 'custom') { return; }
// Update range slider
namedRange.value = event.target.value;
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
// Configure range event handler
namedRange.addEventListener('change', (event) => {
// Update select element
namedRangeSelect.value =
(Object.values(options[option].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${option}-value`).innerText = event.target.value;
updateGameOption(event.target);
});
element.appendChild(namedRangeSelect);
namedRangeWrapper.appendChild(namedRange);
namedRangeWrapper.appendChild(namedRangeVal);
element.appendChild(namedRangeWrapper);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', option);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, namedRange, namedRangeSelect)
);
if (currentOptions[gameName][option] === 'random') {
randomButton.classList.add('active');
namedRange.disabled = true;
namedRangeSelect.disabled = true;
}
namedRangeWrapper.appendChild(randomButton);
break;
default:
console.error(`Ignoring unknown option type: ${options[option].type} with name ${option}`);
return;
}
tdr.appendChild(element);
tr.appendChild(tdr);
tbody.appendChild(tr);
});
table.appendChild(tbody);
return table;
};
const setPresets = (optionsData, presetName) => {
const defaults = optionsData['gameOptions'];
const preset = optionsData['presetOptions'][presetName];
localStorage.setItem(`${gameName}-preset`, presetName);
if (!preset) {
console.error(`No presets defined for preset name: '${presetName}'`);
return;
}
const updateOptionElement = (option, presetValue) => {
const optionElement = document.querySelector(`#${option}[data-key='${option}']`);
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
if (presetValue === 'random') {
randomElement.classList.add('active');
optionElement.disabled = true;
updateGameOption(randomElement, false);
} else {
optionElement.value = presetValue;
randomElement.classList.remove('active');
optionElement.disabled = undefined;
updateGameOption(optionElement, false);
}
};
for (const option in defaults) {
let presetValue = preset[option];
if (presetValue === undefined) {
// Using the default value if not set in presets.
presetValue = defaults[option]['defaultValue'];
}
switch (defaults[option].type) {
case 'range':
const numberElement = document.querySelector(`#${option}-value`);
if (presetValue === 'random') {
numberElement.innerText = defaults[option]['defaultValue'] === 'random'
? defaults[option]['min'] // A fallback so we don't print 'random' in the UI.
: defaults[option]['defaultValue'];
} else {
numberElement.innerText = presetValue;
}
updateOptionElement(option, presetValue);
break;
case 'select': {
updateOptionElement(option, presetValue);
break;
}
case 'named_range': {
const selectElement = document.querySelector(`select[data-key='${option}']`);
const rangeElement = document.querySelector(`input[data-key='${option}']`);
const randomElement = document.querySelector(`.randomize-button[data-key='${option}']`);
if (presetValue === 'random') {
randomElement.classList.add('active');
selectElement.disabled = true;
rangeElement.disabled = true;
updateGameOption(randomElement, false);
} else {
rangeElement.value = presetValue;
selectElement.value = Object.values(defaults[option]['value_names']).includes(parseInt(presetValue)) ?
parseInt(presetValue) : 'custom';
document.getElementById(`${option}-value`).innerText = presetValue;
randomElement.classList.remove('active');
selectElement.disabled = undefined;
rangeElement.disabled = undefined;
updateGameOption(rangeElement, false);
}
break;
}
default:
console.warn(`Ignoring preset value for unknown option type: ${defaults[option].type} with name ${option}`);
break;
}
}
};
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
inputElement.disabled = undefined;
if (optionalSelectElement) {
optionalSelectElement.disabled = undefined;
}
} else {
randomButton.classList.add('active');
inputElement.disabled = true;
if (optionalSelectElement) {
optionalSelectElement.disabled = true;
}
}
updateGameOption(active ? inputElement : randomButton);
};
const updateBaseOption = (event) => {
const options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value);
localStorage.setItem(gameName, JSON.stringify(options));
};
const updateGameOption = (optionElement, toggleCustomPreset = true) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (toggleCustomPreset) {
localStorage.setItem(`${gameName}-preset`, '__custom');
const presetElement = document.getElementById('game-options-preset');
presetElement.value = '__custom';
}
if (optionElement.classList.contains('randomize-button')) {
// If the event passed in is the randomize button, then we know what we must do.
options[gameName][optionElement.getAttribute('data-key')] = 'random';
} else {
options[gameName][optionElement.getAttribute('data-key')] = isNaN(optionElement.value) ?
optionElement.value : parseInt(optionElement.value, 10);
}
localStorage.setItem(gameName, JSON.stringify(options));
};
const exportOptions = () => {
const options = JSON.parse(localStorage.getItem(gameName));
const preset = localStorage.getItem(`${gameName}-preset`);
switch (preset) {
case '__default':
options['description'] = `Generated by https://archipelago.gg with the default preset.`;
break;
case '__custom':
options['description'] = `Generated by https://archipelago.gg.`;
break;
default:
options['description'] = `Generated by https://archipelago.gg with the ${preset} preset.`;
}
if (!options.name || options.name.toString().trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
const yamlText = jsyaml.safeDump(options, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
/** Create an anchor and trigger a download of a text file. */
const download = (filename, text) => {
const downloadLink = document.createElement('a');
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
downloadLink.setAttribute('download', filename);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
const generateGame = (raceMode = false) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (!options.name || options.name.toLowerCase() === 'player' || options.name.trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
axios.post('/api/generate', {
weights: { player: options },
presetData: { player: options },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
let userMessage = 'Something went wrong and your game could not be generated.';
if (error.response.data.text) {
userMessage += ' ' + error.response.data.text;
}
showUserMessage(userMessage);
console.error(error);
});
};
const showUserMessage = (message) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = message;
userMessage.classList.add('visible');
window.scrollTo(0, 0);
userMessage.addEventListener('click', () => {
userMessage.classList.remove('visible');
userMessage.addEventListener('click', hideUserMessage);
});
};
const hideUserMessage = () => {
const userMessage = document.getElementById('user-message');
userMessage.classList.remove('visible');
userMessage.removeEventListener('click', hideUserMessage);
};

View File

@@ -1,398 +0,0 @@
let gameName = null;
window.addEventListener('load', () => {
gameName = document.getElementById('player-settings').getAttribute('data-game');
// Update game name on page
document.getElementById('game-name').innerText = gameName;
fetchSettingData().then((results) => {
let settingHash = localStorage.getItem(`${gameName}-hash`);
if (!settingHash) {
// If no hash data has been set before, set it now
settingHash = md5(JSON.stringify(results));
localStorage.setItem(`${gameName}-hash`, settingHash);
localStorage.removeItem(gameName);
}
if (settingHash !== md5(JSON.stringify(results))) {
showUserMessage("Your settings are out of date! Click here to update them! Be aware this will reset " +
"them all to default.");
document.getElementById('user-message').addEventListener('click', resetSettings);
}
// Page setup
createDefaultSettings(results);
buildUI(results);
adjustHeaderWidth();
// Event listeners
document.getElementById('export-settings').addEventListener('click', () => exportSettings());
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
document.getElementById('generate-game').addEventListener('click', () => generateGame());
// Name input field
const playerSettings = JSON.parse(localStorage.getItem(gameName));
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
nameInput.value = playerSettings.name;
}).catch((e) => {
console.error(e);
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
});
const resetSettings = () => {
localStorage.removeItem(gameName);
localStorage.removeItem(`${gameName}-hash`)
window.location.reload();
};
const fetchSettingData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject(ajax.responseText);
return;
}
try{ resolve(JSON.parse(ajax.responseText)); }
catch(error){ reject(error); }
};
ajax.open('GET', `${window.location.origin}/static/generated/player-settings/${gameName}.json`, true);
ajax.send();
});
const createDefaultSettings = (settingData) => {
if (!localStorage.getItem(gameName)) {
const newSettings = {
[gameName]: {},
};
for (let baseOption of Object.keys(settingData.baseOptions)){
newSettings[baseOption] = settingData.baseOptions[baseOption];
}
for (let gameOption of Object.keys(settingData.gameOptions)){
newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue;
}
localStorage.setItem(gameName, JSON.stringify(newSettings));
}
};
const buildUI = (settingData) => {
// Game Options
const leftGameOpts = {};
const rightGameOpts = {};
Object.keys(settingData.gameOptions).forEach((key, index) => {
if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; }
else { rightGameOpts[key] = settingData.gameOptions[key]; }
});
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
};
const buildOptionsTable = (settings, romOpts = false) => {
const currentSettings = JSON.parse(localStorage.getItem(gameName));
const table = document.createElement('table');
const tbody = document.createElement('tbody');
Object.keys(settings).forEach((setting) => {
const tr = document.createElement('tr');
// td Left
const tdl = document.createElement('td');
const label = document.createElement('label');
label.textContent = `${settings[setting].displayName}: `;
label.setAttribute('for', setting);
const questionSpan = document.createElement('span');
questionSpan.classList.add('interactive');
questionSpan.setAttribute('data-tooltip', settings[setting].description);
questionSpan.innerText = '(?)';
label.appendChild(questionSpan);
tdl.appendChild(label);
tr.appendChild(tdl);
// td Right
const tdr = document.createElement('td');
let element = null;
const randomButton = document.createElement('button');
switch(settings[setting].type){
case 'select':
element = document.createElement('div');
element.classList.add('select-container');
let select = document.createElement('select');
select.setAttribute('id', setting);
select.setAttribute('data-key', setting);
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
settings[setting].options.forEach((opt) => {
const option = document.createElement('option');
option.setAttribute('value', opt.value);
option.innerText = opt.name;
if ((isNaN(currentSettings[gameName][setting]) &&
(parseInt(opt.value, 10) === parseInt(currentSettings[gameName][setting]))) ||
(opt.value === currentSettings[gameName][setting]))
{
option.selected = true;
}
select.appendChild(option);
});
select.addEventListener('change', (event) => updateGameSetting(event.target));
element.appendChild(select);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, select));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
select.disabled = true;
}
element.appendChild(randomButton);
break;
case 'range':
element = document.createElement('div');
element.classList.add('range-container');
let range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('data-key', setting);
range.setAttribute('min', settings[setting].min);
range.setAttribute('max', settings[setting].max);
range.value = currentSettings[gameName][setting];
range.addEventListener('change', (event) => {
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
});
element.appendChild(range);
let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${setting}-value`);
rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
currentSettings[gameName][setting] : settings[setting].defaultValue;
element.appendChild(rangeVal);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(event, range));
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
range.disabled = true;
}
element.appendChild(randomButton);
break;
case 'special_range':
element = document.createElement('div');
element.classList.add('special-range-container');
// Build the select element
let specialRangeSelect = document.createElement('select');
specialRangeSelect.setAttribute('data-key', setting);
Object.keys(settings[setting].value_names).forEach((presetName) => {
let presetOption = document.createElement('option');
presetOption.innerText = presetName;
presetOption.value = settings[setting].value_names[presetName];
const words = presetOption.innerText.split("_");
for (let i = 0; i < words.length; i++) {
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
}
presetOption.innerText = words.join(" ");
specialRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');
customOption.innerText = 'Custom';
customOption.value = 'custom';
customOption.selected = true;
specialRangeSelect.appendChild(customOption);
if (Object.values(settings[setting].value_names).includes(Number(currentSettings[gameName][setting]))) {
specialRangeSelect.value = Number(currentSettings[gameName][setting]);
}
// Build range element
let specialRangeWrapper = document.createElement('div');
specialRangeWrapper.classList.add('special-range-wrapper');
let specialRange = document.createElement('input');
specialRange.setAttribute('type', 'range');
specialRange.setAttribute('data-key', setting);
specialRange.setAttribute('min', settings[setting].min);
specialRange.setAttribute('max', settings[setting].max);
specialRange.value = currentSettings[gameName][setting];
// Build rage value element
let specialRangeVal = document.createElement('span');
specialRangeVal.classList.add('range-value');
specialRangeVal.setAttribute('id', `${setting}-value`);
specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
currentSettings[gameName][setting] : settings[setting].defaultValue;
// Configure select event listener
specialRangeSelect.addEventListener('change', (event) => {
if (event.target.value === 'custom') { return; }
// Update range slider
specialRange.value = event.target.value;
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
});
// Configure range event handler
specialRange.addEventListener('change', (event) => {
// Update select element
specialRangeSelect.value =
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom';
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event.target);
});
element.appendChild(specialRangeSelect);
specialRangeWrapper.appendChild(specialRange);
specialRangeWrapper.appendChild(specialRangeVal);
element.appendChild(specialRangeWrapper);
// Randomize button
randomButton.innerText = '🎲';
randomButton.classList.add('randomize-button');
randomButton.setAttribute('data-key', setting);
randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!');
randomButton.addEventListener('click', (event) => toggleRandomize(
event, specialRange, specialRangeSelect)
);
if (currentSettings[gameName][setting] === 'random') {
randomButton.classList.add('active');
specialRange.disabled = true;
specialRangeSelect.disabled = true;
}
specialRangeWrapper.appendChild(randomButton);
break;
default:
console.error(`Ignoring unknown setting type: ${settings[setting].type} with name ${setting}`);
return;
}
tdr.appendChild(element);
tr.appendChild(tdr);
tbody.appendChild(tr);
});
table.appendChild(tbody);
return table;
};
const toggleRandomize = (event, inputElement, optionalSelectElement = null) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
inputElement.disabled = undefined;
if (optionalSelectElement) {
optionalSelectElement.disabled = undefined;
}
} else {
randomButton.classList.add('active');
inputElement.disabled = true;
if (optionalSelectElement) {
optionalSelectElement.disabled = true;
}
}
updateGameSetting(randomButton);
};
const updateBaseSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value);
localStorage.setItem(gameName, JSON.stringify(options));
};
const updateGameSetting = (settingElement) => {
const options = JSON.parse(localStorage.getItem(gameName));
if (settingElement.classList.contains('randomize-button')) {
// If the event passed in is the randomize button, then we know what we must do.
options[gameName][settingElement.getAttribute('data-key')] = 'random';
} else {
options[gameName][settingElement.getAttribute('data-key')] = isNaN(settingElement.value) ?
settingElement.value : parseInt(settingElement.value, 10);
}
localStorage.setItem(gameName, JSON.stringify(options));
};
const exportSettings = () => {
const settings = JSON.parse(localStorage.getItem(gameName));
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
/** Create an anchor and trigger a download of a text file. */
const download = (filename, text) => {
const downloadLink = document.createElement('a');
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
downloadLink.setAttribute('download', filename);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
const generateGame = (raceMode = false) => {
const settings = JSON.parse(localStorage.getItem(gameName));
if (!settings.name || settings.name.toLowerCase() === 'player' || settings.name.trim().length === 0) {
return showUserMessage('You must enter a player name!');
}
axios.post('/api/generate', {
weights: { player: settings },
presetData: { player: settings },
playerCount: 1,
spoiler: 3,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
let userMessage = 'Something went wrong and your game could not be generated.';
if (error.response.data.text) {
userMessage += ' ' + error.response.data.text;
}
showUserMessage(userMessage);
console.error(error);
});
};
const showUserMessage = (message) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = message;
userMessage.classList.add('visible');
window.scrollTo(0, 0);
userMessage.addEventListener('click', () => {
userMessage.classList.remove('visible');
userMessage.addEventListener('click', hideUserMessage);
});
};
const hideUserMessage = () => {
const userMessage = document.getElementById('user-message');
userMessage.classList.remove('visible');
userMessage.removeEventListener('click', hideUserMessage);
};

View File

@@ -1,51 +1,32 @@
window.addEventListener('load', () => {
const gameHeaders = document.getElementsByClassName('collapse-toggle');
Array.from(gameHeaders).forEach((header) => {
const gameName = header.getAttribute('data-game');
header.addEventListener('click', () => {
const gameArrow = document.getElementById(`${gameName}-arrow`);
const gameInfo = document.getElementById(gameName);
if (gameInfo.classList.contains('collapsed')) {
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
} else {
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
}
});
});
// Add toggle listener to all elements with .collapse-toggle
const toggleButtons = document.querySelectorAll('.collapse-toggle');
toggleButtons.forEach((e) => e.addEventListener('click', toggleCollapse));
// Handle game filter input
const gameSearch = document.getElementById('game-search');
gameSearch.value = '';
gameSearch.addEventListener('input', (evt) => {
if (!evt.target.value.trim()) {
// If input is empty, display all collapsed games
return Array.from(gameHeaders).forEach((header) => {
return toggleButtons.forEach((header) => {
header.style.display = null;
const gameName = header.getAttribute('data-game');
document.getElementById(`${gameName}-arrow`).innerText = '▶';
document.getElementById(gameName).classList.add('collapsed');
header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
});
}
// Loop over all the games
Array.from(gameHeaders).forEach((header) => {
const gameName = header.getAttribute('data-game');
const gameArrow = document.getElementById(`${gameName}-arrow`);
const gameInfo = document.getElementById(gameName);
toggleButtons.forEach((header) => {
// If the game name includes the search string, display the game. If not, hide it
if (gameName.toLowerCase().includes(evt.target.value.toLowerCase())) {
if (header.getAttribute('data-game').toLowerCase().includes(evt.target.value.toLowerCase())) {
header.style.display = null;
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
header.firstElementChild.innerText = '▼';
header.nextElementSibling.classList.remove('collapsed');
} else {
console.log(header);
header.style.display = 'none';
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
}
});
});
@@ -54,30 +35,30 @@ window.addEventListener('load', () => {
document.getElementById('collapse-all').addEventListener('click', collapseAll);
});
const expandAll = () => {
const gameHeaders = document.getElementsByClassName('collapse-toggle');
// Loop over all the games
Array.from(gameHeaders).forEach((header) => {
const gameName = header.getAttribute('data-game');
const gameArrow = document.getElementById(`${gameName}-arrow`);
const gameInfo = document.getElementById(gameName);
const toggleCollapse = (evt) => {
const gameArrow = evt.target.firstElementChild;
const gameInfo = evt.target.nextElementSibling;
if (gameInfo.classList.contains('collapsed')) {
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
} else {
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
}
};
if (header.style.display === 'none') { return; }
gameArrow.innerText = '▼';
gameInfo.classList.remove('collapsed');
});
const expandAll = () => {
document.querySelectorAll('.collapse-toggle').forEach((header) => {
if (header.style.display === 'none') { return; }
header.firstElementChild.innerText = '▼';
header.nextElementSibling.classList.remove('collapsed');
});
};
const collapseAll = () => {
const gameHeaders = document.getElementsByClassName('collapse-toggle');
// Loop over all the games
Array.from(gameHeaders).forEach((header) => {
const gameName = header.getAttribute('data-game');
const gameArrow = document.getElementById(`${gameName}-arrow`);
const gameInfo = document.getElementById(gameName);
if (header.style.display === 'none') { return; }
gameArrow.innerText = '▶';
gameInfo.classList.add('collapsed');
});
document.querySelectorAll('.collapse-toggle').forEach((header) => {
if (header.style.display === 'none') { return; }
header.firstElementChild.innerText = '▶';
header.nextElementSibling.classList.add('collapsed');
});
};

View File

@@ -4,13 +4,20 @@ const adjustTableHeight = () => {
return;
const upperDistance = tablesContainer.getBoundingClientRect().top;
const containerHeight = window.innerHeight - upperDistance;
tablesContainer.style.maxHeight = `calc(${containerHeight}px - 1rem)`;
const tableWrappers = document.getElementsByClassName('table-wrapper');
for(let i=0; i < tableWrappers.length; i++){
const maxHeight = (window.innerHeight - upperDistance) / 2;
tableWrappers[i].style.maxHeight = `calc(${maxHeight}px - 1rem)`;
for (let i = 0; i < tableWrappers.length; i++) {
// Ensure we are starting from maximum size prior to calculation.
tableWrappers[i].style.height = null;
tableWrappers[i].style.maxHeight = null;
// Set as a reasonable height, but still allows the user to resize element if they desire.
const currentHeight = tableWrappers[i].offsetHeight;
const maxHeight = (window.innerHeight - upperDistance) / Math.min(tableWrappers.length, 4);
if (currentHeight > maxHeight) {
tableWrappers[i].style.height = `calc(${maxHeight}px - 1rem)`;
}
tableWrappers[i].style.maxHeight = `${currentHeight}px`;
}
};
@@ -55,7 +62,7 @@ window.addEventListener('load', () => {
render: function (data, type, row) {
if (type === "sort" || type === 'type') {
if (data === "None")
return -1;
return Number.MAX_VALUE;
return parseInt(data);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@ html{
background-size: 650px 650px;
}
#player-settings{
#player-options{
box-sizing: border-box;
max-width: 1024px;
margin-left: auto;
@@ -15,14 +15,14 @@ html{
color: #eeffeb;
}
#player-settings #player-settings-button-row{
#player-options #player-options-button-row{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 15px;
}
#player-settings code{
#player-options code{
background-color: #d9cd8e;
border-radius: 4px;
padding-left: 0.25rem;
@@ -30,7 +30,7 @@ html{
color: #000000;
}
#player-settings #user-message{
#player-options #user-message{
display: none;
width: calc(100% - 8px);
background-color: #ffe86b;
@@ -40,12 +40,12 @@ html{
text-align: center;
}
#player-settings #user-message.visible{
#player-options #user-message.visible{
display: block;
cursor: pointer;
}
#player-settings h1{
#player-options h1{
font-size: 2.5rem;
font-weight: normal;
width: 100%;
@@ -53,7 +53,7 @@ html{
text-shadow: 1px 1px 4px #000000;
}
#player-settings h2{
#player-options h2{
font-size: 40px;
font-weight: normal;
width: 100%;
@@ -62,22 +62,22 @@ html{
text-shadow: 1px 1px 2px #000000;
}
#player-settings h3, #player-settings h4, #player-settings h5, #player-settings h6{
#player-options h3, #player-options h4, #player-options h5, #player-options h6{
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
#player-settings input:not([type]){
#player-options input:not([type]){
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
min-width: 150px;
}
#player-settings input:not([type]):focus{
#player-options input:not([type]):focus{
border: 1px solid #ffffff;
}
#player-settings select{
#player-options select{
border: 1px solid #000000;
padding: 3px;
border-radius: 3px;
@@ -85,72 +85,97 @@ html{
background-color: #ffffff;
}
#player-settings #game-options, #player-settings #rom-options{
#player-options #game-options, #player-options #rom-options{
display: flex;
flex-direction: row;
}
#player-settings .left, #player-settings .right{
#player-options #meta-options {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 3px;
}
#player-options div {
display: flex;
flex-grow: 1;
}
#player-settings .left{
#player-options #meta-options label {
display: inline-block;
min-width: 180px;
flex-grow: 1;
}
#player-options #meta-options input,
#player-options #meta-options select {
box-sizing: border-box;
min-width: 150px;
width: 50%;
}
#player-options .left, #player-options .right{
flex-grow: 1;
}
#player-options .left{
margin-right: 10px;
}
#player-settings .right{
#player-options .right{
margin-left: 10px;
}
#player-settings table{
#player-options table{
margin-bottom: 30px;
width: 100%;
}
#player-settings table .select-container{
#player-options table .select-container{
display: flex;
flex-direction: row;
}
#player-settings table .select-container select{
#player-options table .select-container select{
min-width: 200px;
flex-grow: 1;
}
#player-settings table select:disabled{
#player-options table select:disabled{
background-color: lightgray;
}
#player-settings table .range-container{
#player-options table .range-container{
display: flex;
flex-direction: row;
}
#player-settings table .range-container input[type=range]{
#player-options table .range-container input[type=range]{
flex-grow: 1;
}
#player-settings table .range-value{
#player-options table .range-value{
min-width: 20px;
margin-left: 0.25rem;
}
#player-settings table .special-range-container{
#player-options table .named-range-container{
display: flex;
flex-direction: column;
}
#player-settings table .special-range-wrapper{
#player-options table .named-range-wrapper{
display: flex;
flex-direction: row;
margin-top: 0.25rem;
}
#player-settings table .special-range-wrapper input[type=range]{
#player-options table .named-range-wrapper input[type=range]{
flex-grow: 1;
}
#player-settings table .randomize-button {
#player-options table .randomize-button {
max-height: 24px;
line-height: 16px;
padding: 2px 8px;
@@ -160,23 +185,23 @@ html{
border-radius: 3px;
}
#player-settings table .randomize-button.active {
#player-options table .randomize-button.active {
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
}
#player-settings table .randomize-button[data-tooltip]::after {
#player-options table .randomize-button[data-tooltip]::after {
left: unset;
right: 0;
}
#player-settings table label{
#player-options table label{
display: block;
min-width: 200px;
margin-right: 4px;
cursor: default;
}
#player-settings th, #player-settings td{
#player-options th, #player-options td{
border: none;
padding: 3px;
font-size: 17px;
@@ -184,17 +209,23 @@ html{
}
@media all and (max-width: 1024px) {
#player-settings {
#player-options {
border-radius: 0;
}
#player-settings #game-options{
#player-options #meta-options {
flex-direction: column;
justify-content: flex-start;
gap: 6px;
}
#player-options #game-options{
justify-content: flex-start;
flex-wrap: wrap;
}
#player-settings .left,
#player-settings .right {
#player-options .left,
#player-options .right {
margin: 0;
}

View File

@@ -18,10 +18,16 @@
margin-bottom: 2px;
}
#games .collapse-toggle{
cursor: pointer;
}
#games h2 .collapse-arrow{
font-size: 20px;
display: inline-block; /* make vertical-align work */
padding-bottom: 9px;
vertical-align: middle;
cursor: pointer;
padding-right: 8px;
}
#games p.collapsed{
@@ -42,12 +48,12 @@
margin-bottom: 7px;
}
#games #page-controls{
#games .page-controls{
display: flex;
flex-direction: row;
margin-top: 0.25rem;
}
#games #page-controls button{
#games .page-controls button{
margin-left: 0.5rem;
}

View File

@@ -7,138 +7,55 @@
width: calc(100% - 1rem);
}
#tracker-wrapper a{
#tracker-wrapper a {
color: #234ae4;
text-decoration: none;
cursor: pointer;
}
.table-wrapper{
overflow-y: auto;
overflow-x: auto;
margin-bottom: 1rem;
}
#tracker-header-bar{
#tracker-header-bar {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-content: center;
line-height: 20px;
gap: 0.5rem;
margin-bottom: 1rem;
}
#tracker-header-bar .info{
#tracker-header-bar .info {
color: #ffffff;
}
#search{
border: 1px solid #000000;
border-radius: 3px;
padding: 3px;
width: 200px;
margin-bottom: 0.5rem;
margin-right: 1rem;
}
#multi-stream-link{
margin-right: 1rem;
}
div.dataTables_wrapper.no-footer .dataTables_scrollBody{
border: none;
}
table.dataTable{
color: #000000;
}
table.dataTable thead{
font-family: LexendDeca-Regular, sans-serif;
}
table.dataTable tbody, table.dataTable tfoot{
background-color: #dce2bd;
font-family: LexendDeca-Light, sans-serif;
}
table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover{
background-color: #e2eabb;
}
table.dataTable tbody td, table.dataTable tfoot td{
padding: 4px 6px;
}
table.dataTable, table.dataTable.no-footer{
border-left: 1px solid #bba967;
width: calc(100% - 2px) !important;
font-size: 1rem;
}
table.dataTable thead th{
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
top: 0;
}
table.dataTable thead th.upper-row{
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
height: 36px;
top: 0;
}
table.dataTable thead th.lower-row{
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
height: 22px;
top: 46px;
}
table.dataTable tbody td, table.dataTable tfoot td{
border: 1px solid #bba967;
}
table.dataTable tfoot td{
font-weight: bold;
}
div.dataTables_scrollBody{
background-color: inherit !important;
}
table.dataTable .center-column{
text-align: center;
}
img.alttp-sprite {
height: auto;
max-height: 32px;
min-height: 14px;
}
.item-acquired{
background-color: #d3c97d;
padding: 2px;
flex-grow: 1;
align-self: center;
text-align: justify;
}
#tracker-navigation {
display: inline-flex;
display: flex;
flex-wrap: wrap;
margin: 0 0.5rem 0.5rem 0.5rem;
user-select: none;
height: 2rem;
}
.tracker-navigation-bar {
display: flex;
background-color: #b0a77d;
margin: 0.5rem;
border-radius: 4px;
}
.tracker-navigation-button {
display: block;
display: flex;
justify-content: center;
align-items: center;
margin: 4px;
padding-left: 12px;
padding-right: 12px;
border-radius: 4px;
text-align: center;
font-size: 14px;
color: #000;
color: black !important;
font-weight: lighter;
}
@@ -150,6 +67,100 @@ img.alttp-sprite {
background-color: rgb(220, 226, 189);
}
.table-wrapper {
overflow-y: auto;
overflow-x: auto;
margin-bottom: 1rem;
resize: vertical;
}
#search {
border: 1px solid #000000;
border-radius: 3px;
padding: 3px;
width: 200px;
}
div.dataTables_wrapper.no-footer .dataTables_scrollBody {
border: none;
}
table.dataTable {
color: #000000;
}
table.dataTable thead {
font-family: LexendDeca-Regular, sans-serif;
}
table.dataTable tbody, table.dataTable tfoot {
background-color: #dce2bd;
font-family: LexendDeca-Light, sans-serif;
}
table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover {
background-color: #e2eabb;
}
table.dataTable tbody td, table.dataTable tfoot td {
padding: 4px 6px;
}
table.dataTable, table.dataTable.no-footer {
border-left: 1px solid #bba967;
width: calc(100% - 2px) !important;
font-size: 1rem;
}
table.dataTable thead th {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
top: 0;
}
table.dataTable thead th.upper-row {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
height: 36px;
top: 0;
}
table.dataTable thead th.lower-row {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
height: 22px;
top: 46px;
}
table.dataTable tbody td, table.dataTable tfoot td {
border: 1px solid #bba967;
}
table.dataTable tfoot td {
font-weight: bold;
}
div.dataTables_scrollBody {
background-color: inherit !important;
}
table.dataTable .center-column {
text-align: center;
}
img.icon-sprite {
height: auto;
max-height: 32px;
min-height: 14px;
}
.item-acquired {
background-color: #d3c97d;
}
@media all and (max-width: 1700px) {
table.dataTable thead th.upper-row{
position: -webkit-sticky;
@@ -159,7 +170,7 @@ img.alttp-sprite {
top: 0;
}
table.dataTable thead th.lower-row{
table.dataTable thead th.lower-row {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
@@ -167,11 +178,11 @@ img.alttp-sprite {
top: 37px;
}
table.dataTable, table.dataTable.no-footer{
table.dataTable, table.dataTable.no-footer {
font-size: 0.8rem;
}
img.alttp-sprite {
img.icon-sprite {
height: auto;
max-height: 24px;
min-height: 10px;
@@ -187,7 +198,7 @@ img.alttp-sprite {
top: 0;
}
table.dataTable thead th.lower-row{
table.dataTable thead th.lower-row {
position: -webkit-sticky;
position: sticky;
background-color: #b0a77d;
@@ -195,11 +206,11 @@ img.alttp-sprite {
top: 32px;
}
table.dataTable, table.dataTable.no-footer{
table.dataTable, table.dataTable.no-footer {
font-size: 0.6rem;
}
img.alttp-sprite {
img.icon-sprite {
height: auto;
max-height: 20px;
min-height: 10px;

View File

@@ -292,6 +292,12 @@ html{
margin-right: 0.5rem;
}
#weighted-settings .simple-list hr{
width: calc(100% - 2px);
margin: 2px auto;
border-bottom: 1px solid rgb(255 255 255 / 0.6);
}
#weighted-settings .invisible{
display: none;
}

View File

@@ -28,6 +28,10 @@
{% endfor %}
</tbody>
</table>
{% if combined_yaml %}
<h1>Combined File Download</h1>
<p><a href="data:text/yaml;base64,{{ combined_yaml }}" download="combined.yaml">Download</a></p>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -69,8 +69,8 @@
</td>
<td>
<select name="collect_mode" id="collect_mode">
<option value="goal">Allow !collect after goal completion</option>
<option value="auto">Automatic on goal completion</option>
<option value="goal">Allow !collect after goal completion</option>
<option value="auto-enabled">
Automatic on goal completion and manual !collect
</option>
@@ -93,9 +93,9 @@
{% if race -%}
<option value="disabled">Disabled in Race mode</option>
{%- else -%}
<option value="disabled">Disabled</option>
<option value="goal">Allow !remaining after goal completion</option>
<option value="enabled">Manual !remaining</option>
<option value="disabled">Disabled</option>
{%- endif -%}
</select>
</td>
@@ -185,12 +185,12 @@ Warning: playthrough can take a significant amount of time for larger multiworld
</span>
</td>
<td>
<input type="checkbox" id="plando_items" name="plando_items" value="items">
<label for="plando_items">Items</label><br>
<input type="checkbox" id="plando_bosses" name="plando_bosses" value="bosses" checked>
<label for="plando_bosses">Bosses</label><br>
<input type="checkbox" id="plando_items" name="plando_items" value="items" checked>
<label for="plando_items">Items</label><br>
<input type="checkbox" id="plando_connections" name="plando_connections" value="connections" checked>
<label for="plando_connections">Connections</label><br>

View File

@@ -1,36 +1,57 @@
{% extends 'tablepage.html' %}
{% extends "tablepage.html" %}
{% block head %}
{{ super() }}
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}" data-second="{{ saving_second }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
<span class="info">This tracker will automatically update itself periodically.</span>
{% include "header/dirtHeader.html" %}
<div id="tracker-navigation">
<div class="tracker-navigation-bar">
<a
class="tracker-navigation-button"
href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}"
>
🡸 Return to Multiworld Tracker
</a>
{% if game_specific_tracker %}
<a
class="tracker-navigation-button"
href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}"
>
Game-Specific Tracker
</a>
{% endif %}
</div>
</div>
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}/{{ team }}/{{ player }}" data-second="{{ saving_second }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search" />
<div class="info">This tracker will automatically update itself periodically.</div>
</div>
<div id="tables-container">
<div class="table-wrapper">
<table id="received-table" class="table non-unique-item-table">
<thead>
<tr>
<th>Item</th>
<th>Amount</th>
<th>Order Received</th>
<th>Last Order Received</th>
</tr>
</thead>
<tbody>
{% for id, count in inventory.items() %}
<tr>
<td>{{ id | item_name }}</td>
<td>{{ count }}</td>
<td>{{received_items[id]}}</td>
</tr>
{% for id, count in inventory.items() if count > 0 %}
<tr>
<td>{{ item_id_to_name[game][id] }}</td>
<td>{{ count }}</td>
<td>{{ received_items[id] }}</td>
</tr>
{%- endfor -%}
</tbody>
@@ -39,24 +60,62 @@
<div class="table-wrapper">
<table id="locations-table" class="table non-unique-item-table">
<thead>
<tr>
<th>Location</th>
<th>Checked</th>
</tr>
<tr>
<th>Location</th>
<th class="center-column">Checked</th>
</tr>
</thead>
<tbody>
{% for name in checked_locations %}
{%- for location in locations -%}
<tr>
<td>{{ name | location_name}}</td>
<td></td>
<td>{{ location_id_to_name[game][location] }}</td>
<td class="center-column">
{% if location in checked_locations %}✔{% endif %}
</td>
</tr>
{%- endfor -%}
{% for name in not_checked_locations %}
{%- endfor -%}
</tbody>
</table>
</div>
<div class="table-wrapper">
<table id="hints-table" class="table non-unique-item-table">
<thead>
<tr>
<td>{{ name | location_name}}</td>
<td></td>
<th>Finder</th>
<th>Receiver</th>
<th>Item</th>
<th>Location</th>
<th>Game</th>
<th>Entrance</th>
<th class="center-column">Found</th>
</tr>
{%- endfor -%}
</thead>
<tbody>
{%- for hint in hints -%}
<tr>
<td>
{% if hint.finding_player == player %}
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
{% else %}
{{ player_names_with_alias[(team, hint.finding_player)] }}
{% endif %}
</td>
<td>
{% if hint.receiving_player == player %}
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
{% else %}
{{ player_names_with_alias[(team, hint.receiving_player)] }}
{% endif %}
</td>
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>
<td>{{ location_id_to_name[games[(team, hint.finding_player)]][hint.location] }}</td>
<td>{{ games[(team, hint.finding_player)] }}</td>
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
<td class="center-column">{% if hint.found %}✔{% endif %}</td>
</tr>
{%- endfor -%}
</tbody>
</table>
</div>

View File

@@ -1,28 +0,0 @@
{% for team, hints in hints.items() %}
<div class="table-wrapper">
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
<thead>
<tr>
<th>Finder</th>
<th>Receiver</th>
<th>Item</th>
<th>Location</th>
<th>Entrance</th>
<th>Found</th>
</tr>
</thead>
<tbody>
{%- for hint in hints -%}
<tr>
<td>{{ long_player_names[team, hint.finding_player] }}</td>
<td>{{ long_player_names[team, hint.receiving_player] }}</td>
<td>{{ hint.item|item_name }}</td>
<td>{{ hint.location|location_name }}</td>
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
<td>{% if hint.found %}✔{% endif %}</td>
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}

View File

@@ -3,6 +3,16 @@
{% block head %}
<title>Multiworld {{ room.id|suuid }}</title>
{% if should_refresh %}<meta http-equiv="refresh" content="2">{% endif %}
<meta name="og:site_name" content="Archipelago">
<meta property="og:title" content="Multiworld {{ room.id|suuid }}">
<meta property="og:type" content="website" />
{% if room.seed.slots|length < 2 %}
<meta property="og:description" content="{{ room.seed.slots|length }} Player World
{% if room.last_port != -1 %}running on {{ config['HOST_ADDRESS'] }} with port {{ room.last_port }}{% endif %}">
{% else %}
<meta property="og:description" content="{{ room.seed.slots|length }} Players Multiworld
{% if room.last_port != -1 %}running on {{ config['HOST_ADDRESS'] }} with port {{ room.last_port }}{% endif %}">
{% endif %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% block footer %}
<footer id="island-footer">
<div id="copyright-notice">Copyright 2023 Archipelago</div>
<div id="copyright-notice">Copyright 2024 Archipelago</div>
<div id="links">
<a href="/sitemap">Site Map</a>
-

View File

@@ -1,171 +0,0 @@
{% extends 'tablepage.html' %}
{% block head %}
{{ super() }}
<title>ALttP Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttpMultiTracker.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
{% include 'multiTrackerNavigation.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
<span{% if not video %} hidden{% endif %} id="multi-stream-link">
<a target="_blank" href="https://multistream.me/
{%- for platform, link in video.values()|unique(False, 1)-%}
{%- if platform == "Twitch" -%}t{%- else -%}yt{%- endif -%}:{{- link -}}/
{%- endfor -%}">
Multistream
</a>
</span>
<span class="info">Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically.</span>
</div>
<div id="tables-container">
{% for team, players in inventory.items() %}
<div class="table-wrapper">
<table id="inventory-table" class="table unique-item-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
{%- for name in tracking_names -%}
{%- if name in icons -%}
<th class="center-column">
<img class="alttp-sprite" src="{{ icons[name] }}" alt="{{ name|e }}">
</th>
{%- else -%}
<th class="center-column">{{ name|e }}</th>
{%- endif -%}
{%- endfor -%}
</tr>
</thead>
<tbody>
{%- for player, items in players.items() -%}
<tr>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
{%- if (team, loop.index) in video -%}
{%- if video[(team, loop.index)][0] == "Twitch" -%}
<td>
<a target="_blank" href="https://www.twitch.tv/{{ video[(team, loop.index)][1] }}">
{{ player_names[(team, loop.index)] }}
▶️</a></td>
{%- elif video[(team, loop.index)][0] == "Youtube" -%}
<td>
<a target="_blank" href="youtube.com/c/{{ video[(team, loop.index)][1] }}/live">
{{ player_names[(team, loop.index)] }}
▶️</a></td>
{%- endif -%}
{%- else -%}
<td>{{ player_names[(team, loop.index)] }}</td>
{%- endif -%}
{%- for id in tracking_ids -%}
{%- if items[id] -%}
<td class="center-column item-acquired">
{% if id in multi_items %}{{ items[id] }}{% else %}✔️{% endif %}</td>
{%- else -%}
<td></td>
{%- endif -%}
{% endfor %}
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}
{% for team, players in checks_done.items() %}
<div class="table-wrapper">
<table id="checks-table" class="table non-unique-item-table">
<thead>
<tr>
<th rowspan="2">#</th>
<th rowspan="2">Name</th>
{% for area in ordered_areas %}
{% set colspan = 1 %}
{% if area in key_locations %}
{% set colspan = colspan + 1 %}
{% endif %}
{% if area in big_key_locations %}
{% set colspan = colspan + 1 %}
{% endif %}
{% if area in icons %}
<th colspan="{{ colspan }}" class="center-column upper-row">
<img class="alttp-sprite" src="{{ icons[area] }}" alt="{{ area }}"></th>
{%- else -%}
<th colspan="{{ colspan }}" class="center-column">{{ area }}</th>
{%- endif -%}
{%- endfor -%}
<th rowspan="2" class="center-column">&percnt;</th>
<th rowspan="2" class="center-column hours">Last<br>Activity</th>
</tr>
<tr>
{% for area in ordered_areas %}
<th class="center-column lower-row fraction">
<img class="alttp-sprite" src="{{ icons["Chest"] }}" alt="Checks">
</th>
{% if area in key_locations %}
<th class="center-column lower-row number">
<img class="alttp-sprite" src="{{ icons["Small Key"] }}" alt="Small Key">
</th>
{% endif %}
{% if area in big_key_locations %}
<th class="center-column lower-row number">
<img class="alttp-sprite" src="{{ icons["Big Key"] }}" alt="Big Key">
</th>
{%- endif -%}
{%- endfor -%}
</tr>
</thead>
<tbody>
{%- for player, checks in players.items() -%}
<tr>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
<td>{{ player_names[(team, loop.index)]|e }}</td>
{%- for area in ordered_areas -%}
{% if player in checks_in_area and area in checks_in_area[player] %}
{%- set checks_done = checks[area] -%}
{%- set checks_total = checks_in_area[player][area] -%}
{%- if checks_done == checks_total -%}
<td class="item-acquired center-column">
{{ checks_done }}/{{ checks_total }}</td>
{%- else -%}
<td class="center-column">{{ checks_done }}/{{ checks_total }}</td>
{%- endif -%}
{%- if area in key_locations -%}
<td class="center-column">{{ inventory[team][player][small_key_ids[area]] }}</td>
{%- endif -%}
{%- if area in big_key_locations -%}
<td class="center-column">{% if inventory[team][player][big_key_ids[area]] %}✔️{% endif %}</td>
{%- endif -%}
{% else %}
<td class="center-column"></td>
{%- if area in key_locations -%}
<td class="center-column"></td>
{%- endif -%}
{%- if area in big_key_locations -%}
<td class="center-column"></td>
{%- endif -%}
{% endif %}
{%- endfor -%}
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
{%- if activity_timers[(team, player)] -%}
<td class="center-column">{{ activity_timers[(team, player)].total_seconds() }}</td>
{%- else -%}
<td class="center-column">None</td>
{%- endif -%}
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}
{% include "hintTable.html" with context %}
</div>
</div>
{% endblock %}

View File

@@ -50,6 +50,9 @@
{% elif patch.game == "Dark Souls III" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download JSON File...</a>
{% elif patch.game == "Final Fantasy Mystic Quest" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMQ File...</a>
{% else %}
No file to download for this game.
{% endif %}

View File

@@ -1,46 +0,0 @@
{% extends "multiTracker.html" %}
{% block custom_table_headers %}
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Logistic_science_pack.png/32px-Logistic_science_pack.png"
alt="Logistic Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Military_science_pack.png/32px-Military_science_pack.png"
alt="Military Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Chemical_science_pack.png/32px-Chemical_science_pack.png"
alt="Chemical Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Production_science_pack.png/32px-Production_science_pack.png"
alt="Production Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Utility_science_pack.png/32px-Utility_science_pack.png"
alt="Utility Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Space_science_pack.png/32px-Space_science_pack.png"
alt="Space Science Pack">
</th>
{% endblock %}
{% block custom_table_row scoped %}
{% if games[player] == "Factorio" %}
{% set player_inventory = named_inventory[team][player] %}
{% set prog_science = player_inventory["progressive-science-pack"] %}
<td class="center-column">{% if player_inventory["logistic-science-pack"] or prog_science %}✔{% endif %}</td>
<td class="center-column">{% if player_inventory["military-science-pack"] or prog_science > 1%}✔{% endif %}</td>
<td class="center-column">{% if player_inventory["chemical-science-pack"] or prog_science > 2%}✔{% endif %}</td>
<td class="center-column">{% if player_inventory["production-science-pack"] or prog_science > 3%}✔{% endif %}</td>
<td class="center-column">{% if player_inventory["utility-science-pack"] or prog_science > 4%}✔{% endif %}</td>
<td class="center-column">{% if player_inventory["space-science-pack"] or prog_science > 5%}✔{% endif %}</td>
{% else %}
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
{% endif %}
{% endblock%}

View File

@@ -1,86 +0,0 @@
{% extends 'tablepage.html' %}
{% block head %}
{{ super() }}
<title>Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
{% include 'multiTrackerNavigation.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
<span{% if not video %} hidden{% endif %} id="multi-stream-link">
<a target="_blank" href="https://multistream.me/
{%- for platform, link in video.values()|unique(False, 1)-%}
{%- if platform == "Twitch" -%}t{%- else -%}yt{%- endif -%}:{{- link -}}/
{%- endfor -%}">
Multistream
</a>
</span>
<span class="info">Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically.</span>
</div>
<div id="tables-container">
{% for team, players in checks_done.items() %}
<div class="table-wrapper">
<table id="checks-table" class="table non-unique-item-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Game</th>
<th>Status</th>
{% block custom_table_headers %}
{# implement this block in game-specific multi trackers #}
{% endblock %}
<th class="center-column">Checks</th>
<th class="center-column">&percnt;</th>
<th class="center-column hours last-activity">Last<br>Activity</th>
</tr>
</thead>
<tbody>
{%- for player, checks in players.items() -%}
<tr>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
<td>{{ player_names[(team, loop.index)]|e }}</td>
<td>{{ games[player] }}</td>
<td>{{ {0: "Disconnected", 5: "Connected", 10: "Ready", 20: "Playing",
30: "Goal Completed"}.get(states[team, player], "Unknown State") }}</td>
{% block custom_table_row scoped %}
{# implement this block in game-specific multi trackers #}
{% endblock %}
<td class="center-column" data-sort="{{ checks["Total"] }}">
{{ checks["Total"] }}/{{ locations[player] | length }}
</td>
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
{%- if activity_timers[team, player] -%}
<td class="center-column">{{ activity_timers[team, player].total_seconds() }}</td>
{%- else -%}
<td class="center-column">None</td>
{%- endif -%}
</tr>
{%- endfor -%}
</tbody>
{% if not self.custom_table_headers() | trim %}
<tfoot>
<tr>
<td></td>
<td>Total</td>
<td>All Games</td>
<td>{{ completed_worlds }}/{{ players|length }} Complete</td>
<td class="center-column">{{ players.values()|sum(attribute='Total') }}/{{ total_locations[team] }}</td>
<td class="center-column">{{ (players.values()|sum(attribute='Total') / total_locations[team] * 100) | int }}</td>
<td class="center-column last-activity"></td>
</tr>
</tfoot>
{% endif %}
</table>
</div>
{% endfor %}
{% include "hintTable.html" with context %}
</div>
</div>
{% endblock %}

View File

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

View File

@@ -0,0 +1,144 @@
{% extends "tablepage.html" %}
{% block head %}
{{ super() }}
<title>Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for("static", filename="styles/tracker.css") }}" />
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include "header/dirtHeader.html" %}
{% include "multitrackerNavigation.html" %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker | suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search" />
<div
id="multi-stream-link"
class="tracker-navigation-bar"
{% if not videos %}style="display: none"{% endif %}
>
<a
class="tracker-navigation-button"
href="https://multistream.me/
{%- for platform, link in videos.values() | unique(False, 1) -%}
{%- if platform == "Twitch" -%}t{%- else -%}yt{%- endif -%}:{{- link -}}/
{%- endfor -%}"
target="_blank"
>
► Multistream
</a>
</div>
<div class="info">
Clicking on a slot&apos;s number will bring up the slot-specific tracker.
This tracker will automatically update itself periodically.
</div>
</div>
<div id="tables-container">
{%- for team, players in room_players.items() -%}
<div class="table-wrapper">
<table id="checks-table" class="table non-unique-item-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
{% if current_tracker == "Generic" %}<th>Game</th>{% endif %}
<th>Status</th>
{% block custom_table_headers %}
{# Implement this block in game-specific multi-trackers. #}
{% endblock %}
<th class="center-column">Checks</th>
<th class="center-column">&percnt;</th>
<th class="center-column hours last-activity">Last<br>Activity</th>
</tr>
</thead>
<tbody>
{%- for player in players -%}
{%- if current_tracker == "Generic" or games[(team, player)] == current_tracker -%}
<tr>
<td>
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">
{{ player }}
</a>
</td>
<td>{{ player_names_with_alias[(team, player)] | e }}</td>
{%- if current_tracker == "Generic" -%}
<td>{{ games[(team, player)] }}</td>
{%- endif -%}
<td>
{{
{
0: "Disconnected",
5: "Connected",
10: "Ready",
20: "Playing",
30: "Goal Completed"
}.get(states[(team, player)], "Unknown State")
}}
</td>
{% block custom_table_row scoped %}
{# Implement this block in game-specific multi-trackers. #}
{% endblock %}
{% set location_count = locations[(team, player)] | length %}
<td class="center-column" data-sort="{{ locations_complete[(team, player)] }}">
{{ locations_complete[(team, player)] }}/{{ location_count }}
</td>
<td class="center-column">
{%- if locations[(team, player)] | length > 0 -%}
{% set percentage_of_completion = locations_complete[(team, player)] / location_count * 100 %}
{{ "{0:.2f}".format(percentage_of_completion) }}
{%- else -%}
100.00
{%- endif -%}
</td>
{%- if activity_timers[(team, player)] -%}
<td class="center-column">{{ activity_timers[(team, player)].total_seconds() }}</td>
{%- else -%}
<td class="center-column">None</td>
{%- endif -%}
</tr>
{%- endif -%}
{%- endfor -%}
</tbody>
{%- if not self.custom_table_headers() | trim -%}
<tfoot>
<tr>
<td colspan="2" style="text-align: right">Total</td>
<td>All Games</td>
<td>{{ completed_worlds[team] }}/{{ players | length }} Complete</td>
<td class="center-column">
{{ total_team_locations_complete[team] }}/{{ total_team_locations[team] }}
</td>
<td class="center-column">
{%- if total_team_locations[team] == 0 -%}
100
{%- else -%}
{{ "{0:.2f}".format(total_team_locations_complete[team] / total_team_locations[team] * 100) }}
{%- endif -%}
</td>
<td class="center-column last-activity"></td>
</tr>
</tfoot>
{%- endif -%}
</table>
</div>
{%- endfor -%}
{% block custom_tables %}
{# Implement this block to create custom tables in game-specific multi-trackers. #}
{% endblock %}
{% include "multitrackerHintTable.html" with context %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% for team, hints in hints.items() %}
<div class="table-wrapper">
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
<thead>
<tr>
<th>Finder</th>
<th>Receiver</th>
<th>Item</th>
<th>Location</th>
<th>Game</th>
<th>Entrance</th>
<th class="center-column">Found</th>
</tr>
</thead>
<tbody>
{%- for hint in hints -%}
{%-
if current_tracker == "Generic" or (
games[(team, hint.finding_player)] == current_tracker or
games[(team, hint.receiving_player)] == current_tracker
)
-%}
<tr>
<td>{{ player_names_with_alias[(team, hint.finding_player)] }}</td>
<td>{{ player_names_with_alias[(team, hint.receiving_player)] }}</td>
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>
<td>{{ location_id_to_name[games[(team, hint.finding_player)]][hint.location] }}</td>
<td>{{ games[(team, hint.finding_player)] }}</td>
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
<td class="center-column">{% if hint.found %}✔{% endif %}</td>
</tr>
{% endif %}
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}

View File

@@ -0,0 +1,16 @@
{% if enabled_trackers | length > 1 %}
<div id="tracker-navigation">
{# Multitracker game navigation. #}
<div class="tracker-navigation-bar">
{%- for game_tracker in enabled_trackers -%}
{%- set tracker_url = url_for("get_multiworld_tracker", tracker=room.tracker, game=game_tracker) -%}
<a
class="tracker-navigation-button{% if current_tracker == game_tracker %} selected{% endif %}"
href="{{ tracker_url }}"
>
{{ game_tracker }}
</a>
{%- endfor -%}
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,205 @@
{% extends "multitracker.html" %}
{% block head %}
{{ super() }}
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for("static", filename="assets/lttpMultiTracker.js") }}"></script>
{% endblock %}
{# List all tracker-relevant icons. Format: (Name, Image URL) #}
{%- set icons = {
"Blue Shield": "https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
"Red Shield": "https://www.zeldadungeon.net/wiki/images/5/55/Fire-Shield.png",
"Mirror Shield": "https://www.zeldadungeon.net/wiki/images/8/84/Mirror-Shield.png",
"Fighter Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/40/SFighterSword.png?width=1920",
"Master Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/SMasterSword.png?width=1920",
"Tempered Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/92/STemperedSword.png?width=1920",
"Golden Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/2/28/SGoldenSword.png?width=1920",
"Bow": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=5f85a70e6366bf473544ef93b274f74c",
"Silver Bow": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/Bow.png?width=1920",
"Green Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c9/SGreenTunic.png?width=1920",
"Blue Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/98/SBlueTunic.png?width=1920",
"Red Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/7/74/SRedTunic.png?width=1920",
"Power Glove": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/f/f5/SPowerGlove.png?width=1920",
"Titan Mitts": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920",
"Progressive Sword": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/cc/ALttP_Master_Sword_Sprite.png?version=55869db2a20e157cd3b5c8f556097725",
"Pegasus Boots": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Pegasus_Shoes_Sprite.png?version=405f42f97240c9dcd2b71ffc4bebc7f9",
"Progressive Glove": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920",
"Flippers": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/4c/ZoraFlippers.png?width=1920",
"Moon Pearl": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Moon_Pearl_Sprite.png?version=d601542d5abcc3e006ee163254bea77e",
"Progressive Bow": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=cfb7648b3714cccc80e2b17b2adf00ed",
"Blue Boomerang": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c3/ALttP_Boomerang_Sprite.png?version=96127d163759395eb510b81a556d500e",
"Red Boomerang": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Magical_Boomerang_Sprite.png?version=47cddce7a07bc3e4c2c10727b491f400",
"Hookshot": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/24/Hookshot.png?version=c90bc8e07a52e8090377bd6ef854c18b",
"Mushroom": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/35/ALttP_Mushroom_Sprite.png?version=1f1acb30d71bd96b60a3491e54bbfe59",
"Magic Powder": "https://www.zeldadungeon.net/wiki/images/thumb/6/62/MagicPowder-ALttP-Sprite.png/86px-MagicPowder-ALttP-Sprite.png",
"Fire Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d6/FireRod.png?version=6eabc9f24d25697e2c4cd43ddc8207c0",
"Ice Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d7/ALttP_Ice_Rod_Sprite.png?version=1f944148223d91cfc6a615c92286c3bc",
"Bombos": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/8c/ALttP_Bombos_Medallion_Sprite.png?version=f4d6aba47fb69375e090178f0fc33b26",
"Ether": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/Ether.png?version=34027651a5565fcc5a83189178ab17b5",
"Quake": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/56/ALttP_Quake_Medallion_Sprite.png?version=efd64d451b1831bd59f7b7d6b61b5879",
"Lamp": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Lantern_Sprite.png?version=e76eaa1ec509c9a5efb2916698d5a4ce",
"Hammer": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d1/ALttP_Hammer_Sprite.png?version=e0adec227193818dcaedf587eba34500",
"Shovel": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c4/ALttP_Shovel_Sprite.png?version=e73d1ce0115c2c70eaca15b014bd6f05",
"Flute": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/db/Flute.png?version=ec4982b31c56da2c0c010905c5c60390",
"Bug Catching Net": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/54/Bug-CatchingNet.png?version=4d40e0ee015b687ff75b333b968d8be6",
"Book of Mudora": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/22/ALttP_Book_of_Mudora_Sprite.png?version=11e4632bba54f6b9bf921df06ac93744",
"Bottle": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ef/ALttP_Magic_Bottle_Sprite.png?version=fd98ab04db775270cbe79fce0235777b",
"Cane of Somaria": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e1/ALttP_Cane_of_Somaria_Sprite.png?version=8cc1900dfd887890badffc903bb87943",
"Cane of Byrna": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Cane_of_Byrna_Sprite.png?version=758b607c8cbe2cf1900d42a0b3d0fb54",
"Cape": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1c/ALttP_Magic_Cape_Sprite.png?version=6b77f0d609aab0c751307fc124736832",
"Magic Mirror": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Mirror_Sprite.png?version=e035dbc9cbe2a3bd44aa6d047762b0cc",
"Triforce": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/4/4e/TriforceALttPTitle.png?version=dc398e1293177581c16303e4f9d12a48",
"Triforce Piece": "https://www.zeldadungeon.net/wiki/images/thumb/5/54/Triforce_Fragment_-_BS_Zelda.png/62px-Triforce_Fragment_-_BS_Zelda.png",
"Small Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/f/f1/ALttP_Small_Key_Sprite.png?version=4f35d92842f0de39d969181eea03774e",
"Big Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Big_Key_Sprite.png?version=136dfa418ba76c8b4e270f466fc12f4d",
"Chest": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Treasure_Chest_Sprite.png?version=5f530ecd98dcb22251e146e8049c0dda",
"Light World": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e7/ALttP_Soldier_Green_Sprite.png?version=d650d417934cd707a47e496489c268a6",
"Dark World": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/94/ALttP_Moblin_Sprite.png?version=ebf50e33f4657c377d1606bcc0886ddc",
"Hyrule Castle": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d3/ALttP_Ball_and_Chain_Trooper_Sprite.png?version=1768a87c06d29cc8e7ddd80b9fa516be",
"Agahnims Tower": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1e/ALttP_Agahnim_Sprite.png?version=365956e61b0c2191eae4eddbe591dab5",
"Desert Palace": "https://www.zeldadungeon.net/wiki/images/2/25/Lanmola-ALTTP-Sprite.png",
"Eastern Palace": "https://www.zeldadungeon.net/wiki/images/d/dc/RedArmosKnight.png",
"Tower of Hera": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/ALttP_Moldorm_Sprite.png?version=c588257bdc2543468e008a6b30f262a7",
"Palace of Darkness": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Helmasaur_King_Sprite.png?version=ab8a4a1cfd91d4fc43466c56cba30022",
"Swamp Palace": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Arrghus_Sprite.png?version=b098be3122e53f751b74f4a5ef9184b5",
"Skull Woods": "https://alttp-wiki.net/images/6/6a/Mothula.png",
"Thieves Town": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/86/ALttP_Blind_the_Thief_Sprite.png?version=3833021bfcd112be54e7390679047222",
"Ice Palace": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Kholdstare_Sprite.png?version=e5a1b0e8b2298e550d85f90bf97045c0",
"Misery Mire": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/85/ALttP_Vitreous_Sprite.png?version=92b2e9cb0aa63f831760f08041d8d8d8",
"Turtle Rock": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/91/ALttP_Trinexx_Sprite.png?version=0cc867d513952aa03edd155597a0c0be",
"Ganons Tower": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Ganon_Sprite.png?version=956f51f054954dfff53c1a9d4f929c74",
} -%}
{%- block custom_table_headers %}
{#- macro that creates a table header with display name and image -#}
{%- macro make_header(name, img_src) %}
<th class="center-column">
<img height="24" src="{{ img_src }}" title="{{ name }}" alt="{{ name }}" />
</th>
{% endmacro -%}
{#- call the macro to build the table header -#}
{%- for name in tracking_names %}
{%- if name in icons -%}
<th class="center-column">
<img class="icon-sprite" src="{{ icons[name] }}" alt="{{ name | e }}" title="{{ name | e }}" />
</th>
{%- endif %}
{% endfor -%}
{% endblock %}
{# build each row of custom entries #}
{% block custom_table_row scoped %}
{%- for id in tracking_ids -%}
{# {{ checks }}#}
{%- if inventories[(team, player)][id] -%}
<td class="center-column item-acquired">
{% if id in multi_items %}{{ inventories[(team, player)][id] }}{% else %}✔️{% endif %}
</td>
{%- else -%}
<td></td>
{%- endif -%}
{% endfor %}
{% endblock %}
{% block custom_tables %}
{% for team, _ in total_team_locations.items() %}
<div class="table-wrapper">
<table id="area-table" class="table non-unique-item-table">
<thead>
<tr>
<th rowspan="2">#</th>
<th rowspan="2">Name</th>
{% for area in ordered_areas %}
{% set colspan = 1 %}
{% if area in key_locations %}
{% set colspan = colspan + 1 %}
{% endif %}
{% if area in big_key_locations %}
{% set colspan = colspan + 1 %}
{% endif %}
{% if area in icons %}
<th colspan="{{ colspan }}" class="center-column upper-row">
<img class="icon-sprite" src="{{ icons[area] }}" alt="{{ area }}" title="{{ area }}"></th>
{%- else -%}
<th colspan="{{ colspan }}" class="center-column">{{ area }}</th>
{%- endif -%}
{%- endfor -%}
<th rowspan="2" class="center-column">&percnt;</th>
<th rowspan="2" class="center-column hours">Last<br>Activity</th>
</tr>
<tr>
{% for area in ordered_areas %}
<th class="center-column lower-row fraction">
<img class="icon-sprite" src="{{ icons["Chest"] }}" alt="Checks" title="Checks Complete">
</th>
{% if area in key_locations %}
<th class="center-column lower-row number">
<img class="icon-sprite" src="{{ icons["Small Key"] }}" alt="Small Key" title="Small Keys">
</th>
{% endif %}
{% if area in big_key_locations %}
<th class="center-column lower-row number">
<img class="icon-sprite" src="{{ icons["Big Key"] }}" alt="Big Key" title="Big Keys">
</th>
{%- endif -%}
{%- endfor -%}
</tr>
</thead>
<tbody>
{%- for (checks_team, player), area_checks in checks_done.items() if games[(team, player)] == current_tracker and team == checks_team -%}
<tr>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ player }}</a></td>
<td>{{ player_names_with_alias[(team, player)] | e }}</td>
{%- for area in ordered_areas -%}
{% if (team, player) in checks_in_area and area in checks_in_area[(team, player)] %}
{%- set checks_done = area_checks[area] -%}
{%- set checks_total = checks_in_area[(team, player)][area] -%}
{%- if checks_done == checks_total -%}
<td class="item-acquired center-column">
{{ checks_done }}/{{ checks_total }}</td>
{%- else -%}
<td class="center-column">{{ checks_done }}/{{ checks_total }}</td>
{%- endif -%}
{%- if area in key_locations -%}
<td class="center-column">{{ inventories[(team, player)][small_key_ids[area]] }}</td>
{%- endif -%}
{%- if area in big_key_locations -%}
<td class="center-column">{% if inventories[(team, player)][big_key_ids[area]] %}✔️{% endif %}</td>
{%- endif -%}
{% else %}
<td class="center-column"></td>
{%- if area in key_locations -%}
<td class="center-column"></td>
{%- endif -%}
{%- if area in big_key_locations -%}
<td class="center-column"></td>
{%- endif -%}
{% endif %}
{%- endfor -%}
<td class="center-column">
{% set location_count = locations[(team, player)] | length %}
{%- if locations[(team, player)] | length > 0 -%}
{% set percentage_of_completion = locations_complete[(team, player)] / location_count * 100 %}
{{ "{0:.2f}".format(percentage_of_completion) }}
{%- else -%}
100.00
{%- endif -%}
</td>
{%- if activity_timers[(team, player)] -%}
<td class="center-column">{{ activity_timers[(team, player)].total_seconds() }}</td>
{%- else -%}
<td class="center-column">None</td>
{%- endif -%}
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}
{% endblock %}

View File

@@ -0,0 +1,41 @@
{% extends "multitracker.html" %}
{# establish the to be tracked data. Display Name, factorio/AP internal name, display image #}
{%- set science_packs = [
("Logistic Science Pack", "logistic-science-pack",
"https://wiki.factorio.com/images/thumb/Logistic_science_pack.png/32px-Logistic_science_pack.png"),
("Military Science Pack", "military-science-pack",
"https://wiki.factorio.com/images/thumb/Military_science_pack.png/32px-Military_science_pack.png"),
("Chemical Science Pack", "chemical-science-pack",
"https://wiki.factorio.com/images/thumb/Chemical_science_pack.png/32px-Chemical_science_pack.png"),
("Production Science Pack", "production-science-pack",
"https://wiki.factorio.com/images/thumb/Production_science_pack.png/32px-Production_science_pack.png"),
("Utility Science Pack", "utility-science-pack",
"https://wiki.factorio.com/images/thumb/Utility_science_pack.png/32px-Utility_science_pack.png"),
("Space Science Pack", "space-science-pack",
"https://wiki.factorio.com/images/thumb/Space_science_pack.png/32px-Space_science_pack.png"),
] -%}
{%- block custom_table_headers %}
{#- macro that creates a table header with display name and image -#}
{%- macro make_header(name, img_src) %}
<th class="center-column">
<img class="icon-sprite" src="{{ img_src }}" alt="{{ name }}" title="{{ name }}" />
</th>
{% endmacro -%}
{#- call the macro to build the table header -#}
{%- for name, internal_name, img_src in science_packs %}
{{ make_header(name, img_src) }}
{% endfor -%}
{% endblock %}
{% block custom_table_row scoped %}
{%- set player_inventory = inventories[(team, player)] -%}
{%- set prog_science = player_inventory["progressive-science-pack"] -%}
{%- for name, internal_name, img_src in science_packs %}
{% if player_inventory[internal_name] or prog_science > loop.index0 %}
<td class="center-column item-acquired">✔️</td>
{% else %}
<td class="center-column"></td>
{% endif %}
{% endfor -%}
{% endblock%}

View File

@@ -16,7 +16,7 @@
{% with messages = get_flashed_messages() %}
{% if messages %}
<div>
{% for message in messages %}
{% for message in messages | unique %}
<div class="user-message">{{ message }}</div>
{% endfor %}
</div>

View File

@@ -1,26 +1,26 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>{{ game }} Settings</title>
<title>{{ game }} Options</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/player-settings.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/player-options.css") }}" />
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/md5.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/player-settings.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/player-options.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/'+theme+'Header.html' %}
<div id="player-settings" class="markdown" data-game="{{ game }}">
<div id="player-options" class="markdown" data-game="{{ game }}">
<div id="user-message"></div>
<h1><span id="game-name">Player</span> Settings</h1>
<h1><span id="game-name">Player</span> Options</h1>
<p>Choose the options you would like to play with! You may generate a single-player game from this page,
or download a settings file you can use to participate in a MultiWorld.</p>
or download an options file you can use to participate in a MultiWorld.</p>
<p>
A more advanced settings configuration for all games can be found on the
<a href="/weighted-settings">Weighted Settings</a> page.
A more advanced options configuration for all games can be found on the
<a href="/weighted-options">Weighted options</a> page.
<br />
A list of all games you have generated can be found on the <a href="/user-content">User Content Page</a>.
<br />
@@ -28,10 +28,24 @@
<a href="/static/generated/configs/{{ game }}.yaml">template file for this game</a>.
</p>
<p><label for="player-name">Please enter your player name. This will appear in-game as you send and receive
items if you are playing in a MultiWorld.</label><br />
<input id="player-name" placeholder="Player Name" data-key="name" maxlength="16" />
</p>
<div id="meta-options">
<div>
<label for="player-name">
Player Name: <span class="interactive" data-tooltip="This is the name you use to connect with your game. This is also known as your 'slot name'.">(?)</span>
</label>
<input id="player-name" placeholder="Player" data-key="name" maxlength="16" />
</div>
<div>
<label for="game-options-preset">
Options Preset: <span class="interactive" data-tooltip="Select from a list of developer-curated presets (if any) or reset all options to their defaults.">(?)</span>
</label>
<select id="game-options-preset">
<option value="__default">Defaults</option>
<option value="__custom" hidden>Custom</option>
</select>
</div>
</div>
<h2>Game Options</h2>
<div id="game-options">
@@ -39,8 +53,8 @@
<div id="game-options-right" class="right"></div>
</div>
<div id="player-settings-button-row">
<button id="export-settings">Export Settings</button>
<div id="player-options-button-row">
<button id="export-options">Export Options</button>
<button id="generate-game">Generate Game</button>
<button id="generate-race">Generate Race</button>
</div>

View File

@@ -24,7 +24,7 @@
<li><a href="/games">Supported Games Page</a></li>
<li><a href="/tutorial">Tutorials Page</a></li>
<li><a href="/user-content">User Content</a></li>
<li><a href="/weighted-settings">Weighted Settings Page</a></li>
<li><a href="/weighted-options">Weighted Options Page</a></li>
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
<li><a href="/glossary/en">Glossary</a></li>
</ul>
@@ -46,11 +46,11 @@
{% endfor %}
</ul>
<h2>Game Settings Pages</h2>
<h2>Game Options Pages</h2>
<ul>
{% for game in games | title_sorted %}
{% if game['has_settings'] %}
<li><a href="{{ url_for('player_settings', game=game['title']) }}">{{ game['title'] }}</a></li>
<li><a href="{{ url_for('player_options', game=game['title']) }}">{{ game['title'] }}</a></li>
{% endif %}
{% endfor %}
</ul>

View File

@@ -5,15 +5,35 @@
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/supportedGames.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/supportedGames.js") }}"></script>
<noscript>
<style>
/* always un-collapse all and hide arrow and search bar */
.js-only{
display: none;
}
#games p.collapsed{
display: block;
}
#games h2 .collapse-arrow{
display: none;
}
#games .collapse-toggle{
cursor: unset;
}
</style>
</noscript>
{% endblock %}
{% block body %}
{% include 'header/oceanHeader.html' %}
<div id="games" class="markdown">
<h1>Currently Supported Games</h1>
<div>
<div class="js-only">
<label for="game-search">Search for your game below!</label><br />
<div id="page-controls">
<div class="page-controls">
<input id="game-search" placeholder="Search by title..." autofocus />
<button id="expand-all">Expand All</button>
<button id="collapse-all">Collapse All</button>
@@ -22,21 +42,21 @@
{% for game_name in worlds | title_sorted %}
{% set world = worlds[game_name] %}
<h2 class="collapse-toggle" data-game="{{ game_name }}">
<span id="{{ game_name }}-arrow" class="collapse-arrow"></span>&nbsp;{{ game_name }}
<span class="collapse-arrow"></span>{{ game_name }}
</h2>
<p id="{{ game_name }}" class="collapsed">
<p class="collapsed">
{{ world.__doc__ | default("No description provided.", true) }}<br />
<a href="{{ url_for("game_info", game=game_name, lang="en") }}">Game Page</a>
{% if world.web.tutorials %}
<span class="link-spacer">|</span>
<a href="{{ url_for("tutorial_landing") }}#{{ game_name }}">Setup Guides</a>
{% endif %}
{% if world.web.settings_page is string %}
{% if world.web.options_page is string %}
<span class="link-spacer">|</span>
<a href="{{ world.web.settings_page }}">Settings Page</a>
{% elif world.web.settings_page %}
<a href="{{ world.web.options_page }}">Options Page</a>
{% elif world.web.options_page %}
<span class="link-spacer">|</span>
<a href="{{ url_for("player_settings", game=game_name) }}">Settings Page</a>
<a href="{{ url_for("player_options", game=game_name) }}">Options Page</a>
{% endif %}
{% if world.web.bug_report_page %}
<span class="link-spacer">|</span>

View File

@@ -0,0 +1,154 @@
{%- set icons = {
"Blue Shield": "https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
"Red Shield": "https://www.zeldadungeon.net/wiki/images/5/55/Fire-Shield.png",
"Mirror Shield": "https://www.zeldadungeon.net/wiki/images/8/84/Mirror-Shield.png",
"Fighter Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/40/SFighterSword.png?width=1920",
"Master Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/SMasterSword.png?width=1920",
"Tempered Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/92/STemperedSword.png?width=1920",
"Golden Sword": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/2/28/SGoldenSword.png?width=1920",
"Bow": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=5f85a70e6366bf473544ef93b274f74c",
"Silver Bow": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/6/65/Bow.png?width=1920",
"Green Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c9/SGreenTunic.png?width=1920",
"Blue Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/9/98/SBlueTunic.png?width=1920",
"Red Mail": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/7/74/SRedTunic.png?width=1920",
"Power Glove": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/f/f5/SPowerGlove.png?width=1920",
"Titan Mitts": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920",
"Progressive Sword": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/cc/ALttP_Master_Sword_Sprite.png?version=55869db2a20e157cd3b5c8f556097725",
"Pegasus Boots": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Pegasus_Shoes_Sprite.png?version=405f42f97240c9dcd2b71ffc4bebc7f9",
"Progressive Glove": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/c/c1/STitanMitt.png?width=1920",
"Flippers": "https://oyster.ignimgs.com/mediawiki/apis.ign.com/the-legend-of-zelda-a-link-to-the-past/4/4c/ZoraFlippers.png?width=1920",
"Moon Pearl": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Moon_Pearl_Sprite.png?version=d601542d5abcc3e006ee163254bea77e",
"Progressive Bow": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Bow_%26_Arrows_Sprite.png?version=cfb7648b3714cccc80e2b17b2adf00ed",
"Blue Boomerang": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c3/ALttP_Boomerang_Sprite.png?version=96127d163759395eb510b81a556d500e",
"Red Boomerang": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Magical_Boomerang_Sprite.png?version=47cddce7a07bc3e4c2c10727b491f400",
"Hookshot": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/24/Hookshot.png?version=c90bc8e07a52e8090377bd6ef854c18b",
"Mushroom": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/35/ALttP_Mushroom_Sprite.png?version=1f1acb30d71bd96b60a3491e54bbfe59",
"Magic Powder": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Powder_Sprite.png?version=c24e38effbd4f80496d35830ce8ff4ec",
"Fire Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d6/FireRod.png?version=6eabc9f24d25697e2c4cd43ddc8207c0",
"Ice Rod": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d7/ALttP_Ice_Rod_Sprite.png?version=1f944148223d91cfc6a615c92286c3bc",
"Bombos": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/8c/ALttP_Bombos_Medallion_Sprite.png?version=f4d6aba47fb69375e090178f0fc33b26",
"Ether": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/Ether.png?version=34027651a5565fcc5a83189178ab17b5",
"Quake": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/56/ALttP_Quake_Medallion_Sprite.png?version=efd64d451b1831bd59f7b7d6b61b5879",
"Lamp": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/6/63/ALttP_Lantern_Sprite.png?version=e76eaa1ec509c9a5efb2916698d5a4ce",
"Hammer": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d1/ALttP_Hammer_Sprite.png?version=e0adec227193818dcaedf587eba34500",
"Shovel": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/c/c4/ALttP_Shovel_Sprite.png?version=e73d1ce0115c2c70eaca15b014bd6f05",
"Flute": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/db/Flute.png?version=ec4982b31c56da2c0c010905c5c60390",
"Bug Catching Net": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/5/54/Bug-CatchingNet.png?version=4d40e0ee015b687ff75b333b968d8be6",
"Book of Mudora": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/2/22/ALttP_Book_of_Mudora_Sprite.png?version=11e4632bba54f6b9bf921df06ac93744",
"Bottle": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ef/ALttP_Magic_Bottle_Sprite.png?version=fd98ab04db775270cbe79fce0235777b",
"Cane of Somaria": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e1/ALttP_Cane_of_Somaria_Sprite.png?version=8cc1900dfd887890badffc903bb87943",
"Cane of Byrna": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/bc/ALttP_Cane_of_Byrna_Sprite.png?version=758b607c8cbe2cf1900d42a0b3d0fb54",
"Cape": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1c/ALttP_Magic_Cape_Sprite.png?version=6b77f0d609aab0c751307fc124736832",
"Magic Mirror": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e5/ALttP_Magic_Mirror_Sprite.png?version=e035dbc9cbe2a3bd44aa6d047762b0cc",
"Triforce": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/4/4e/TriforceALttPTitle.png?version=dc398e1293177581c16303e4f9d12a48",
"Triforce Piece": "https://www.zeldadungeon.net/wiki/images/thumb/5/54/Triforce_Fragment_-_BS_Zelda.png/62px-Triforce_Fragment_-_BS_Zelda.png",
"Small Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/f/f1/ALttP_Small_Key_Sprite.png?version=4f35d92842f0de39d969181eea03774e",
"Big Key": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Big_Key_Sprite.png?version=136dfa418ba76c8b4e270f466fc12f4d",
"Chest": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Treasure_Chest_Sprite.png?version=5f530ecd98dcb22251e146e8049c0dda",
"Light World": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/e7/ALttP_Soldier_Green_Sprite.png?version=d650d417934cd707a47e496489c268a6",
"Dark World": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/94/ALttP_Moblin_Sprite.png?version=ebf50e33f4657c377d1606bcc0886ddc",
"Hyrule Castle": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/d/d3/ALttP_Ball_and_Chain_Trooper_Sprite.png?version=1768a87c06d29cc8e7ddd80b9fa516be",
"Agahnims Tower": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/1/1e/ALttP_Agahnim_Sprite.png?version=365956e61b0c2191eae4eddbe591dab5",
"Desert Palace": "https://www.zeldadungeon.net/wiki/images/2/25/Lanmola-ALTTP-Sprite.png",
"Eastern Palace": "https://www.zeldadungeon.net/wiki/images/d/dc/RedArmosKnight.png",
"Tower of Hera": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/3c/ALttP_Moldorm_Sprite.png?version=c588257bdc2543468e008a6b30f262a7",
"Palace of Darkness": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/e/ed/ALttP_Helmasaur_King_Sprite.png?version=ab8a4a1cfd91d4fc43466c56cba30022",
"Swamp Palace": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/7/73/ALttP_Arrghus_Sprite.png?version=b098be3122e53f751b74f4a5ef9184b5",
"Skull Woods": "https://alttp-wiki.net/images/6/6a/Mothula.png",
"Thieves Town": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/86/ALttP_Blind_the_Thief_Sprite.png?version=3833021bfcd112be54e7390679047222",
"Ice Palace": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/3/33/ALttP_Kholdstare_Sprite.png?version=e5a1b0e8b2298e550d85f90bf97045c0",
"Misery Mire": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/8/85/ALttP_Vitreous_Sprite.png?version=92b2e9cb0aa63f831760f08041d8d8d8",
"Turtle Rock": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/9/91/ALttP_Trinexx_Sprite.png?version=0cc867d513952aa03edd155597a0c0be",
"Ganons Tower": "https://gamepedia.cursecdn.com/zelda_gamepedia_en/b/b9/ALttP_Ganon_Sprite.png?version=956f51f054954dfff53c1a9d4f929c74",
} -%}
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/lttp-tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttp-tracker.js") }}"></script>
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ icons[bow_icon] }}" class="{{ 'acquired' if bow_acquired }}" /></td>
<td><img src="{{ icons["Blue Boomerang"] }}" class="{{ 'acquired' if 'Blue Boomerang' in acquired_items }}" /></td>
<td><img src="{{ icons["Red Boomerang"] }}" class="{{ 'acquired' if 'Red Boomerang' in acquired_items }}" /></td>
<td><img src="{{ icons["Hookshot"] }}" class="{{ 'acquired' if 'Hookshot' in acquired_items }}" /></td>
<td><img src="{{ icons["Magic Powder"] }}" class="powder-fix {{ 'acquired' if 'Magic Powder' in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Fire Rod"] }}" class="{{ 'acquired' if "Fire Rod" in acquired_items }}" /></td>
<td><img src="{{ icons["Ice Rod"] }}" class="{{ 'acquired' if "Ice Rod" in acquired_items }}" /></td>
<td><img src="{{ icons["Bombos"] }}" class="{{ 'acquired' if "Bombos" in acquired_items }}" /></td>
<td><img src="{{ icons["Ether"] }}" class="{{ 'acquired' if "Ether" in acquired_items }}" /></td>
<td><img src="{{ icons["Quake"] }}" class="{{ 'acquired' if "Quake" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Lamp"] }}" class="{{ 'acquired' if "Lamp" in acquired_items }}" /></td>
<td><img src="{{ icons["Hammer"] }}" class="{{ 'acquired' if "Hammer" in acquired_items }}" /></td>
<td><img src="{{ icons["Flute"] }}" class="{{ 'acquired' if "Flute" in acquired_items }}" /></td>
<td><img src="{{ icons["Bug Catching Net"] }}" class="{{ 'acquired' if "Bug Catching Net" in acquired_items }}" /></td>
<td><img src="{{ icons["Book of Mudora"] }}" class="{{ 'acquired' if "Book of Mudora" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Bottle"] }}" class="{{ 'acquired' if "Bottle" in acquired_items }}" /></td>
<td><img src="{{ icons["Cane of Somaria"] }}" class="{{ 'acquired' if "Cane of Somaria" in acquired_items }}" /></td>
<td><img src="{{ icons["Cane of Byrna"] }}" class="{{ 'acquired' if "Cane of Byrna" in acquired_items }}" /></td>
<td><img src="{{ icons["Cape"] }}" class="{{ 'acquired' if "Cape" in acquired_items }}" /></td>
<td><img src="{{ icons["Magic Mirror"] }}" class="{{ 'acquired' if "Magic Mirror" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons["Pegasus Boots"] }}" class="{{ 'acquired' if "Pegasus Boots" in acquired_items }}" /></td>
<td><img src="{{ icons[glove_icon] }}" class="{{ 'acquired' if glove_acquired }}" /></td>
<td><img src="{{ icons["Flippers"] }}" class="{{ 'acquired' if "Flippers" in acquired_items }}" /></td>
<td><img src="{{ icons["Moon Pearl"] }}" class="{{ 'acquired' if "Moon Pearl" in acquired_items }}" /></td>
<td><img src="{{ icons["Mushroom"] }}" class="{{ 'acquired' if "Mushroom" in acquired_items }}" /></td>
</tr>
<tr>
<td><img src="{{ icons[sword_icon] }}" class="{{ 'acquired' if sword_acquired }}" /></td>
<td><img src="{{ icons[shield_icon] }}" class="{{ 'acquired' if shield_acquired }}" /></td>
<td><img src="{{ icons[mail_icon] }}" class="acquired" /></td>
<td><img src="{{ icons["Shovel"] }}" class="{{ 'acquired' if "Shovel" in acquired_items }}" /></td>
<td><img src="{{ icons["Triforce"] }}" class="{{ 'acquired' if "Triforce" in acquired_items }}" /></td>
</tr>
</table>
<table id="location-table">
<tr>
<th></th>
<th class="counter"><img src="{{ icons["Chest"] }}" /></th>
{% if key_locations and "Universal" not in key_locations %}
<th class="counter"><img src="{{ icons["Small Key"] }}" /></th>
{% endif %}
{% if big_key_locations %}
<th><img src="{{ icons["Big Key"] }}" /></th>
{% endif %}
</tr>
{% for area in sp_areas %}
<tr>
<td>{{ area }}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
{% if key_locations and "Universal" not in key_locations %}
<td class="counter">
{{ inventory[small_key_ids[area]] if area in key_locations else '—' }}
</td>
{% endif %}
{% if big_key_locations %}
<td>
{{ '✔' if area in big_key_locations and inventory[big_key_ids[area]] else ('—' if area not in big_key_locations else '') }}
</td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -7,6 +7,11 @@
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr class="column-headers">

View File

@@ -8,13 +8,18 @@
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
<td><img src="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></td>
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
title="Progressive Resource Crafting" /></td>
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
<td>

View File

@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/ootTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/ootTracker.js') }}"></script>
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ ocarina_url }}" class="{{ 'acquired' if 'Ocarina' in acquired_items }}" title="Ocarina" /></td>
<td><img src="{{ icons['Bombs'] }}" class="{{ 'acquired' if 'Bomb Bag' in acquired_items }}" title="Bombs" /></td>
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Bow' in acquired_items }}" title="Fairy Bow" /></td>
<td><img src="{{ icons['Fire Arrows'] }}" class="{{ 'acquired' if 'Fire Arrows' in acquired_items }}" title="Fire Arrows" /></td>
<td><img src="{{ icons['Kokiri Sword'] }}" class="{{ 'acquired' if 'Kokiri Sword' in acquired_items }}" title="Kokiri Sword" /></td>
<td><img src="{{ icons['Biggoron Sword'] }}" class="{{ 'acquired' if 'Biggoron Sword' in acquired_items }}" title="Biggoron's Sword" /></td>
<td><img src="{{ icons['Mirror Shield'] }}" class="{{ 'acquired' if 'Mirror Shield' in acquired_items }}" title="Mirror Shield" /></td>
</tr>
<tr>
<td><img src="{{ icons['Slingshot'] }}" class="{{ 'acquired' if 'Slingshot' in acquired_items }}" title="Slingshot" /></td>
<td><img src="{{ icons['Bombchus'] }}" class="{{ 'acquired' if has_bombchus }}" title="Bombchus" /></td>
<td>
<div class="counted-item">
<img src="{{ hookshot_url }}" class="{{ 'acquired' if 'Progressive Hookshot' in acquired_items }}" title="Progressive Hookshot" />
<div class="item-count">{{ hookshot_length }}</div>
</div>
</td>
<td><img src="{{ icons['Ice Arrows'] }}" class="{{ 'acquired' if 'Ice Arrows' in acquired_items }}" title="Ice Arrows" /></td>
<td><img src="{{ strength_upgrade_url }}" class="{{ 'acquired' if 'Progressive Strength Upgrade' in acquired_items }}" title="Progressive Strength Upgrade" /></td>
<td><img src="{{ icons['Goron Tunic'] }}" class="{{ 'acquired' if 'Goron Tunic' in acquired_items }}" title="Goron Tunic" /></td>
<td><img src="{{ icons['Zora Tunic'] }}" class="{{ 'acquired' if 'Zora Tunic' in acquired_items }}" title="Zora Tunic" /></td>
</tr>
<tr>
<td><img src="{{ icons['Boomerang'] }}" class="{{ 'acquired' if 'Boomerang' in acquired_items }}" title="Boomerang" /></td>
<td><img src="{{ icons['Lens of Truth'] }}" class="{{ 'acquired' if 'Lens of Truth' in acquired_items }}" title="Lens of Truth" /></td>
<td><img src="{{ icons['Megaton Hammer'] }}" class="{{ 'acquired' if 'Megaton Hammer' in acquired_items }}" title="Megaton Hammer" /></td>
<td><img src="{{ icons['Light Arrows'] }}" class="{{ 'acquired' if 'Light Arrows' in acquired_items }}" title="Light Arrows" /></td>
<td><img src="{{ scale_url }}" class="{{ 'acquired' if 'Progressive Scale' in acquired_items }}" title="Progressive Scale" /></td>
<td><img src="{{ icons['Iron Boots'] }}" class="{{ 'acquired' if 'Iron Boots' in acquired_items }}" title="Iron Boots" /></td>
<td><img src="{{ icons['Hover Boots'] }}" class="{{ 'acquired' if 'Hover Boots' in acquired_items }}" title="Hover Boots" /></td>
</tr>
<tr>
<td>
<div class="counted-item">
<img src="{{ bottle_url }}" class="{{ 'acquired' if bottle_count > 0 }}" title="Bottles" />
<div class="item-count">{{ bottle_count if bottle_count > 0 else '' }}</div>
</div>
</td>
<td><img src="{{ icons['Dins Fire'] }}" class="{{ 'acquired' if 'Dins Fire' in acquired_items }}" title="Din's Fire" /></td>
<td><img src="{{ icons['Farores Wind'] }}" class="{{ 'acquired' if 'Farores Wind' in acquired_items }}" title="Farore's Wind" /></td>
<td><img src="{{ icons['Nayrus Love'] }}" class="{{ 'acquired' if 'Nayrus Love' in acquired_items }}" title="Nayru's Love" /></td>
<td>
<div class="counted-item">
<img src="{{ wallet_url }}" class="{{ 'acquired' if 'Progressive Wallet' in acquired_items }}" title="Progressive Wallet" />
<div class="item-count">{{ wallet_size }}</div>
</div>
</td>
<td><img src="{{ magic_meter_url }}" class="{{ 'acquired' if 'Magic Meter' in acquired_items }}" title="Magic Meter" /></td>
<td><img src="{{ icons['Gerudo Membership Card'] }}" class="{{ 'acquired' if 'Gerudo Membership Card' in acquired_items }}" title="Gerudo Membership Card" /></td>
</tr>
<tr>
<td>
<div class="counted-item">
<img src="{{ icons['Zeldas Lullaby'] }}" class="{{ 'acquired' if 'Zeldas Lullaby' in acquired_items }}" title="Zelda's Lullaby" id="lullaby"/>
<div class="item-count">Zelda</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Eponas Song'] }}" class="{{ 'acquired' if 'Eponas Song' in acquired_items }}" title="Epona's Song" id="epona" />
<div class="item-count">Epona</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Sarias Song'] }}" class="{{ 'acquired' if 'Sarias Song' in acquired_items }}" title="Saria's Song" id="saria"/>
<div class="item-count">Saria</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Suns Song'] }}" class="{{ 'acquired' if 'Suns Song' in acquired_items }}" title="Sun's Song" id="sun"/>
<div class="item-count">Sun</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Song of Time'] }}" class="{{ 'acquired' if 'Song of Time' in acquired_items }}" title="Song of Time" id="time"/>
<div class="item-count">Time</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Song of Storms'] }}" class="{{ 'acquired' if 'Song of Storms' in acquired_items }}" title="Song of Storms" />
<div class="item-count">Storms</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Gold Skulltula Token'] }}" class="{{ 'acquired' if token_count > 0 }}" title="Gold Skulltula Tokens" />
<div class="item-count">{{ token_count }}</div>
</div>
</td>
</tr>
<tr>
<td>
<div class="counted-item">
<img src="{{ icons['Minuet of Forest'] }}" class="{{ 'acquired' if 'Minuet of Forest' in acquired_items }}" title="Minuet of Forest" />
<div class="item-count">Min</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Bolero of Fire'] }}" class="{{ 'acquired' if 'Bolero of Fire' in acquired_items }}" title="Bolero of Fire" />
<div class="item-count">Bol</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Serenade of Water'] }}" class="{{ 'acquired' if 'Serenade of Water' in acquired_items }}" title="Serenade of Water" />
<div class="item-count">Ser</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Requiem of Spirit'] }}" class="{{ 'acquired' if 'Requiem of Spirit' in acquired_items }}" title="Requiem of Spirit" />
<div class="item-count">Req</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Nocturne of Shadow'] }}" class="{{ 'acquired' if 'Nocturne of Shadow' in acquired_items }}" title="Nocturne of Shadow" />
<div class="item-count">Noc</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Prelude of Light'] }}" class="{{ 'acquired' if 'Prelude of Light' in acquired_items }}" title="Prelude of Light" />
<div class="item-count">Pre</div>
</div>
</td>
<td>
<div class="counted-item">
<img src="{{ icons['Triforce'] if game_finished else icons['Triforce Piece'] }}" class="{{ 'acquired' if game_finished or piece_count > 0 }}" title="{{ 'Triforce' if game_finished else 'Triforce Pieces' }}" id=triforce />
<div class="item-count">{{ piece_count if piece_count > 0 else '' }}</div>
</div>
</td>
</tr>
</table>
<table id="location-table">
<tr>
<td></td>
<td><img src="{{ icons['Small Key'] }}" title="Small Keys" /></td>
<td><img src="{{ icons['Boss Key'] }}" title="Boss Key" /></td>
<td class="right-align">Items</td>
</tr>
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="smallkeys">{{ small_key_counts.get(area, '-') }}</td>
<td class="bosskeys">{{ boss_key_counts.get(area, '-') }}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
</tr>
<tbody class="locations hide" id="{{area}}">
{% for location in location_info[area] %}
<tr>
<td class="location-name">{{ location }}</td>
<td></td>
<td></td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -8,6 +8,11 @@
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>

View File

@@ -7,6 +7,11 @@
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>

View File

@@ -7,6 +7,11 @@
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<div id="inventory-table">
<div class="table-row">
@@ -51,16 +56,16 @@
<div class="C4"><img src="{{ icons['Security Keycard D'] }}" class="{{ 'acquired' if 'Security Keycard D' in acquired_items }}" title="Security Keycard D" /></div>
{% if 'DownloadableItems' in options %}
<div class="C5"><img src="{{ icons['Library Keycard V'] }}" class="{{ 'acquired' if 'Library Keycard V' in acquired_items }}" title="Library Keycard V" /></div>
{% endif %}
{% endif %}
</div>
<div class="table-row">
{% if 'DownloadableItems' in options %}
<div class="C1"><img src="{{ icons['Tablet'] }}" class="{{ 'acquired' if 'Tablet' in acquired_items }}" title="Tablet" /></div>
{% endif %}
{% endif %}
<div class="C2"><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></div>
{% if 'EyeSpy' in options %}
<div class="C3"><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></div>
{% endif %}
{% endif %}
<div class="C4"><img src="{{ icons['Water Mask'] }}" class="{{ 'acquired' if 'Water Mask' in acquired_items }}" title="Water Mask" /></div>
<div class="C5"><img src="{{ icons['Gas Mask'] }}" class="{{ 'acquired' if 'Gas Mask' in acquired_items }}" title="Gas Mask" /></div>
</div>

View File

@@ -1,26 +1,26 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>{{ game }} Settings</title>
<title>{{ game }} Options</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/weighted-settings.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/weighted-options.css") }}" />
<script type="application/ecmascript" src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/md5.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/js-yaml.min.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/weighted-settings.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/weighted-options.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/grassHeader.html' %}
<div id="weighted-settings" class="markdown" data-game="{{ game }}">
<div id="user-message"></div>
<h1>Weighted Settings</h1>
<p>Weighted Settings allows you to choose how likely a particular option is to be used in game generation.
<h1>Weighted Options</h1>
<p>Weighted options allow you to choose how likely a particular option is to be used in game generation.
The higher an option is weighted, the more likely the option will be chosen. Think of them like
entries in a raffle.</p>
<p>Choose the games and options you would like to play with! You may generate a single-player game from
this page, or download a settings file you can use to participate in a MultiWorld.</p>
this page, or download an options file you can use to participate in a MultiWorld.</p>
<p>A list of all games you have generated can be found on the <a href="/user-content">User Content</a>
page.</p>
@@ -40,7 +40,7 @@
</div>
<div id="weighted-settings-button-row">
<button id="export-settings">Export Settings</button>
<button id="export-options">Export Options</button>
<button id="generate-game">Generate Game</button>
<button id="generate-race">Generate Race</button>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -11,17 +11,46 @@ from flask import request, flash, redirect, url_for, session, render_template
from markupsafe import Markup
from pony.orm import commit, flush, select, rollback
from pony.orm.core import TransactionIntegrityError
import schema
import MultiServer
from NetUtils import SlotType
from Utils import VersionException, __version__
from worlds import GamesPackage
from worlds.Files import AutoPatchRegister
from worlds.AutoWorld import data_package_checksum
from . import app
from .models import Seed, Room, Slot, GameDataPackage
banned_zip_contents = (".sfc", ".z64", ".n64", ".sms", ".gb")
banned_extensions = (".sfc", ".z64", ".n64", ".nes", ".smc", ".sms", ".gb", ".gbc", ".gba")
allowed_options_extensions = (".yaml", ".json", ".yml", ".txt", ".zip")
allowed_generation_extensions = (".archipelago", ".zip")
games_package_schema = schema.Schema({
"item_name_groups": {str: [str]},
"item_name_to_id": {str: int},
"location_name_groups": {str: [str]},
"location_name_to_id": {str: int},
schema.Optional("checksum"): str,
schema.Optional("version"): int,
})
def allowed_options(filename: str) -> bool:
return filename.endswith(allowed_options_extensions)
def allowed_generation(filename: str) -> bool:
return filename.endswith(allowed_generation_extensions)
def banned_file(filename: str) -> bool:
return filename.endswith(banned_extensions)
def process_multidata(compressed_multidata, files={}):
game_data: GamesPackage
decompressed_multidata = MultiServer.Context.decompress(compressed_multidata)
slots: typing.Set[Slot] = set()
@@ -30,11 +59,19 @@ def process_multidata(compressed_multidata, files={}):
game_data_packages: typing.List[GameDataPackage] = []
for game, game_data in decompressed_multidata["datapackage"].items():
if game_data.get("checksum"):
original_checksum = game_data.pop("checksum")
game_data = games_package_schema.validate(game_data)
game_data = {key: value for key, value in sorted(game_data.items())}
game_data["checksum"] = data_package_checksum(game_data)
game_data_package = GameDataPackage(checksum=game_data["checksum"],
data=pickle.dumps(game_data))
if original_checksum != game_data["checksum"]:
raise Exception(f"Original checksum {original_checksum} != "
f"calculated checksum {game_data['checksum']} "
f"for game {game}.")
decompressed_multidata["datapackage"][game] = {
"version": game_data.get("version", 0),
"checksum": game_data["checksum"]
"checksum": game_data["checksum"],
}
try:
commit() # commit game data package
@@ -49,20 +86,21 @@ def process_multidata(compressed_multidata, files={}):
if slot_info.type == SlotType.group:
continue
slots.add(Slot(data=files.get(slot, None),
player_name=slot_info.name,
player_id=slot,
game=slot_info.game))
player_name=slot_info.name,
player_id=slot,
game=slot_info.game))
flush() # commit slots
compressed_multidata = compressed_multidata[0:1] + zlib.compress(pickle.dumps(decompressed_multidata), 9)
return slots, compressed_multidata
def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, sid=None):
if not owner:
owner = session["_id"]
infolist = zfile.infolist()
if all(file.filename.endswith((".yaml", ".yml")) or file.is_dir() for file in infolist):
flash(Markup("Error: Your .zip file only contains .yaml files. "
if all(allowed_options(file.filename) or file.is_dir() for file in infolist):
flash(Markup("Error: Your .zip file only contains options files. "
'Did you mean to <a href="/generate">generate a game</a>?'))
return
@@ -73,7 +111,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
# Load files.
for file in infolist:
handler = AutoPatchRegister.get_handler(file.filename)
if file.filename.endswith(banned_zip_contents):
if banned_file(file.filename):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted."
@@ -104,13 +142,21 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
# Factorio
elif file.filename.endswith(".zip"):
_, _, slot_id, *_ = file.filename.split('_')[0].split('-', 3)
try:
_, _, slot_id, *_ = file.filename.split('_')[0].split('-', 3)
except ValueError:
flash("Error: Unexpected file found in .zip: " + file.filename)
return
data = zfile.open(file, "r").read()
files[int(slot_id[1:])] = data
# All other files using the standard MultiWorld.get_out_file_name_base method
else:
_, _, slot_id, *_ = file.filename.split('.')[0].split('_', 3)
try:
_, _, slot_id, *_ = file.filename.split('.')[0].split('_', 3)
except ValueError:
flash("Error: Unexpected file found in .zip: " + file.filename)
return
data = zfile.open(file, "r").read()
files[int(slot_id[1:])] = data
@@ -128,35 +174,34 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
flash("No multidata was found in the zip file, which is required.")
@app.route('/uploads', methods=['GET', 'POST'])
@app.route("/uploads", methods=["GET", "POST"])
def uploads():
if request.method == 'POST':
# check if the post request has the file part
if 'file' not in request.files:
flash('No file part')
if request.method == "POST":
# check if the POST request has a file part.
if "file" not in request.files:
flash("No file part in POST request.")
else:
file = request.files['file']
# if user does not select file, browser also
# submit an empty part without filename
if file.filename == '':
flash('No selected file')
elif file and allowed_file(file.filename):
if zipfile.is_zipfile(file):
with zipfile.ZipFile(file, 'r') as zfile:
uploaded_file = request.files["file"]
# If the user does not select file, the browser will still submit an empty string without a file name.
if uploaded_file.filename == "":
flash("No selected file.")
elif uploaded_file and allowed_generation(uploaded_file.filename):
if zipfile.is_zipfile(uploaded_file):
with zipfile.ZipFile(uploaded_file, "r") as zfile:
try:
res = upload_zip_to_db(zfile)
except VersionException:
flash(f"Could not load multidata. Wrong Version detected.")
else:
if type(res) == str:
if res is str:
return res
elif res:
return redirect(url_for("view_seed", seed=res.id))
else:
file.seek(0) # offset from is_zipfile check
uploaded_file.seek(0) # offset from is_zipfile check
# noinspection PyBroadException
try:
multidata = file.read()
multidata = uploaded_file.read()
slots, multidata = process_multidata(multidata)
except Exception as e:
flash(f"Could not load multidata. File may be corrupted or incompatible. ({e})")
@@ -174,7 +219,3 @@ def user_content():
rooms = select(room for room in Room if room.owner == session["_id"])
seeds = select(seed for seed in Seed if seed.owner == session["_id"])
return render_template("userContent.html", rooms=rooms, seeds=seeds)
def allowed_file(filename):
return filename.endswith(('.archipelago', ".zip"))

View File

@@ -13,7 +13,6 @@ from typing import List
import Utils
from Utils import async_start
from worlds import lookup_any_location_id_to_name
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
get_base_parser
@@ -153,7 +152,7 @@ def get_payload(ctx: ZeldaContext):
def reconcile_shops(ctx: ZeldaContext):
checked_location_names = [lookup_any_location_id_to_name[location] for location in ctx.checked_locations]
checked_location_names = [ctx.location_names[location] for location in ctx.checked_locations]
shops = [location for location in checked_location_names if "Shop" in location]
left_slots = [shop for shop in shops if "Left" in shop]
middle_slots = [shop for shop in shops if "Middle" in shop]
@@ -191,7 +190,7 @@ async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone=
locations_checked = []
location = None
for location in ctx.missing_locations:
location_name = lookup_any_location_id_to_name[location]
location_name = ctx.location_names[location]
if location_name in Locations.overworld_locations and zone == "overworld":
status = locations_array[Locations.major_location_offsets[location_name]]

View File

@@ -1,505 +1,10 @@
import asyncio
import base64
import platform
from typing import Any, ClassVar, Coroutine, Dict, List, Optional, Protocol, Tuple, Type, cast
import ModuleUpdate
ModuleUpdate.update()
# CommonClient import first to trigger ModuleUpdater
from CommonClient import CommonContext, server_loop, gui_enabled, \
ClientCommandProcessor, logger, get_base_parser
from NetUtils import ClientStatus
import Utils
from Utils import async_start
import colorama # type: ignore
from zilliandomizer.zri.memory import Memory
from zilliandomizer.zri import events
from zilliandomizer.utils.loc_name_maps import id_to_loc
from zilliandomizer.options import Chars
from zilliandomizer.patch import RescueInfo
from worlds.zillion.id_maps import make_id_to_others
from worlds.zillion.config import base_id, zillion_map
class ZillionCommandProcessor(ClientCommandProcessor):
ctx: "ZillionContext"
def _cmd_sms(self) -> None:
""" Tell the client that Zillion is running in RetroArch. """
logger.info("ready to look for game")
self.ctx.look_for_retroarch.set()
def _cmd_map(self) -> None:
""" Toggle view of the map tracker. """
self.ctx.ui_toggle_map()
class ToggleCallback(Protocol):
def __call__(self) -> None: ...
class SetRoomCallback(Protocol):
def __call__(self, rooms: List[List[int]]) -> None: ...
class ZillionContext(CommonContext):
game = "Zillion"
command_processor: Type[ClientCommandProcessor] = ZillionCommandProcessor
items_handling = 1 # receive items from other players
known_name: Optional[str]
""" This is almost the same as `auth` except `auth` is reset to `None` when server disconnects, and this isn't. """
from_game: "asyncio.Queue[events.EventFromGame]"
to_game: "asyncio.Queue[events.EventToGame]"
ap_local_count: int
""" local checks watched by server """
next_item: int
""" index in `items_received` """
ap_id_to_name: Dict[int, str]
ap_id_to_zz_id: Dict[int, int]
start_char: Chars = "JJ"
rescues: Dict[int, RescueInfo] = {}
loc_mem_to_id: Dict[int, int] = {}
got_room_info: asyncio.Event
""" flag for connected to server """
got_slot_data: asyncio.Event
""" serves as a flag for whether I am logged in to the server """
look_for_retroarch: asyncio.Event
"""
There is a bug in Python in Windows
https://github.com/python/cpython/issues/91227
that makes it so if I look for RetroArch before it's ready,
it breaks the asyncio udp transport system.
As a workaround, we don't look for RetroArch until this event is set.
"""
ui_toggle_map: ToggleCallback
ui_set_rooms: SetRoomCallback
""" parameter is y 16 x 8 numbers to show in each room """
def __init__(self,
server_address: str,
password: str) -> None:
super().__init__(server_address, password)
self.known_name = None
self.from_game = asyncio.Queue()
self.to_game = asyncio.Queue()
self.got_room_info = asyncio.Event()
self.got_slot_data = asyncio.Event()
self.ui_toggle_map = lambda: None
self.ui_set_rooms = lambda rooms: None
self.look_for_retroarch = asyncio.Event()
if platform.system() != "Windows":
# asyncio udp bug is only on Windows
self.look_for_retroarch.set()
self.reset_game_state()
def reset_game_state(self) -> None:
for _ in range(self.from_game.qsize()):
self.from_game.get_nowait()
for _ in range(self.to_game.qsize()):
self.to_game.get_nowait()
self.got_slot_data.clear()
self.ap_local_count = 0
self.next_item = 0
self.ap_id_to_name = {}
self.ap_id_to_zz_id = {}
self.rescues = {}
self.loc_mem_to_id = {}
self.locations_checked.clear()
self.missing_locations.clear()
self.checked_locations.clear()
self.finished_game = False
self.items_received.clear()
# override
def on_deathlink(self, data: Dict[str, Any]) -> None:
self.to_game.put_nowait(events.DeathEventToGame())
return super().on_deathlink(data)
# override
async def server_auth(self, password_requested: bool = False) -> None:
if password_requested and not self.password:
await super().server_auth(password_requested)
if not self.auth:
logger.info('waiting for connection to game...')
return
logger.info("logging in to server...")
await self.send_connect()
# override
def run_gui(self) -> None:
from kvui import GameManager
from kivy.core.text import Label as CoreLabel
from kivy.graphics import Ellipse, Color, Rectangle
from kivy.uix.layout import Layout
from kivy.uix.widget import Widget
class ZillionManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Zillion Client"
class MapPanel(Widget):
MAP_WIDTH: ClassVar[int] = 281
_number_textures: List[Any] = []
rooms: List[List[int]] = []
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self.rooms = [[0 for _ in range(8)] for _ in range(16)]
self._make_numbers()
self.update_map()
self.bind(pos=self.update_map)
# self.bind(size=self.update_bg)
def _make_numbers(self) -> None:
self._number_textures = []
for n in range(10):
label = CoreLabel(text=str(n), font_size=22, color=(0.1, 0.9, 0, 1))
label.refresh()
self._number_textures.append(label.texture)
def update_map(self, *args: Any) -> None:
self.canvas.clear()
with self.canvas:
Color(1, 1, 1, 1)
Rectangle(source=zillion_map,
pos=self.pos,
size=(ZillionManager.MapPanel.MAP_WIDTH,
int(ZillionManager.MapPanel.MAP_WIDTH * 1.456))) # aspect ratio of that image
for y in range(16):
for x in range(8):
num = self.rooms[15 - y][x]
if num > 0:
Color(0, 0, 0, 0.4)
pos = [self.pos[0] + 17 + x * 32, self.pos[1] + 14 + y * 24]
Ellipse(size=[22, 22], pos=pos)
Color(1, 1, 1, 1)
pos = [self.pos[0] + 22 + x * 32, self.pos[1] + 12 + y * 24]
num_texture = self._number_textures[num]
Rectangle(texture=num_texture, size=num_texture.size, pos=pos)
def build(self) -> Layout:
container = super().build()
self.map_widget = ZillionManager.MapPanel(size_hint_x=None, width=0)
self.main_area_container.add_widget(self.map_widget)
return container
def toggle_map_width(self) -> None:
if self.map_widget.width == 0:
self.map_widget.width = ZillionManager.MapPanel.MAP_WIDTH
else:
self.map_widget.width = 0
self.container.do_layout()
def set_rooms(self, rooms: List[List[int]]) -> None:
self.map_widget.rooms = rooms
self.map_widget.update_map()
self.ui = ZillionManager(self)
self.ui_toggle_map = lambda: self.ui.toggle_map_width()
self.ui_set_rooms = lambda rooms: self.ui.set_rooms(rooms)
run_co: Coroutine[Any, Any, None] = self.ui.async_run()
self.ui_task = asyncio.create_task(run_co, name="UI")
def on_package(self, cmd: str, args: Dict[str, Any]) -> None:
self.room_item_numbers_to_ui()
if cmd == "Connected":
logger.info("logged in to Archipelago server")
if "slot_data" not in args:
logger.warn("`Connected` packet missing `slot_data`")
return
slot_data = args["slot_data"]
if "start_char" not in slot_data:
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `start_char`")
return
self.start_char = slot_data['start_char']
if self.start_char not in {"Apple", "Champ", "JJ"}:
logger.warn("invalid Zillion `Connected` packet, "
f"`slot_data` `start_char` has invalid value: {self.start_char}")
if "rescues" not in slot_data:
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `rescues`")
return
rescues = slot_data["rescues"]
self.rescues = {}
for rescue_id, json_info in rescues.items():
assert rescue_id in ("0", "1"), f"invalid rescue_id in Zillion slot_data: {rescue_id}"
# TODO: just take start_char out of the RescueInfo so there's no opportunity for a mismatch?
assert json_info["start_char"] == self.start_char, \
f'mismatch in Zillion slot data: {json_info["start_char"]} {self.start_char}'
ri = RescueInfo(json_info["start_char"],
json_info["room_code"],
json_info["mask"])
self.rescues[0 if rescue_id == "0" else 1] = ri
if "loc_mem_to_id" not in slot_data:
logger.warn("invalid Zillion `Connected` packet, `slot_data` missing `loc_mem_to_id`")
return
loc_mem_to_id = slot_data["loc_mem_to_id"]
self.loc_mem_to_id = {}
for mem_str, id_str in loc_mem_to_id.items():
mem = int(mem_str)
id_ = int(id_str)
room_i = mem // 256
assert 0 <= room_i < 74
assert id_ in id_to_loc
self.loc_mem_to_id[mem] = id_
if len(self.loc_mem_to_id) != 394:
logger.warn("invalid Zillion `Connected` packet, "
f"`slot_data` missing locations in `loc_mem_to_id` - len {len(self.loc_mem_to_id)}")
self.got_slot_data.set()
payload = {
"cmd": "Get",
"keys": [f"zillion-{self.auth}-doors"]
}
async_start(self.send_msgs([payload]))
elif cmd == "Retrieved":
if "keys" not in args:
logger.warning(f"invalid Retrieved packet to ZillionClient: {args}")
return
keys = cast(Dict[str, Optional[str]], args["keys"])
doors_b64 = keys[f"zillion-{self.auth}-doors"]
if doors_b64:
logger.info("received door data from server")
doors = base64.b64decode(doors_b64)
self.to_game.put_nowait(events.DoorEventToGame(doors))
elif cmd == "RoomInfo":
self.seed_name = args["seed_name"]
self.got_room_info.set()
def room_item_numbers_to_ui(self) -> None:
rooms = [[0 for _ in range(8)] for _ in range(16)]
for loc_id in self.missing_locations:
loc_id_small = loc_id - base_id
loc_name = id_to_loc[loc_id_small]
y = ord(loc_name[0]) - 65
x = ord(loc_name[2]) - 49
if y == 9 and x == 5:
# don't show main computer in numbers
continue
assert (0 <= y < 16) and (0 <= x < 8), f"invalid index from location name {loc_name}"
rooms[y][x] += 1
# TODO: also add locations with locals lost from loading save state or reset
self.ui_set_rooms(rooms)
def process_from_game_queue(self) -> None:
if self.from_game.qsize():
event_from_game = self.from_game.get_nowait()
if isinstance(event_from_game, events.AcquireLocationEventFromGame):
server_id = event_from_game.id + base_id
loc_name = id_to_loc[event_from_game.id]
self.locations_checked.add(server_id)
if server_id in self.missing_locations:
self.ap_local_count += 1
n_locations = len(self.missing_locations) + len(self.checked_locations) - 1 # -1 to ignore win
logger.info(f'New Check: {loc_name} ({self.ap_local_count}/{n_locations})')
async_start(self.send_msgs([
{"cmd": 'LocationChecks', "locations": [server_id]}
]))
else:
# This will happen a lot in Zillion,
# because all the key words are local and unwatched by the server.
logger.debug(f"DEBUG: {loc_name} not in missing")
elif isinstance(event_from_game, events.DeathEventFromGame):
async_start(self.send_death())
elif isinstance(event_from_game, events.WinEventFromGame):
if not self.finished_game:
async_start(self.send_msgs([
{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}
]))
self.finished_game = True
elif isinstance(event_from_game, events.DoorEventFromGame):
if self.auth:
doors_b64 = base64.b64encode(event_from_game.doors).decode()
payload = {
"cmd": "Set",
"key": f"zillion-{self.auth}-doors",
"operations": [{"operation": "replace", "value": doors_b64}]
}
async_start(self.send_msgs([payload]))
else:
logger.warning(f"WARNING: unhandled event from game {event_from_game}")
def process_items_received(self) -> None:
if len(self.items_received) > self.next_item:
zz_item_ids = [self.ap_id_to_zz_id[item.item] for item in self.items_received]
for index in range(self.next_item, len(self.items_received)):
ap_id = self.items_received[index].item
from_name = self.player_names[self.items_received[index].player]
# TODO: colors in this text, like sni client?
logger.info(f'received {self.ap_id_to_name[ap_id]} from {from_name}')
self.to_game.put_nowait(
events.ItemEventToGame(zz_item_ids)
)
self.next_item = len(self.items_received)
def name_seed_from_ram(data: bytes) -> Tuple[str, str]:
""" returns player name, and end of seed string """
if len(data) == 0:
# no connection to game
return "", "xxx"
null_index = data.find(b'\x00')
if null_index == -1:
logger.warning(f"invalid game id in rom {repr(data)}")
null_index = len(data)
name = data[:null_index].decode()
null_index_2 = data.find(b'\x00', null_index + 1)
if null_index_2 == -1:
null_index_2 = len(data)
seed_name = data[null_index + 1:null_index_2].decode()
return name, seed_name
async def zillion_sync_task(ctx: ZillionContext) -> None:
logger.info("started zillion sync task")
# to work around the Python bug where we can't check for RetroArch
if not ctx.look_for_retroarch.is_set():
logger.info("Start Zillion in RetroArch, then use the /sms command to connect to it.")
await asyncio.wait((
asyncio.create_task(ctx.look_for_retroarch.wait()),
asyncio.create_task(ctx.exit_event.wait())
), return_when=asyncio.FIRST_COMPLETED)
last_log = ""
def log_no_spam(msg: str) -> None:
nonlocal last_log
if msg != last_log:
last_log = msg
logger.info(msg)
# to only show this message once per client run
help_message_shown = False
with Memory(ctx.from_game, ctx.to_game) as memory:
while not ctx.exit_event.is_set():
ram = await memory.read()
game_id = memory.get_rom_to_ram_data(ram)
name, seed_end = name_seed_from_ram(game_id)
if len(name):
if name == ctx.known_name:
ctx.auth = name
# this is the name we know
if ctx.server and ctx.server.socket: # type: ignore
if ctx.got_room_info.is_set():
if ctx.seed_name and ctx.seed_name.endswith(seed_end):
# correct seed
if memory.have_generation_info():
log_no_spam("everything connected")
await memory.process_ram(ram)
ctx.process_from_game_queue()
ctx.process_items_received()
else: # no generation info
if ctx.got_slot_data.is_set():
memory.set_generation_info(ctx.rescues, ctx.loc_mem_to_id)
ctx.ap_id_to_name, ctx.ap_id_to_zz_id, _ap_id_to_zz_item = \
make_id_to_others(ctx.start_char)
ctx.next_item = 0
ctx.ap_local_count = len(ctx.checked_locations)
else: # no slot data yet
async_start(ctx.send_connect())
log_no_spam("logging in to server...")
await asyncio.wait((
asyncio.create_task(ctx.got_slot_data.wait()),
asyncio.create_task(ctx.exit_event.wait()),
asyncio.create_task(asyncio.sleep(6))
), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets
else: # not correct seed name
log_no_spam("incorrect seed - did you mix up roms?")
else: # no room info
# If we get here, it looks like `RoomInfo` packet got lost
log_no_spam("waiting for room info from server...")
else: # server not connected
log_no_spam("waiting for server connection...")
else: # new game
log_no_spam("connected to new game")
await ctx.disconnect()
ctx.reset_server_state()
ctx.seed_name = None
ctx.got_room_info.clear()
ctx.reset_game_state()
memory.reset_game_state()
ctx.auth = name
ctx.known_name = name
async_start(ctx.connect())
await asyncio.wait((
asyncio.create_task(ctx.got_room_info.wait()),
asyncio.create_task(ctx.exit_event.wait()),
asyncio.create_task(asyncio.sleep(6))
), return_when=asyncio.FIRST_COMPLETED)
else: # no name found in game
if not help_message_shown:
logger.info('In RetroArch, make sure "Settings > Network > Network Commands" is on.')
help_message_shown = True
log_no_spam("looking for connection to game...")
await asyncio.sleep(0.3)
await asyncio.sleep(0.09375)
logger.info("zillion sync task ending")
async def main() -> None:
parser = get_base_parser()
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apzl Archipelago Binary Patch file')
# SNI parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
args = parser.parse_args()
print(args)
if args.diff_file:
import Patch
logger.info("patch file was supplied - creating sms rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta:
args.connect = meta["server"]
logger.info(f"wrote rom file to {rom_file}")
ctx = ZillionContext(args.connect, args.password)
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
sync_task = asyncio.create_task(zillion_sync_task(ctx))
await ctx.exit_event.wait()
ctx.server_address = None
logger.debug("waiting for sync task to end")
await sync_task
logger.debug("sync task ended")
await ctx.shutdown()
import Utils # noqa: E402
from worlds.zillion.client import launch # noqa: E402
if __name__ == "__main__":
Utils.init_logging("ZillionClient", exception_logger="Client")
colorama.init()
asyncio.run(main())
colorama.deinit()
launch()

View File

@@ -17,6 +17,12 @@
color: "FFFFFF"
<TabbedPanel>:
tab_width: root.width / app.tab_count
<TooltipLabel>:
text_size: self.width, None
size_hint_y: None
height: self.texture_size[1]
font_size: dp(20)
markup: True
<SelectableLabel>:
canvas.before:
Color:
@@ -24,11 +30,6 @@
Rectangle:
size: self.size
pos: self.pos
text_size: self.width, None
size_hint_y: None
height: self.texture_size[1]
font_size: dp(20)
markup: True
<UILog>:
messages: 1000 # amount of messages stored in client logs.
cols: 1
@@ -44,6 +45,70 @@
height: self.minimum_height
orientation: 'vertical'
spacing: dp(3)
<HintLabel>:
canvas.before:
Color:
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1) if self.striped else (0.18, 0.18, 0.18, 1)
Rectangle:
size: self.size
pos: self.pos
height: self.minimum_height
receiving_text: "Receiving Player"
item_text: "Item"
finding_text: "Finding Player"
location_text: "Location"
entrance_text: "Entrance"
found_text: "Found?"
TooltipLabel:
id: receiving
text: root.receiving_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: item
text: root.item_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: finding
text: root.finding_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: location
text: root.location_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: entrance
text: root.entrance_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: found
text: root.found_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
<HintLog>:
cols: 1
viewclass: 'HintLabel'
scroll_y: self.height
scroll_type: ["content", "bars"]
bar_width: dp(12)
effect_cls: "ScrollEffect"
SelectableRecycleBoxLayout:
default_size: None, dp(20)
default_size_hint: 1, None
size_hint_y: None
height: self.minimum_height
orientation: 'vertical'
spacing: dp(3)
<ServerLabel>:
text: "Server:"
size_hint_x: None

View File

@@ -249,6 +249,24 @@ Response:
- `err` (`string`): A description of the problem
]]
local bizhawk_version = client.getversion()
local bizhawk_major, bizhawk_minor, bizhawk_patch = bizhawk_version:match("(%d+)%.(%d+)%.?(%d*)")
bizhawk_major = tonumber(bizhawk_major)
bizhawk_minor = tonumber(bizhawk_minor)
if bizhawk_patch == "" then
bizhawk_patch = 0
else
bizhawk_patch = tonumber(bizhawk_patch)
end
local lua_major, lua_minor = _VERSION:match("Lua (%d+)%.(%d+)")
lua_major = tonumber(lua_major)
lua_minor = tonumber(lua_minor)
if lua_major > 5 or (lua_major == 5 and lua_minor >= 3) then
require("lua_5_3_compat")
end
local base64 = require("base64")
local socket = require("socket")
local json = require("json")
@@ -257,7 +275,9 @@ local json = require("json")
-- Will cause lag due to large console output
local DEBUG = false
local SOCKET_PORT = 43055
local SOCKET_PORT_FIRST = 43055
local SOCKET_PORT_RANGE_SIZE = 5
local SOCKET_PORT_LAST = SOCKET_PORT_FIRST + SOCKET_PORT_RANGE_SIZE
local STATE_NOT_CONNECTED = 0
local STATE_CONNECTED = 1
@@ -277,24 +297,6 @@ local locked = false
local rom_hash = nil
local lua_major, lua_minor = _VERSION:match("Lua (%d+)%.(%d+)")
lua_major = tonumber(lua_major)
lua_minor = tonumber(lua_minor)
if lua_major > 5 or (lua_major == 5 and lua_minor >= 3) then
require("lua_5_3_compat")
end
local bizhawk_version = client.getversion()
local bizhawk_major, bizhawk_minor, bizhawk_patch = bizhawk_version:match("(%d+)%.(%d+)%.?(%d*)")
bizhawk_major = tonumber(bizhawk_major)
bizhawk_minor = tonumber(bizhawk_minor)
if bizhawk_patch == "" then
bizhawk_patch = 0
else
bizhawk_patch = tonumber(bizhawk_patch)
end
function queue_push (self, value)
self[self.right] = value
self.right = self.right + 1
@@ -435,7 +437,7 @@ function send_receive ()
end
if message == "VERSION" then
local result, err client_socket:send(tostring(SCRIPT_VERSION).."\n")
client_socket:send(tostring(SCRIPT_VERSION).."\n")
else
local res = {}
local data = json.decode(message)
@@ -454,6 +456,7 @@ function send_receive ()
failed_guard_response = response
end
else
if type(response) ~= "string" then response = "Unknown error" end
res[i] = {type = "ERROR", err = response}
end
end
@@ -463,14 +466,45 @@ function send_receive ()
end
end
function main ()
server, err = socket.bind("localhost", SOCKET_PORT)
function initialize_server ()
local err
local port = SOCKET_PORT_FIRST
local res = nil
server, err = socket.socket.tcp4()
while res == nil and port <= SOCKET_PORT_LAST do
res, err = server:bind("localhost", port)
if res == nil and err ~= "address already in use" then
print(err)
return
end
if res == nil then
port = port + 1
end
end
if port > SOCKET_PORT_LAST then
print("Too many instances of connector script already running. Exiting.")
return
end
res, err = server:listen(0)
if err ~= nil then
print(err)
return
end
server:settimeout(0)
end
function main ()
while true do
if server == nil then
initialize_server()
end
current_time = socket.socket.gettime()
timeout_timer = timeout_timer - (current_time - prev_time)
message_timer = message_timer - (current_time - prev_time)
@@ -482,16 +516,16 @@ function main ()
end
if current_state == STATE_NOT_CONNECTED then
if emu.framecount() % 60 == 0 then
server:settimeout(2)
if emu.framecount() % 30 == 0 then
print("Looking for client...")
local client, timeout = server:accept()
if timeout == nil then
print("Client connected")
current_state = STATE_CONNECTED
client_socket = client
server:close()
server = nil
client_socket:settimeout(0)
else
print("No client found. Trying again...")
end
end
else
@@ -527,37 +561,37 @@ else
emu.frameadvance()
end
end
rom_hash = gameinfo.getromhash()
print("Waiting for client to connect. Emulation will freeze intermittently until a client is found.\n")
print("Waiting for client to connect. This may take longer the more instances of this script you have open at once.\n")
local co = coroutine.create(main)
function tick ()
local status, err = coroutine.resume(co)
if not status then
if not status and err ~= "cannot resume dead coroutine" then
print("\nERROR: "..err)
print("Consider reporting this crash.\n")
if server ~= nil then
server:close()
end
co = coroutine.create(main)
end
end
-- Gambatte has a setting which can cause script execution to become
-- misaligned, so for GB and GBC we explicitly set the callback on
-- vblank instead.
-- https://github.com/TASEmulators/BizHawk/issues/3711
if emu.getsystemid() == "GB" or emu.getsystemid() == "GBC" then
if emu.getsystemid() == "GB" or emu.getsystemid() == "GBC" or emu.getsystemid() == "SGB" then
event.onmemoryexecute(tick, 0x40, "tick", "System Bus")
else
event.onframeend(tick)
end
while true do
emu.frameadvance()
end

View File

@@ -1,224 +0,0 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
require("common")
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local SCRIPT_VERSION = 3
local APIndex = 0x1A6E
local APDeathLinkAddress = 0x00FD
local APItemAddress = 0x00FF
local EventFlagAddress = 0x1735
local MissableAddress = 0x161A
local HiddenItemsAddress = 0x16DE
local RodAddress = 0x1716
local DexSanityAddress = 0x1A71
local InGameAddress = 0x1A84
local ClientCompatibilityAddress = 0xFF00
local ItemsReceived = nil
local playerName = nil
local seedName = nil
local deathlink_rec = nil
local deathlink_send = false
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local gbSocket = nil
local frame = 0
local compat = nil
local function defineMemoryFunctions()
local memDomain = {}
local domains = memory.getmemorydomainlist()
memDomain["rom"] = function() memory.usememorydomain("ROM") end
memDomain["wram"] = function() memory.usememorydomain("WRAM") end
return memDomain
end
local memDomain = defineMemoryFunctions()
u8 = memory.read_u8
wU8 = memory.write_u8
u16 = memory.read_u16_le
function uRange(address, bytes)
data = memory.readbyterange(address - 1, bytes + 1)
data[0] = nil
return data
end
function generateLocationsChecked()
memDomain.wram()
events = uRange(EventFlagAddress, 0x140)
missables = uRange(MissableAddress, 0x20)
hiddenitems = uRange(HiddenItemsAddress, 0x0E)
rod = {u8(RodAddress)}
dexsanity = uRange(DexSanityAddress, 19)
data = {}
categories = {events, missables, hiddenitems, rod}
if compat > 1 then
table.insert(categories, dexsanity)
end
for _, category in ipairs(categories) do
for _, v in ipairs(category) do
table.insert(data, v)
end
end
return data
end
local function arrayEqual(a1, a2)
if #a1 ~= #a2 then
return false
end
for i, v in ipairs(a1) do
if v ~= a2[i] then
return false
end
end
return true
end
function receive()
l, e = gbSocket:receive()
if e == 'closed' then
if curstate == STATE_OK then
print("Connection closed")
end
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
if l ~= nil then
block = json.decode(l)
if block ~= nil then
local itemsBlock = block["items"]
if itemsBlock ~= nil then
ItemsReceived = itemsBlock
end
deathlink_rec = block["deathlink"]
end
end
-- Determine Message to send back
memDomain.rom()
newPlayerName = uRange(0xFFF0, 0x10)
newSeedName = uRange(0xFFDB, 21)
if (playerName ~= nil and not arrayEqual(playerName, newPlayerName)) or (seedName ~= nil and not arrayEqual(seedName, newSeedName)) then
print("ROM changed, quitting")
curstate = STATE_UNINITIALIZED
return
end
playerName = newPlayerName
seedName = newSeedName
local retTable = {}
retTable["scriptVersion"] = SCRIPT_VERSION
if compat == nil then
compat = u8(ClientCompatibilityAddress)
if compat < 2 then
InGameAddress = 0x1A71
end
end
retTable["clientCompatibilityVersion"] = compat
retTable["playerName"] = playerName
retTable["seedName"] = seedName
memDomain.wram()
in_game = u8(InGameAddress)
if in_game == 0x2A or in_game == 0xAC then
retTable["locations"] = generateLocationsChecked()
elseif in_game ~= 0 then
print("Game may have crashed")
curstate = STATE_UNINITIALIZED
return
end
retTable["deathLink"] = deathlink_send
deathlink_send = false
msg = json.encode(retTable).."\n"
local ret, error = gbSocket:send(msg)
if ret == nil then
print(error)
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
curstate = STATE_TENTATIVELY_CONNECTED
elseif curstate == STATE_TENTATIVELY_CONNECTED then
print("Connected!")
curstate = STATE_OK
end
end
function main()
if not checkBizHawkVersion() then
return
end
server, error = socket.bind('localhost', 17242)
while true do
frame = frame + 1
if not (curstate == prevstate) then
print("Current state: "..curstate)
prevstate = curstate
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 5 == 0) then
receive()
in_game = u8(InGameAddress)
if in_game == 0x2A or in_game == 0xAC then
if u8(APItemAddress) == 0x00 then
ItemIndex = u16(APIndex)
if deathlink_rec == true then
wU8(APDeathLinkAddress, 1)
elseif u8(APDeathLinkAddress) == 3 then
wU8(APDeathLinkAddress, 0)
deathlink_send = true
end
if ItemsReceived[ItemIndex + 1] ~= nil then
item_id = ItemsReceived[ItemIndex + 1] - 172000000
if item_id > 255 then
item_id = item_id - 256
end
wU8(APItemAddress, item_id)
end
end
end
end
elseif (curstate == STATE_UNINITIALIZED) then
if (frame % 60 == 0) then
print("Waiting for client.")
emu.frameadvance()
server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
curstate = STATE_INITIAL_CONNECTION_MADE
gbSocket = client
gbSocket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

View File

@@ -46,12 +46,21 @@
# DOOM 1993
/worlds/doom_1993/ @Daivuk
# DOOM II
/worlds/doom_ii/ @Daivuk
# Factorio
/worlds/factorio/ @Berserker66
# Final Fantasy
/worlds/ff1/ @jtoyoda
# Final Fantasy Mystic Quest
/worlds/ffmq/ @Alchav @wildham0
# Heretic
/worlds/heretic/ @Daivuk
# Hollow Knight
/worlds/hk/ @BadMagic100 @ThePhar
@@ -61,6 +70,12 @@
# Kingdom Hearts 2
/worlds/kh2/ @JaredWeakStrike
# Landstalker: The Treasures of King Nole
/worlds/landstalker/ @Dinopony
# Lingo
/worlds/lingo/ @hatkirby
# Links Awakening DX
/worlds/ladx/ @zig-for
@@ -92,6 +107,9 @@
# Overcooked! 2
/worlds/overcooked2/ @toasterparty
# Pokemon Emerald
/worlds/pokemon_emerald/ @Zunawe
# Pokemon Red and Blue
/worlds/pokemon_rb/ @Alchav
@@ -104,6 +122,9 @@
# Risk of Rain 2
/worlds/ror2/ @kindasneaki
# Shivers
/worlds/shivers/ @GodlFire
# Sonic Adventure 2 Battle
/worlds/sa2b/ @PoryGone @RaspberrySpace
@@ -143,6 +164,9 @@
# The Legend of Zelda (1)
/worlds/tloz/ @Rosalie-A @t3hf1gm3nt
# TUNIC
/worlds/tunic/ @silent-destroyer
# Undertale
/worlds/undertale/ @jonloveslegos

View File

@@ -1,214 +1,206 @@
# How do I add a game to Archipelago?
# How do I add a game to Archipelago?
This guide is going to try and be a broad summary of how you can do just that.
There are two key steps to incorporating a game into Archipelago:
- Game Modification
There are two key steps to incorporating a game into Archipelago:
- Game Modification
- Archipelago Server Integration
Refer to the following documents as well:
- [network protocol.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/network%20protocol.md) for network communication between client and server.
- [world api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md) for documentation on server side code and creating a world package.
- [network protocol.md](/docs/network%20protocol.md) for network communication between client and server.
- [world api.md](/docs/world%20api.md) for documentation on server side code and creating a world package.
# Game Modification
One half of the work required to integrate a game into Archipelago is the development of the game client. This is
# Game Modification
One half of the work required to integrate a game into Archipelago is the development of the game client. This is
typically done through a modding API or other modification process, described further down.
As an example, modifications to a game typically include (more on this later):
- Hooking into when a 'location check' is completed.
- Networking with the Archipelago server.
- Optionally, UI or HUD updates to show status of the multiworld session or Archipelago server connection.
In order to determine how to modify a game, refer to the following sections.
## Engine Identification
This is a good way to make the modding process much easier. Being able to identify what engine a game was made in is critical. The first step is to look at a game's files. Let's go over what some game files might look like. Its important that you be able to see file extensions, so be sure to enable that feature in your file viewer of choice.
## Engine Identification
This is a good way to make the modding process much easier. Being able to identify what engine a game was made in is
critical. The first step is to look at a game's files. Let's go over what some game files might look like. Its
important that you be able to see file extensions, so be sure to enable that feature in your file viewer of choice.
Examples are provided below.
### Creepy Castle
![Creepy Castle Root Directory in Window's Explorer](./img/creepy-castle-directory.png)
![Creepy Castle Root Directory in Windows Explorer](/docs/img/creepy-castle-directory.png)
This is the delightful title Creepy Castle, which is a fantastic game that I highly recommend. Its also your worst-case
scenario as a modder. All thats present here is an executable file and some meta-information that Steam uses. You have
basically nothing here to work with. If you want to change this game, the only option you have is to do some pretty nasty
disassembly and reverse engineering work, which is outside the scope of this tutorial. Lets look at some other examples
of game releases.
scenario as a modder. All thats present here is an executable file and some meta-information that Steam uses. You have
basically nothing here to work with. If you want to change this game, the only option you have is to do some pretty
nasty disassembly and reverse engineering work, which is outside the scope of this tutorial. Lets look at some other
examples of game releases.
### Heavy Bullets
![Heavy Bullets Root Directory in Window's Explorer](./img/heavy-bullets-directory.png)
Heres the release files for another game, Heavy Bullets. We see a .exe file, like expected, and a few more files.
“hello.txt” is a text file, which we can quickly skim in any text editor. Many games have them in some form, usually
with a name like README.txt, and they may contain information about a game, such as a EULA, terms of service, licensing
information, credits, and general info about the game. You usually wont find anything too helpful here, but it never
hurts to check. In this case, it contains some credits and a changelog for the game, so nothing too important.
“steam_api.dll” is a file you can safely ignore, its just some code used to interface with Steam.
The directory “HEAVY_BULLETS_Data”, however, has some good news.
![Heavy Bullets Data Directory in Window's Explorer](./img/heavy-bullets-data-directory.png)
Jackpot! It might not be obvious what youre looking at here, but I can instantly tell from this folders contents that
what we have is a game made in the Unity Engine. If you look in the sub-folders, youll seem some .dll files which affirm
our suspicions. Telltale signs for this are directories titled “Managed” and “Mono”, as well as the numbered, extension-less
level files and the sharedassets files. Well tell you a bit about why seeing a Unity game is such good news later,
but for now, this is what one looks like. Also keep your eyes out for an executable with a name like UnityCrashHandler,
thats another dead giveaway.
![Heavy Bullets Root Directory in Window's Explorer](/docs/img/heavy-bullets-directory.png)
Heres the release files for another game, Heavy Bullets. We see a .exe file, like expected, and a few more files.
“hello.txt” is a text file, which we can quickly skim in any text editor. Many games have them in some form, usually
with a name like README.txt, and they may contain information about a game, such as a EULA, terms of service, licensing
information, credits, and general info about the game. You usually wont find anything too helpful here, but it never
hurts to check. In this case, it contains some credits and a changelog for the game, so nothing too important.
“steam_api.dll” is a file you can safely ignore, its just some code used to interface with Steam.
The directory “HEAVY_BULLETS_Data”, however, has some good news.
![Heavy Bullets Data Directory in Window's Explorer](/docs/img/heavy-bullets-data-directory.png)
Jackpot! It might not be obvious what youre looking at here, but I can instantly tell from this folders contents that
what we have is a game made in the Unity Engine. If you look in the sub-folders, youll seem some .dll files which
affirm our suspicions. Telltale signs for this are directories titled “Managed” and “Mono”, as well as the numbered,
extension-less level files and the sharedassets files. If you've identified the game as a Unity game, some useful tools
and information to help you on your journey can be found at this
[Unity Game Hacking guide.](https://github.com/imadr/Unity-game-hacking)
### Stardew Valley
![Stardew Valley Root Directory in Window's Explorer](./img/stardew-valley-directory.png)
This is the game contents of Stardew Valley. A lot more to look at here, but some key takeaways.
Notice the .dll files which include “CSharp” in their name. This tells us that the game was made in C#, which is good news.
More on that later.
![Stardew Valley Root Directory in Window's Explorer](/docs/img/stardew-valley-directory.png)
This is the game contents of Stardew Valley. A lot more to look at here, but some key takeaways.
Notice the .dll files which include “CSharp” in their name. This tells us that the game was made in C#, which is good
news. Many games made in C# can be modified using the same tools found in our Unity game hacking toolset; namely BepInEx
and MonoMod.
### Gato Roboto
![Gato Roboto Root Directory in Window's Explorer](./img/gato-roboto-directory.png)
Our last example is the game Gato Roboto. This game is made in GameMaker, which is another green flag to look out for.
The giveaway is the file titled "data.win". This immediately tips us off that this game was made in GameMaker.
This isn't all you'll ever see looking at game files, but it's a good place to start.
As a general rule, the more files a game has out in plain sight, the more you'll be able to change.
This especially applies in the case of code or script files - always keep a lookout for anything you can use to your
advantage!
![Gato Roboto Root Directory in Window's Explorer](/docs/img/gato-roboto-directory.png)
Our last example is the game Gato Roboto. This game is made in GameMaker, which is another green flag to look out for.
The giveaway is the file titled "data.win". This immediately tips us off that this game was made in GameMaker. For
modifying GameMaker games the [Undertale Mod Tool](https://github.com/krzys-h/UndertaleModTool) is incredibly helpful.
This isn't all you'll ever see looking at game files, but it's a good place to start.
As a general rule, the more files a game has out in plain sight, the more you'll be able to change.
This especially applies in the case of code or script files - always keep a lookout for anything you can use to your
advantage!
## Open or Leaked Source Games
As a side note, many games have either been made open source, or have had source files leaked at some point.
This can be a boon to any would-be modder, for obvious reasons.
Always be sure to check - a quick internet search for "(Game) Source Code" might not give results often, but when it
does you're going to have a much better time.
As a side note, many games have either been made open source, or have had source files leaked at some point.
This can be a boon to any would-be modder, for obvious reasons. Always be sure to check - a quick internet search for
"(Game) Source Code" might not give results often, but when it does, you're going to have a much better time.
Be sure never to distribute source code for games that you decompile or find if you do not have express permission to do
so, or to redistribute any materials obtained through similar methods, as this is illegal and unethical.
## Modifying Release Versions of Games
However, for now we'll assume you haven't been so lucky, and have to work with only whats sitting in your install directory.
Some developers are kind enough to deliberately leave you ways to alter their games, like modding tools,
but these are often not geared to the kind of work you'll be doing and may not help much.
As a general rule, any modding tool that lets you write actual code is something worth using.
## Modifying Release Versions of Games
However, for now we'll assume you haven't been so lucky, and have to work with only whats sitting in your install
directory. Some developers are kind enough to deliberately leave you ways to alter their games, like modding tools,
but these are often not geared to the kind of work you'll be doing and may not help much.
As a general rule, any modding tool that lets you write actual code is something worth using.
### Research
The first step is to research your game. Even if you've been dealt the worst hand in terms of engine modification,
it's possible other motivated parties have concocted useful tools for your game already.
Always be sure to search the Internet for the efforts of other modders.
### Analysis Tools
Depending on the games underlying engine, there may be some tools you can use either in lieu of or in addition to existing game tools.
#### [dnSpy](https://github.com/dnSpy/dnSpy/releases)
The first tool in your toolbox is dnSpy.
dnSpy is useful for opening and modifying code files, like .exe and .dll files, that were made in C#.
This won't work for executable files made by other means, and obfuscated code (code which was deliberately made
difficult to reverse engineer) will thwart it, but 9 times out of 10 this is exactly what you need.
You'll want to avoid opening common library files in dnSpy, as these are unlikely to contain the data you're looking to
modify.
For Unity games, the file youll want to open will be the file (Data Folder)/Managed/Assembly-CSharp.dll, as pictured below:
![Heavy Bullets Managed Directory in Window's Explorer](./img/heavy-bullets-managed-directory.png)
This file will contain the data of the actual game.
For other C# games, the file you want is usually just the executable itself.
With dnSpy, you can view the games C# code, but the tool isnt perfect.
Although the names of classes, methods, variables, and more will be preserved, code structures may not remain entirely intact. This is because compilers will often subtly rewrite code to be more optimal, so that it works the same as the original code but uses fewer resources. Compiled C# files also lose comments and other documentation.
#### [UndertaleModTool](https://github.com/krzys-h/UndertaleModTool/releases)
This is currently the best tool for modifying games made in GameMaker, and supports games made in both GMS 1 and 2.
It allows you to modify code in GML, if the game wasn't made with the wrong compiler (usually something you don't have
to worry about).
The first step is to research your game. Even if you've been dealt the worst hand in terms of engine modification,
it's possible other motivated parties have concocted useful tools for your game already.
Always be sure to search the Internet for the efforts of other modders.
You'll want to open the data.win file, as this is where all the goods are kept.
Like dnSpy, you wont be able to see comments.
In addition, you will be able to see and modify many hidden fields on items that GameMaker itself will often hide from
creators.
### Other helpful tools
Fonts in particular are notoriously complex, and to add new sprites you may need to modify existing sprite sheets.
#### [CheatEngine](https://cheatengine.org/)
CheatEngine is a tool with a very long and storied history.
Be warned that because it performs live modifications to the memory of other processes, it will likely be flagged as
malware (because this behavior is most commonly found in malware and rarely used by other programs).
If you use CheatEngine, you need to have a deep understanding of how computers work at the nuts and bolts level,
including binary data formats, addressing, and assembly language programming.
Depending on the games underlying engine, there may be some tools you can use either in lieu of or in addition to
existing game tools.
The tool itself is highly complex and even I have not yet charted its expanses.
#### [CheatEngine](https://cheatengine.org/)
CheatEngine is a tool with a very long and storied history.
Be warned that because it performs live modifications to the memory of other processes, it will likely be flagged as
malware (because this behavior is most commonly found in malware and rarely used by other programs).
If you use CheatEngine, you need to have a deep understanding of how computers work at the nuts and bolts level,
including binary data formats, addressing, and assembly language programming.
The tool itself is highly complex and even I have not yet charted its expanses.
However, it can also be a very powerful tool in the right hands, allowing you to query and modify gamestate without ever
modifying the actual game itself.
In theory it is compatible with any piece of software you can run on your computer, but there is no "easy way" to do
anything with it.
### What Modifications You Should Make to the Game
We talked about this briefly in [Game Modification](#game-modification) section.
The next step is to know what you need to make the game do now that you can modify it. Here are your key goals:
- Modify the game so that checks are shuffled
- Know when the player has completed a check, and react accordingly
- Listen for messages from the Archipelago server
- Modify the game to display messages from the Archipelago server
- Add interface for connecting to the Archipelago server with passwords and sessions
- Add commands for manually rewarding, re-syncing, releasing, and other actions
To elaborate, you need to be able to inform the server whenever you check locations, print out messages that you receive
from the server in-game so players can read them, award items when the server tells you to, sync and re-sync when necessary,
avoid double-awarding items while still maintaining game file integrity, and allow players to manually enter commands in
case the client or server make mistakes.
modifying the actual game itself.
In theory it is compatible with any piece of software you can run on your computer, but there is no "easy way" to do
anything with it.
### What Modifications You Should Make to the Game
We talked about this briefly in [Game Modification](#game-modification) section.
The next step is to know what you need to make the game do now that you can modify it. Here are your key goals:
- Know when the player has checked a location, and react accordingly
- Be able to receive items from the server on the fly
- Keep an index for items received in order to resync from disconnections
- Add interface for connecting to the Archipelago server with passwords and sessions
- Add commands for manually rewarding, re-syncing, releasing, and other actions
Refer to the [Network Protocol documentation](/docs/network%20protocol.md) for how to communicate with Archipelago's
servers.
## But my Game is a console game. Can I still add it?
That depends what console?
### My Game is a recent game for the PS4/Xbox-One/Nintendo Switch/etc
Refer to the [Network Protocol documentation](./network%20protocol.md) for how to communicate with Archipelago's servers.
## But my Game is a console game. Can I still add it?
That depends what console?
### My Game is a recent game for the PS4/Xbox-One/Nintendo Switch/etc
Most games for recent generations of console platforms are inaccessible to the typical modder. It is generally advised
that you do not attempt to work with these games as they are difficult to modify and are protected by their copyright
holders. Most modern AAA game studios will provide a modding interface or otherwise deny modifications for their console games.
### My Game isnt that old, its for the Wii/PS2/360/etc
This is very complex, but doable.
If you don't have good knowledge of stuff like Assembly programming, this is not where you want to learn it.
holders. Most modern AAA game studios will provide a modding interface or otherwise deny modifications for their console
games.
### My Game isnt that old, its for the Wii/PS2/360/etc
This is very complex, but doable.
If you don't have good knowledge of stuff like Assembly programming, this is not where you want to learn it.
There exist many disassembly and debugging tools, but more recent content may have lackluster support.
### My Game is a classic for the SNES/Sega Genesis/etc
Thats a lot more feasible.
There are many good tools available for understanding and modifying games on these older consoles, and the emulation
community will have figured out the bulk of the consoles secrets.
Look for debugging tools, but be ready to learn assembly.
Old consoles usually have their own unique dialects of ASM youll need to get used to.
### My Game is a classic for the SNES/Sega Genesis/etc
Thats a lot more feasible.
There are many good tools available for understanding and modifying games on these older consoles, and the emulation
community will have figured out the bulk of the consoles secrets.
Look for debugging tools, but be ready to learn assembly.
Old consoles usually have their own unique dialects of ASM youll need to get used to.
Also make sure theres a good way to interface with a running emulator, since thats the only way you can connect these
older consoles to the Internet.
There are also hardware mods and flash carts, which can do the same things an emulator would when connected to a computer,
but these will require the same sort of interface software to be written in order to work properly - from your perspective
the two won't really look any different.
### My Game is an exclusive for the Super Baby Magic Dream Boy. Its this console from the Soviet Union that-
Unless you have a circuit schematic for the Super Baby Magic Dream Boy sitting on your desk, no.
There are also hardware mods and flash carts, which can do the same things an emulator would when connected to a
computer, but these will require the same sort of interface software to be written in order to work properly; from your
perspective the two won't really look any different.
### My Game is an exclusive for the Super Baby Magic Dream Boy. Its this console from the Soviet Union that-
Unless you have a circuit schematic for the Super Baby Magic Dream Boy sitting on your desk, no.
Obscurity is your enemy there will likely be little to no emulator or modding information, and youd essentially be
working from scratch.
working from scratch.
## How to Distribute Game Modifications
**NEVER EVER distribute anyone else's copyrighted work UNLESS THEY EXPLICITLY GIVE YOU PERMISSION TO DO SO!!!**
This is a good way to get any project you're working on sued out from under you.
The right way to distribute modified versions of a game's binaries, assuming that the licensing terms do not allow you
to copy them wholesale, is as patches.
to copy them wholesale, is as patches.
There are many patch formats, which I'll cover in brief. The common theme is that you cant distribute anything that
wasn't made by you. Patches are files that describe how your modified file differs from the original one, thus avoiding
the issue of distributing someone elses original work.
Users who have a copy of the game just need to apply the patch, and those who dont are unable to play.
Users who have a copy of the game just need to apply the patch, and those who dont are unable to play.
### Patches
#### IPS
IPS patches are a simple list of chunks to replace in the original to generate the output. It is not possible to encode
moving of a chunk, so they may inadvertently contain copyrighted material and should be avoided unless you know it's
fine.
#### UPS, BPS, VCDIFF (xdelta), bsdiff
Other patch formats generate the difference between two streams (delta patches) with varying complexity. This way it is
possible to insert bytes or move chunks without including any original data. Bsdiff is highly optimized and includes
compression, so this format is used by APBP.
@@ -217,6 +209,7 @@ Only a bsdiff module is integrated into AP. If the final patch requires or is ba
bsdiff or APBP before adding it to the AP source code as "basepatch.bsdiff4" or "basepatch.apbp".
#### APBP Archipelago Binary Patch
Starting with version 4 of the APBP format, this is a ZIP file containing metadata in `archipelago.json` and additional
files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the
bsdiff between the original and the randomized ROM.
@@ -224,121 +217,53 @@ bsdiff between the original and the randomized ROM.
To make using APBP easy, they can be generated by inheriting from `worlds.Files.APDeltaPatch`.
### Mod files
Games which support modding will usually just let you drag and drop the mods files into a folder somewhere.
Mod files come in many forms, but the rules about not distributing other people's content remain the same.
They can either be generic and modify the game using a seed or `slot_data` from the AP websocket, or they can be
generated per seed.
generated per seed. If at all possible, it's generally best practice to collect your world information from `slot_data`
so that the users don't have to move files around in order to play.
If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy
integration into the Webhost by inheriting from `worlds.Files.APContainer`.
## Archipelago Integration
Integrating a randomizer into Archipelago involves a few steps.
There are several things that may need to be done, but the most important is to create an implementation of the
`World` class specific to your game. This implementation should exist as a Python module within the `worlds` folder
in the Archipelago file structure.
This encompasses most of the data for your game the items available, what checks you have, the logic for reaching those
checks, what options to offer for the players yaml file, and the code to initialize all this data.
In order for your game to communicate with the Archipelago server and generate the necessary randomized information,
you must create a world package in the main Archipelago repo. This section will cover the requisites and expectations
and show the basics of a world. More in depth documentation on the available API can be read in
the [world api doc.](/docs/world%20api.md)
For setting up your working environment with Archipelago refer
to [running from source](/docs/running%20from%20source.md) and the [style guide](/docs/style.md).
Heres an example of what your world module can look like:
![Example world module directory open in Window's Explorer](./img/archipelago-world-directory-example.png)
### Requirements
The minimum requirements for a new archipelago world are the package itself (the world folder containing a file named `__init__.py`),
which must define a `World` class object for the game with a game name, create an equal number of items and locations with rules,
a win condition, and at least one `Region` object.
Let's give a quick breakdown of what the contents for these files look like.
This is just one example of an Archipelago world - the way things are done below is not an immutable property of Archipelago.
### Items.py
This file is used to define the items which exist in a given game.
![Example Items.py file open in Notepad++](./img/example-items-py-file.png)
Some important things to note here. The center of our Items.py file is the item_table, which individually lists every
item in the game and associates them with an ItemData.
A world implementation requires a few key things from its implementation
This file is rather skeletal - most of the actual data has been stripped out for simplicity.
Each ItemData gives a numeric ID to associate with the item and a boolean telling us whether the item might allow the
player to do more than they would have been able to before.
Next there's the item_frequencies. This simply tells Archipelago how many times each item appears in the pool.
Items that appear exactly once need not be listed - Archipelago will interpret absence from this dictionary as meaning
that the item appears once.
Lastly, note the `lookup_id_to_name` dictionary, which is typically imported and used in your Archipelago `World`
implementation. This is how Archipelago is told about the items in your world.
### Locations.py
This file lists all locations in the game.
![Example Locations.py file open in Notepad++](./img/example-locations-py-file.png)
First is the achievement_table. It lists each location, the region that it can be found in (more on regions later),
and a numeric ID to associate with each location.
The exclusion table is a series of dictionaries which are used to exclude certain checks from the pool of progression
locations based on user settings, and the events table associates certain specific checks with specific items.
`lookup_id_to_name` is also present for locations, though this is a separate dictionary, to be clear.
### Options.py
This file details options to be searched for in a player's YAML settings file.
![Example Options.py file open in Notepad++](./img/example-options-py-file.png)
There are several types of option Archipelago has support for.
In our case, we have three separate choices a player can toggle, either On or Off.
You can also have players choose between a number of predefined values, or have them provide a numeric value within a
specified range.
### Regions.py
This file contains data which defines the world's topology.
In other words, it details how different regions of the game connect to each other.
![Example Regions.py file open in Notepad++](./img/example-regions-py-file.png)
`terraria_regions` contains a list of tuples.
The first element of the tuple is the name of the region, and the second is a list of connections that lead out of the region.
`mandatory_connections` describe where the connection leads.
Above this data is a function called `link_terraria_structures` which uses our defined regions and connections to create
something more usable for Archipelago, but this has been left out for clarity.
### Rules.py
This is the file that details rules for what players can and cannot logically be required to do, based on items and settings.
![Example Rules.py file open in Notepad++](./img/example-rules-py-file.png)
This is the most complicated part of the job, and is one part of Archipelago that is likely to see some changes in the future.
The first class, called `TerrariaLogic`, is an extension of the `LogicMixin` class.
This is where you would want to define methods for evaluating certain conditions, which would then return a boolean to
indicate whether conditions have been met. Your rule definitions should start with some sort of identifier to delineate it
from other games, as all rules are mixed together due to `LogicMixin`. In our case, `_terraria_rule` would be a better name.
The method below, `set_rules()`, is where you would assign these functions as "rules", using lambdas to associate these
functions or combinations of them (or any other code that evaluates to a boolean, in my case just the placeholder `True`)
to certain tasks, like checking locations or using entrances.
### \_\_init\_\_.py
This is the file that actually extends the `World` class, and is where you expose functionality and data to Archipelago.
![Example \_\_init\_\_.py file open in Notepad++](./img/example-init-py-file.png)
This is the most important file for the implementation, and technically the only one you need, but it's best to keep this
file as short as possible and use other script files to do most of the heavy lifting.
If you've done things well, this will just be where you assign everything you set up in the other files to their associated
fields in the class being extended.
This is also a good place to put game-specific quirky behavior that needs to be managed, as it tends to make things a bit
cluttered if you put these things elsewhere.
The various methods and attributes are documented in `/worlds/AutoWorld.py[World]` and
[world api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/world%20api.md),
though it is also recommended to look at existing implementations to see how all this works first-hand.
Once you get all that, all that remains to do is test the game and publish your work.
Make sure to check out [world maintainer.md](./world%20maintainer.md) before publishing.
- A folder within `worlds` that contains an `__init__.py`
- This is what defines it as a Python package and how it's able to be imported
into Archipelago's generation system. During generation time only code that is
defined within this file will be run. It's suggested to split up your information
into more files to improve readability, but all of that information can be
imported at its base level within your world.
- A `World` subclass where you create your world and define all of its rules
and the following requirements:
- Your items and locations need a `item_name_to_id` and `location_name_to_id`,
respectively, mapping.
- An `option_definitions` mapping of your game options with the format
`{name: Class}`, where `name` uses Python snake_case.
- You must define your world's `create_item` method, because this may be called
by the generator in certain circumstances
- When creating your world you submit items and regions to the Multiworld.
- These are lists of said objects which you can access at
`self.multiworld.itempool` and `self.multiworld.regions`. Best practice for
adding to these lists is with either `append` or `extend`, where `append` is a
single object and `extend` is a list.
- Do not use `=` as this will delete other worlds' items and regions.
- Regions are containers for holding your world's Locations.
- Locations are where players will "check" for items and must exist within
a region. It's also important for your world's submitted items to be the same as
its submitted locations count.
- You must always have a "Menu" Region from which the generation algorithm
uses to enter the game and access locations.
- Make sure to check out [world maintainer.md](/docs/world%20maintainer.md) before publishing.

View File

@@ -29,6 +29,7 @@ The zip can contain arbitrary files in addition what was specified above.
## Caveats
Imports from other files inside the apworld have to use relative imports.
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. Options.py and worlds/AutoWorld.py.
Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or
`from worlds.AutoWorld import World`

View File

@@ -1,14 +1,33 @@
# Contributing
Contributions are welcome. We have a few requests of any new contributors.
Contributions are welcome. We have a few requests for new contributors:
* Ensure that all changes which affect logic are covered by unit tests.
* Do not introduce any unit test failures/regressions.
* Follow styling as designated in our [styling documentation](/docs/style.md).
* **Follow styling guidelines.**
Please take a look at the [code style documentation](/docs/style.md)
to ensure ease of communication and uniformity.
Otherwise, we tend to judge code on a case to case basis.
* **Ensure that critical changes are covered by tests.**
It is strongly recommended that unit tests are used to avoid regression and to ensure everything is still working.
If you wish to contribute by adding a new game, please take a look at the [logic unit test documentation](/docs/tests.md).
If you wish to contribute to the website, please take a look at [these tests](/test/webhost).
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see
[the docs folder](/docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev
channel in our [Discord](https://archipelago.gg/discord).
If you want to merge a new game, please make sure to read the responsibilities as
[world maintainer](/docs/world%20maintainer.md).
* **Do not introduce unit test failures/regressions.**
Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test
your changes. Currently, the oldest supported version is [Python 3.8](https://www.python.org/downloads/release/python-380/).
It is recommended that automated github actions are turned on in your fork to have github run all of the unit tests after pushing.
You can turn them on here:
![Github actions example](./img/github-actions-example.png)
Other than these requests, we tend to judge code on a case by case basis.
For contribution to the website, please refer to the [WebHost README](/WebHostLib/README.md).
If you want to contribute to the core, you will be subject to stricter review on your pull requests. It is recommended
that you get in touch with other core maintainers via the [Discord](https://archipelago.gg/discord).
If you want to add Archipelago support for a new game, please take a look at the [adding games documentation](/docs/adding%20games.md), which details what is required
to implement support for a game, as well as tips for how to get started.
If you want to merge a new game into the main Archipelago repo, please make sure to read the responsibilities as a
[world maintainer](/docs/world%20maintainer.md).
For other questions, feel free to explore the [main documentation folder](/docs/) and ask us questions in the #archipelago-dev channel
of the [Discord](https://archipelago.gg/discord).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -380,11 +380,13 @@ Additional arguments sent in this package will also be added to the [Retrieved](
Some special keys exist with specific return data, all of them have the prefix `_read_`, so `hints_{team}_{slot}` is `_read_hints_{team}_{slot}`.
| Name | Type | Notes |
|-------------------------------|--------------------------|---------------------------------------------------|
| hints_{team}_{slot} | list\[[Hint](#Hint)\] | All Hints belonging to the requested Player. |
| slot_data_{slot} | dict\[str, any\] | slot_data belonging to the requested slot. |
| item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. |
| Name | Type | Notes |
|----------------------------------|-------------------------------|-------------------------------------------------------|
| hints_{team}_{slot} | list\[[Hint](#Hint)\] | All Hints belonging to the requested Player. |
| slot_data_{slot} | dict\[str, any\] | slot_data belonging to the requested slot. |
| item_name_groups_{game_name} | dict\[str, list\[str\]\] | item_name_groups belonging to the requested game. |
| location_name_groups_{game_name} | dict\[str, list\[str\]\] | location_name_groups belonging to the requested game. |
| client_status_{team}_{slot} | [ClientStatus](#ClientStatus) | The current game status of the requested player. |
### Set
Used to write data to the server's data storage, that data can then be shared across worlds or just saved for later. Values for keys in the data storage can be retrieved with a [Get](#Get) package, or monitored with a [SetNotify](#SetNotify) package.
@@ -415,6 +417,8 @@ The following operations can be applied to a datastorage key
| mul | Multiplies the current value of the key by `value`. |
| pow | Multiplies the current value of the key to the power of `value`. |
| mod | Sets the current value of the key to the remainder after division by `value`. |
| floor | Floors the current value (`value` is ignored). |
| ceil | Ceils the current value (`value` is ignored). |
| max | Sets the current value of the key to `value` if `value` is bigger. |
| min | Sets the current value of the key to `value` if `value` is lower. |
| and | Applies a bitwise AND to the current value of the key with `value`. |
@@ -556,7 +560,7 @@ Color options:
`player` marks owning player id for location/item,
`flags` contains the [NetworkItem](#NetworkItem) flags that belong to the item
### Client States
### ClientStatus
An enumeration containing the possible client states that may be used to inform
the server in [StatusUpdate](#StatusUpdate). The MultiServer automatically sets
the client state to `ClientStatus.CLIENT_CONNECTED` on the first active connection
@@ -671,8 +675,8 @@ Tags are represented as a list of strings, the common Client tags follow:
### DeathLink
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
| Name | Type | Notes |
| ---- | ---- | ---- |
| time | float | Unix Time Stamp of time of death. |
| cause | str | Optional. Text to explain the cause of death, ex. "Berserker was run over by a train." |
| source | str | Name of the player who first died. Can be a slot name, but can also be a name from within a multiplayer game. |
| Name | Type | Notes |
|--------|-------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| time | float | Unix Time Stamp of time of death. |
| cause | str | Optional. Text to explain the cause of death. When provided, or checked, this should contain the player name, ex. "Berserker was run over by a train." |
| source | str | Name of the player who first died. Can be a slot name, but can also be a name from within a multiplayer game. |

View File

@@ -28,19 +28,23 @@ Choice, and defining `alias_true = option_full`.
and is reserved by AP. You can set this as your default value, but you cannot define your own `option_random`.
As an example, suppose we want an option that lets the user start their game with a sword in their inventory. Let's
create our option class (with a docstring), give it a `display_name`, and add it to a dictionary that keeps track of our
options:
create our option class (with a docstring), give it a `display_name`, and add it to our game's options dataclass:
```python
# Options.py
# options.py
from dataclasses import dataclass
from Options import Toggle, PerGameCommonOptions
class StartingSword(Toggle):
"""Adds a sword to your starting inventory."""
display_name = "Start With Sword"
example_options = {
"starting_sword": StartingSword
}
@dataclass
class ExampleGameOptions(PerGameCommonOptions):
starting_sword: StartingSword
```
This will create a `Toggle` option, internally called `starting_sword`. To then submit this to the multiworld, we add it
@@ -48,29 +52,58 @@ to our world's `__init__.py`:
```python
from worlds.AutoWorld import World
from .Options import options
from .Options import ExampleGameOptions
class ExampleWorld(World):
option_definitions = options
# this gives the generator all the definitions for our options
options_dataclass = ExampleGameOptions
# this gives us typing hints for all the options we defined
options: ExampleGameOptions
```
### Option Checking
Options are parsed by `Generate.py` before the worlds are created, and then the option classes are created shortly after
world instantiation. These are created as attributes on the MultiWorld and can be accessed with
`self.multiworld.my_option_name[self.player]`. This is the option class, which supports direct comparison methods to
`self.options.my_option_name`. This is an instance of the option class, which supports direct comparison methods to
relevant objects (like comparing a Toggle class to a `bool`). If you need to access the option result directly, this is
the option class's `value` attribute. For our example above we can do a simple check:
```python
if self.multiworld.starting_sword[self.player]:
if self.options.starting_sword:
do_some_things()
```
or if I need a boolean object, such as in my slot_data I can access it as:
```python
start_with_sword = bool(self.multiworld.starting_sword[self.player].value)
start_with_sword = bool(self.options.starting_sword.value)
```
All numeric options (i.e. Toggle, Choice, Range) can be compared to integers, strings that match their attributes,
strings that match the option attributes after "option_" is stripped, and the attributes themselves.
```python
# options.py
class Logic(Choice):
option_normal = 0
option_hard = 1
option_challenging = 2
option_extreme = 3
option_insane = 4
alias_extra_hard = 2
crazy = 4 # won't be listed as an option and only exists as an attribute on the class
# __init__.py
from .options import Logic
if self.options.logic:
do_things_for_all_non_normal_logic()
if self.options.logic == 1:
do_hard_things()
elif self.options.logic == "challenging":
do_challenging_things()
elif self.options.logic == Logic.option_extreme:
do_extreme_things()
elif self.options.logic == "crazy":
do_insane_things()
```
## Generic Option Classes
These options are generically available to every game automatically, but can be overridden for slightly different
behavior, if desired. See `worlds/soe/Options.py` for an example.
@@ -120,7 +153,7 @@ Like Toggle, but 1 (true) is the default value.
A numeric option allowing you to define different sub options. Values are stored as integers, but you can also do
comparison methods with the class and strings, so if you have an `option_early_sword`, this can be compared with:
```python
if self.multiworld.sword_availability[self.player] == "early_sword":
if self.options.sword_availability == "early_sword":
do_early_sword_things()
```
@@ -128,7 +161,7 @@ or:
```python
from .Options import SwordAvailability
if self.multiworld.sword_availability[self.player] == SwordAvailability.option_early_sword:
if self.options.sword_availability == SwordAvailability.option_early_sword:
do_early_sword_things()
```
@@ -137,13 +170,20 @@ A numeric option allowing a variety of integers including the endpoints. Has a d
`range_end` of 1. Allows for negative values as well. This will always be an integer and has no methods for string
comparisons.
### SpecialRange
### NamedRange
Like range but also allows you to define a dictionary of special names the user can use to equate to a specific value.
`special_range_names` can be used to
- give descriptive names to certain values from within the range
- add option values above or below the regular range, to be associated with a special meaning
For example:
```python
range_start = 1
range_end = 99
special_range_names: {
"normal": 20,
"extreme": 99,
"unlimited": -1,
}
```
@@ -160,7 +200,7 @@ within the world.
Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any
user defined string as a valid option, so will either need to be validated by adding a validation step to the option
class or within world, if necessary. Value for this class is `Union[str, int]` so if you need the value at a specified
point, `self.multiworld.my_option[self.player].current_key` will always return a string.
point, `self.options.my_option.current_key` will always return a string.
### PlandoBosses
An option specifically built for handling boss rando, if your game can use it. Is a subclass of TextChoice so supports

90
docs/tests.md Normal file
View File

@@ -0,0 +1,90 @@
# Archipelago Unit Testing API
This document covers some of the generic tests available using Archipelago's unit testing system, as well as some basic
steps on how to write your own.
## Generic Tests
Some generic tests are run on every World to ensure basic functionality with default options. These basic tests can be
found in the [general test directory](/test/general).
## Defining World Tests
In order to run tests from your world, you will need to create a `test` package within your world package. This can be
done by creating a `test` directory with a file named `__init__.py` inside it inside your world. By convention, a base
for your world tests can be created in this file that you can then import into other modules.
### WorldTestBase
In order to test basic functionality of varying options, as well as to test specific edge cases or that certain
interactions in the world interact as expected, you will want to use the [WorldTestBase](/test/bases.py). This class
comes with the basics for test setup as well as a few preloaded tests that most worlds might want to check on varying
options combinations.
Example `/worlds/<my_game>/test/__init__.py`:
```python
from test.bases import WorldTestBase
class MyGameTestBase(WorldTestBase):
game = "My Game"
```
The basic tests that WorldTestBase comes with include `test_all_state_can_reach_everything`,
`test_empty_state_can_reach_something`, and `test_fill`. These test that with all collected items everything is
reachable, with no collected items at least something is reachable, and that a valid multiworld can be completed with
all steps being called, respectively.
### Writing Tests
#### Using WorldTestBase
Adding runs for the basic tests for a different option combination is as easy as making a new module in the test
package, creating a class that inherits from your game's TestBase, and defining the options in a dict as a field on the
class. The new module should be named `test_<something>.py` and have at least one class inheriting from the base, or
define its own testing methods. Newly defined test methods should follow standard PEP8 snake_case format and also start
with `test_`.
Example `/worlds/<my_game>/test/test_chest_access.py`:
```python
from . import MyGameTestBase
class TestChestAccess(MyGameTestBase):
options = {
"difficulty": "easy",
"final_boss_hp": 4000,
}
def test_sword_chests(self) -> None:
"""Test locations that require a sword"""
locations = ["Chest1", "Chest2"]
items = [["Sword"]]
# This tests that the provided locations aren't accessible without the provided items, but can be accessed once
# the items are obtained.
# This will also check that any locations not provided don't have the same dependency requirement.
# Optionally, passing only_check_listed=True to the method will only check the locations provided.
self.assertAccessDependency(locations, items)
```
When tests are run, this class will create a multiworld with a single player having the provided options, and run the
generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld
that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be
overridden. For more information on what methods are available to your class, check the
[WorldTestBase definition](/test/bases.py#L104).
#### Alternatives to WorldTestBase
Unit tests can also be created using [TestBase](/test/bases.py#L14) or
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
testing portions of your code that can be tested without relying on a multiworld to be created first.
## Running Tests
In PyCharm, running all tests can be done by right-clicking the root `test` directory and selecting `run Python tests`.
If you do not have pytest installed, you may get import failures. To solve this, edit the run configuration, and set the
working directory of the run to the Archipelago directory. If you only want to run your world's defined tests, repeat
the steps for the test directory within your world.

View File

@@ -0,0 +1,100 @@
# Triage Role Expectations
Users with Triage-level access are selected contributors who can and wish to proactively label/triage issues and pull
requests without being granted write access to the Archipelago repository.
Triage users are not necessarily official members of the Archipelago organization, for the list of core maintainers,
please reference [ArchipelagoMW Members](https://github.com/orgs/ArchipelagoMW/people) page.
## Access Permissions
Triage users have the following permissions:
* Apply/dismiss labels on all issues and pull requests.
* Close, reopen, and assign all issues and pull requests.
* Mark issues and pull requests as duplicate.
* Request pull request reviews from repository members.
* Hide comments in issues or pull requests from public view.
* Hidden comments are not deleted and can be reversed by another triage user or repository member with write access.
* And all other standard permissions granted to regular GitHub users.
For more details on permissions granted by the Triage role, see
[GitHub's Role Documentation](https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/repository-roles-for-an-organization).
## Expectations
Users with triage-level permissions have no expectation to review code, but, if desired, to review pull requests/issues
and apply the relevant labels and ping/request reviews from any relevant [code owners](./CODEOWNERS) for review. Triage
users are also expected not to close others' issues or pull requests without strong reason to do so (with exception of
`meta: invalid` or `meta: duplicate` scenarios, which are listed below). When in doubt, defer to a core maintainer.
Triage users are not "moderators" for others' issues or pull requests. However, they may voice their opinions/feedback
on issues or pull requests, just the same as any other GitHub user contributing to Archipelago.
## Labeling
As of the time of writing this document, there are 15 distinct labels that can be applied to issues and pull requests.
### Affects
These labels notate if certain issues or pull requests affect critical aspects of Archipelago that may require specific
review. More than one of these labels can be used on a issue or pull request, if relevant.
* `affects: core` is to be applied to issues/PRs that may affect core Archipelago functionality and should be reviewed
with additional scrutiny.
* Core is defined as any files not contained in the `WebHostLib` directory or individual world implementations
directories inside the `worlds` directory, not including `worlds/generic`.
* `affects: webhost` is to be applied to issues/PRs that may affect the core WebHost portion of Archipelago. In
general, this is anything being modified inside the `WebHostLib` directory or `WebHost.py` file.
* `affects: release/blocker` is to be applied for any issues/PRs that may either negatively impact (issues) or propose
to resolve critical issues (pull requests) that affect the current or next official release of Archipelago and should be
given top priority for review.
### Is
These labels notate what kinds of changes are being made or proposed in issues or pull requests. More than one of these
labels can be used on a issue or pull request, if relevant, but at least one of these labels should be applied to every
pull request and issue.
* `is: bug/fix` is to be applied to issues/PRs that report or resolve an issue in core, web, or individual world
implementations.
* `is: documentation` is to be applied to issues/PRs that relate to adding, updating, or removing documentation in
core, web, or individual world implementations without modifying actual code.
* `is: enhancement` is to be applied to issues/PRs that relate to adding, modifying, or removing functionality in
core, web, or individual world implementations.
* `is: refactor/cleanup` is to be applied to issues/PRs that relate to reorganizing existing code to improve
readability or performance without adding, modifying, or removing functionality or fixing known regressions.
* `is: maintenance` is to be applied to issues/PRs that don't modify logic, refactor existing code, change features.
This is typically reserved for pull requests that need to update dependencies or increment version numbers without
resolving existing issues.
* `is: new game` is to be applied to any pull requests that introduce a new game for the first time to the `worlds`
directory.
* Issues should not be opened and classified with `is: new game`, and instead should be directed to the
#future-game-design channel in Archipelago for opening suggestions. If they are opened, they should be labeled
with `meta: invalid` and closed.
* Pull requests for new games should only have this label, as enhancement, documentation, bug/fix, refactor, and
possibly maintenance is implied.
### Meta
These labels allow additional quick meta information for contributors or reviewers for issues and pull requests. They
have specific situations where they should be applied.
* `meta: duplicate` is to be applied to any issues/PRs that are duplicate of another issue/PR that was already opened.
* These should be immediately closed after leaving a comment, directing to the original issue or pull request.
* `meta: invalid` is to be applied to any issues/PRs that do not relate to Archipelago or are inappropriate for
discussion on GitHub.
* These should be immediately closed afterwards.
* `meta: help wanted` is to be applied to any issues/PRs that require additional attention for whatever reason.
* These should include a comment describing what kind of help is requested when the label is added.
* Some common reasons include, but are not limited to: Breaking API changes that require developer input/testing or
pull requests with large line changes that need additional reviewers to be reviewed effectively.
* This label may require some programming experience and familiarity with Archipelago source to determine if
requesting additional attention for help is warranted.
* `meta: good first issue` is to be applied to any issues that may be a good starting ground for new contributors to try
and tackle.
* This label may require some programming experience and familiarity with Archipelago source to determine if an
issue is a "good first issue".
* `meta: wontfix` is to be applied for any issues/PRs that are opened that will not be actioned because it's out of
scope or determined to not be an issue.
* This should be reserved for use by a world's code owner(s) on their relevant world or by core maintainers.

View File

@@ -73,6 +73,53 @@ for your world specifically on the webhost:
`game_info_languages` (optional) List of strings for defining the existing gameinfo pages your game supports. The documents must be
prefixed with the same string as defined here. Default already has 'en'.
`options_presets` (optional) A `Dict[str, Dict[str, Any]]` where the keys are the names of the presets and the values
are the options to be set for that preset. The options are defined as a `Dict[str, Any]` where the keys are the names of
the options and the values are the values to be set for that option. These presets will be available for users to select from on the game's options page.
Note: The values must be a non-aliased value for the option type and can only include the following option types:
- If you have a `Range`/`NamedRange` option, the value should be an `int` between the `range_start` and `range_end`
values.
- If you have a `NamedRange` option, the value can alternatively be a `str` that is one of the
`special_range_names` keys.
- If you have a `Choice` option, the value should be a `str` that is one of the `option_<name>` values.
- If you have a `Toggle`/`DefaultOnToggle` option, the value should be a `bool`.
- `random` is also a valid value for any of these option types.
`OptionDict`, `OptionList`, `OptionSet`, `FreeText`, or custom `Option`-derived classes are not supported for presets on the webhost at this time.
Here is an example of a defined preset:
```python
# presets.py
options_presets = {
"Limited Potential": {
"progression_balancing": 0,
"fairy_chests_per_zone": 2,
"starting_class": "random",
"chests_per_zone": 30,
"vendors": "normal",
"architect": "disabled",
"gold_gain_multiplier": "half",
"number_of_children": 2,
"free_diary_on_generation": False,
"health_pool": 10,
"mana_pool": 10,
"attack_pool": 10,
"magic_damage_pool": 10,
"armor_pool": 5,
"equip_pool": 10,
"crit_chance_pool": 5,
"crit_damage_pool": 5,
}
}
# __init__.py
class RLWeb(WebWorld):
options_presets = options_presets
# ...
```
### MultiWorld Object
The `MultiWorld` object references the whole multiworld (all items and locations
@@ -86,9 +133,11 @@ inside a `World` object.
### Player Options
Players provide customized settings for their World in the form of yamls.
Those are accessible through `self.multiworld.<option_name>[self.player]`. A dict
of valid options has to be provided in `self.option_definitions`. Options are automatically
added to the `World` object for easy access.
A `dataclass` of valid options definitions has to be provided in `self.options_dataclass`.
(It must be a subclass of `PerGameCommonOptions`.)
Option results are automatically added to the `World` object for easy access.
Those are accessible through `self.options.<option_name>`, and you can get a dictionary of the option values via
`self.options.as_dict(<option_names>)`, passing the desired options as strings.
### World Settings
@@ -119,6 +168,38 @@ Classification is one of `LocationProgressType.DEFAULT`, `PRIORITY` or `EXCLUDED
The Fill algorithm will force progression items to be placed at priority locations, giving a higher chance of them being
required, and will prevent progression and useful items from being placed at excluded locations.
#### Documenting Locations
Worlds can optionally provide a `location_descriptions` map which contains
human-friendly descriptions of locations or location groups. These descriptions
will show up in location-selection options in the Weighted Options page. Extra
indentation and single newlines will be collapsed into spaces.
```python
# Locations.py
location_descriptions = {
"Red Potion #6": "In a secret destructible block under the second stairway",
"L2 Spaceship": """
The group of all items in the spaceship in Level 2.
This doesn't include the item on the spaceship door, since it can be
accessed without the Spaeship Key.
"""
}
```
```python
# __init__.py
from worlds.AutoWorld import World
from .Locations import location_descriptions
class MyGameWorld(World):
location_descriptions = location_descriptions
```
### Items
Items are all things that can "drop" for your game. This may be RPG items like
@@ -145,6 +226,37 @@ Other classifications include
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
will not be moved around by progression balancing; used, e.g., for currency or tokens
#### Documenting Items
Worlds can optionally provide an `item_descriptions` map which contains
human-friendly descriptions of items or item groups. These descriptions will
show up in item-selection options in the Weighted Options page. Extra
indentation and single newlines will be collapsed into spaces.
```python
# Items.py
item_descriptions = {
"Red Potion": "A standard health potion",
"Spaceship Key": """
The key to the spaceship in Level 2.
This is necessary to get to the Star Realm.
"""
}
```
```python
# __init__.py
from worlds.AutoWorld import World
from .Items import item_descriptions
class MyGameWorld(World):
item_descriptions = item_descriptions
```
### Events
Events will mark some progress. You define an event location, an
@@ -221,11 +333,11 @@ See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requireme
AP will only import the `__init__.py`. Depending on code size it makes sense to
use multiple files and use relative imports to access them.
e.g. `from .Options import mygame_options` from your `__init__.py` will load
`worlds/<world_name>/Options.py` and make its `mygame_options` accessible.
e.g. `from .options import MyGameOptions` from your `__init__.py` will load
`world/[world_name]/options.py` and make its `MyGameOptions` accessible.
When imported names pile up it may be easier to use `from . import Options`
and access the variable as `Options.mygame_options`.
When imported names pile up it may be easier to use `from . import options`
and access the variable as `options.MyGameOptions`.
Imports from directories outside your world should use absolute imports.
Correct use of relative / absolute imports is required for zipped worlds to
@@ -246,7 +358,7 @@ class MyGameItem(Item):
game: str = "My Game"
```
By convention this class definition will either be placed in your `__init__.py`
or your `Items.py`. For a more elaborate example see `worlds/oot/Items.py`.
or your `items.py`. For a more elaborate example see `worlds/oot/Items.py`.
### Your location type
@@ -258,30 +370,31 @@ class MyGameLocation(Location):
game: str = "My Game"
# override constructor to automatically mark event locations as such
def __init__(self, player: int, name = "", code = None, parent = None):
def __init__(self, player: int, name = "", code = None, parent = None) -> None:
super(MyGameLocation, self).__init__(player, name, code, parent)
self.event = code is None
```
in your `__init__.py` or your `Locations.py`.
in your `__init__.py` or your `locations.py`.
### Options
By convention options are defined in `Options.py` and will be used when parsing
By convention options are defined in `options.py` and will be used when parsing
the players' yaml files.
Each option has its own class, inherits from a base option type, has a docstring
to describe it and a `display_name` property for display on the website and in
spoiler logs.
The actual name as used in the yaml is defined in a `Dict[str, AssembleOptions]`, that is
assigned to the world under `self.option_definitions`.
The actual name as used in the yaml is defined via the field names of a `dataclass` that is
assigned to the world under `self.options_dataclass`. By convention, the strings
that define your option names should be in `snake_case`.
Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`.
For more see `Options.py` in AP's base directory.
#### Toggle, DefaultOnToggle
Those don't need any additional properties defined. After parsing the option,
These don't need any additional properties defined. After parsing the option,
its `value` will either be True or False.
#### Range
@@ -307,10 +420,10 @@ default = 0
#### Sample
```python
# Options.py
# options.py
from Options import Toggle, Range, Choice, Option
import typing
from dataclasses import dataclass
from Options import Toggle, Range, Choice, PerGameCommonOptions
class Difficulty(Choice):
"""Sets overall game difficulty."""
@@ -333,23 +446,27 @@ class FixXYZGlitch(Toggle):
"""Fixes ABC when you do XYZ"""
display_name = "Fix XYZ Glitch"
# By convention we call the options dict variable `<world>_options`.
mygame_options: typing.Dict[str, AssembleOptions] = {
"difficulty": Difficulty,
"final_boss_hp": FinalBossHP,
"fix_xyz_glitch": FixXYZGlitch,
}
# By convention, we call the options dataclass `<world>Options`.
# It has to be derived from 'PerGameCommonOptions'.
@dataclass
class MyGameOptions(PerGameCommonOptions):
difficulty: Difficulty
final_boss_hp: FinalBossHP
fix_xyz_glitch: FixXYZGlitch
```
```python
# __init__.py
from worlds.AutoWorld import World
from .Options import mygame_options # import the options dict
from .options import MyGameOptions # import the options dataclass
class MyGameWorld(World):
#...
option_definitions = mygame_options # assign the options dict to the world
#...
# ...
options_dataclass = MyGameOptions # assign the options dataclass to the world
options: MyGameOptions # typing for option results
# ...
```
### A World Class Skeleton
@@ -359,13 +476,14 @@ class MyGameWorld(World):
import settings
import typing
from .Options import mygame_options # the options we defined earlier
from .Items import mygame_items # data used below to add items to the World
from .Locations import mygame_locations # same as above
from .options import MyGameOptions # the options we defined earlier
from .items import mygame_items # data used below to add items to the World
from .locations import mygame_locations # same as above
from worlds.AutoWorld import World
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
class MyGameItem(Item): # or from Items import MyGameItem
game = "My Game" # name of the game/world this item is from
@@ -374,6 +492,7 @@ class MyGameLocation(Location): # or from Locations import MyGameLocation
game = "My Game" # name of the game/world this location is in
class MyGameSettings(settings.Group):
class RomFile(settings.SNESRomPath):
"""Insert help text for host.yaml here."""
@@ -384,7 +503,8 @@ class MyGameSettings(settings.Group):
class MyGameWorld(World):
"""Insert description of the world/game here."""
game = "My Game" # name of the game/world
option_definitions = mygame_options # options the player can set
options_dataclass = MyGameOptions # options the player can set
options: MyGameOptions # typing hints for option results
settings: typing.ClassVar[MyGameSettings] # will be automatically assigned from type hint
topology_present = True # show path to required location checks in spoiler
@@ -417,7 +537,7 @@ The world has to provide the following things for generation
* additions to the regions list: at least one called "Menu"
* locations placed inside those regions
* a `def create_item(self, item: str) -> MyGameItem` to create any item on demand
* applying `self.multiworld.push_precollected` for start inventory
* applying `self.multiworld.push_precollected` for world defined start inventory
* `required_client_version: Tuple[int, int, int]`
Optional client version as tuple of 3 ints to make sure the client is compatible to
this world (e.g. implements all required features) when connecting.
@@ -427,31 +547,32 @@ In addition, the following methods can be implemented and are called in this ord
* `stage_assert_generate(cls, multiworld)` is a class method called at the start of
generation to check the existence of prerequisite files, usually a ROM for
games which require one.
* `def generate_early(self)`
called per player before any items or locations are created. You can set
properties on your world here. Already has access to player options and RNG.
* `def create_regions(self)`
* `generate_early(self)`
called per player before any items or locations are created. You can set properties on your world here. Already has
access to player options and RNG. This is the earliest step where the world should start setting up for the current
multiworld as any steps before this, the multiworld itself is still getting set up
* `create_regions(self)`
called to place player's regions and their locations into the MultiWorld's regions list. If it's
hard to separate, this can be done during `generate_early` or `create_items` as well.
* `def create_items(self)`
* `create_items(self)`
called to place player's items into the MultiWorld's itempool. After this step all regions and items have to be in
the MultiWorld's regions and itempool, and these lists should not be modified afterwards.
* `def set_rules(self)`
* `set_rules(self)`
called to set access and item rules on locations and entrances.
Locations have to be defined before this, or rule application can miss them.
* `def generate_basic(self)`
* `generate_basic(self)`
called after the previous steps. Some placement and player specific
randomizations can be done here.
* `pre_fill`, `fill_hook` and `post_fill` are called to modify item placement
* `pre_fill(self)`, `fill_hook(self)` and `post_fill(self)` are called to modify item placement
before, during and after the regular fill process, before `generate_output`.
If items need to be placed during pre_fill, these items can be determined
and created using `get_prefill_items`
* `def generate_output(self, output_directory: str)` that creates the output
* `generate_output(self, output_directory: str)` that creates the output
files if there is output to be generated. When this is
called, `self.multiworld.get_locations(self.player)` has all locations for the player, with
attribute `item` pointing to the item.
`location.item.player` can be used to see if it's a local item.
* `fill_slot_data` and `modify_multidata` can be used to modify the data that
* `fill_slot_data(self)` and `modify_multidata(self, multidata: Dict[str, Any])` can be used to modify the data that
will be used by the server to host the MultiWorld.
@@ -460,7 +581,7 @@ In addition, the following methods can be implemented and are called in this ord
```python
def generate_early(self) -> None:
# read player settings to world instance
self.final_boss_hp = self.multiworld.final_boss_hp[self.player].value
self.final_boss_hp = self.options.final_boss_hp.value
```
#### create_item
@@ -468,9 +589,9 @@ def generate_early(self) -> None:
```python
# we need a way to know if an item provides progress in the game ("key item")
# this can be part of the items definition, or depend on recipe randomization
from .Items import is_progression # this is just a dummy
from .items import is_progression # this is just a dummy
def create_item(self, item: str):
def create_item(self, item: str) -> MyGameItem:
# This is called when AP wants to create an item by name (for plando) or
# when you call it from your own code.
classification = ItemClassification.progression if is_progression(item) else \
@@ -478,7 +599,7 @@ def create_item(self, item: str):
return MyGameItem(item, classification, self.item_name_to_id[item],
self.player)
def create_event(self, event: str):
def create_event(self, event: str) -> MyGameItem:
# while we are at it, we can also add a helper to create events
return MyGameItem(event, True, None, self.player)
```
@@ -570,8 +691,8 @@ def generate_basic(self) -> None:
### Setting Rules
```python
from worlds.generic.Rules import add_rule, set_rule, forbid_item
from Items import get_item_type
from worlds.generic.Rules import add_rule, set_rule, forbid_item, add_item_rule
from .items import get_item_type
def set_rules(self) -> None:
@@ -597,7 +718,7 @@ def set_rules(self) -> None:
# require one item from an item group
add_rule(self.multiworld.get_location("Chest3", self.player),
lambda state: state.has_group("weapons", self.player))
# state also has .item_count() for items, .has_any() and .has_all() for sets
# state also has .count() for items, .has_any() and .has_all() for multiple
# and .count_group() for groups
# set_rule is likely to be a bit faster than add_rule
@@ -640,12 +761,12 @@ Please do this with caution and only when necessary.
#### Sample
```python
# Logic.py
# logic.py
from worlds.AutoWorld import LogicMixin
class MyGameLogic(LogicMixin):
def mygame_has_key(self, player: int):
def mygame_has_key(self, player: int) -> bool:
# Arguments above are free to choose
# MultiWorld can be accessed through self.multiworld, explicitly passing in
# MyGameWorld instance for easy options access is also a valid approach
@@ -655,11 +776,11 @@ class MyGameLogic(LogicMixin):
# __init__.py
from worlds.generic.Rules import set_rule
import .Logic # apply the mixin by importing its file
import .logic # apply the mixin by importing its file
class MyGameWorld(World):
# ...
def set_rules(self):
def set_rules(self) -> None:
set_rule(self.multiworld.get_location("A Door", self.player),
lambda state: state.mygame_has_key(self.player))
```
@@ -667,10 +788,10 @@ class MyGameWorld(World):
### Generate Output
```python
from .Mod import generate_mod
from .mod import generate_mod
def generate_output(self, output_directory: str):
def generate_output(self, output_directory: str) -> None:
# How to generate the mod or ROM highly depends on the game
# if the mod is written in Lua, Jinja can be used to fill a template
# if the mod reads a json file, `json.dump()` can be used to generate that
@@ -685,12 +806,10 @@ def generate_output(self, output_directory: str):
# make sure to mark as not remote_start_inventory when connecting if stored in rom/mod
"starter_items": [item.name for item
in self.multiworld.precollected_items[self.player]],
"final_boss_hp": self.final_boss_hp,
# store option name "easy", "normal" or "hard" for difficuly
"difficulty": self.multiworld.difficulty[self.player].current_key,
# store option value True or False for fixing a glitch
"fix_xyz_glitch": self.multiworld.fix_xyz_glitch[self.player].value,
}
# add needed option results to the dictionary
data.update(self.options.as_dict("final_boss_hp", "difficulty", "fix_xyz_glitch"))
# point to a ROM specified by the installation
src = self.settings.rom_file
# or point to worlds/mygame/data/mod_template
@@ -702,6 +821,26 @@ def generate_output(self, output_directory: str):
generate_mod(src, out_file, data)
```
### Slot Data
If the game client needs to know information about the generated seed, a preferred method of transferring the data
is through the slot data. This can be filled from the `fill_slot_data` method of your world by returning a `Dict[str, Any]`,
but should be limited to data that is absolutely necessary to not waste resources. Slot data is sent to your client once
it has successfully [connected](network%20protocol.md#connected).
If you need to know information about locations in your world, instead
of propagating the slot data, it is preferable to use [LocationScouts](network%20protocol.md#locationscouts) since that
data already exists on the server. The most common usage of slot data is to send option results that the client needs
to be aware of.
```python
def fill_slot_data(self) -> Dict[str, Any]:
# in order for our game client to handle the generated seed correctly we need to know what the user selected
# for their difficulty and final boss HP
# a dictionary returned from this method gets set as the slot_data and will be sent to the client after connecting
# the options dataclass has a method to return a `Dict[str, Any]` of each option name provided and the option's value
return self.options.as_dict("difficulty", "final_boss_hp")
```
### Documentation
Each world implementation should have a tutorial and a game info page. These are both rendered on the website by reading
@@ -729,8 +868,9 @@ multiworld for each test written using it. Within subsequent modules, classes sh
TestBase, and can then define options to test in the class body, and run tests in each test method.
Example `__init__.py`
```python
from test.TestBase import WorldTestBase
from test.bases import WorldTestBase
class MyGameTestBase(WorldTestBase):
@@ -739,23 +879,25 @@ class MyGameTestBase(WorldTestBase):
Next using the rules defined in the above `set_rules` we can test that the chests have the correct access rules.
Example `testChestAccess.py`
Example `test_chest_access.py`
```python
from . import MyGameTestBase
class TestChestAccess(MyGameTestBase):
def test_sword_chests(self):
def test_sword_chests(self) -> None:
"""Test locations that require a sword"""
locations = ["Chest1", "Chest2"]
items = [["Sword"]]
# this will test that each location can't be accessed without the "Sword", but can be accessed once obtained.
self.assertAccessDependency(locations, items)
def test_any_weapon_chests(self):
def test_any_weapon_chests(self) -> None:
"""Test locations that require any weapon"""
locations = [f"Chest{i}" for i in range(3, 6)]
items = [["Sword"], ["Axe"], ["Spear"]]
# this will test that chests 3-5 can't be accessed without any weapon, but can be with just one of them.
self.assertAccessDependency(locations, items)
```
For more information on tests check the [tests doc](tests.md).

View File

@@ -46,151 +46,33 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{
[Types]
Name: "full"; Description: "Full installation"
Name: "hosting"; Description: "Installation for hosting purposes"
Name: "playing"; Description: "Installation for playing purposes"
Name: "minimal"; Description: "Minimal installation"
Name: "custom"; Description: "Custom installation"; Flags: iscustom
[Components]
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
Name: "generator"; Description: "Generator"; Types: full hosting
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/dkc3"; Description: "Donkey Kong Country 3 ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/smw"; Description: "Super Mario World ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/l2ac"; Description: "Lufia II Ancient Cave ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 2621440; Flags: disablenouninstallwarning
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
Name: "generator/zl"; Description: "Zillion ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 150000; Flags: disablenouninstallwarning
Name: "generator/pkmn_r"; Description: "Pokemon Red ROM Setup"; Types: full hosting
Name: "generator/pkmn_b"; Description: "Pokemon Blue ROM Setup"; Types: full hosting
Name: "generator/mmbn3"; Description: "MegaMan Battle Network 3"; Types: full hosting; ExtraDiskSpaceRequired: 8388608; Flags: disablenouninstallwarning
Name: "generator/ladx"; Description: "Link's Awakening DX ROM Setup"; Types: full hosting
Name: "generator/tloz"; Description: "The Legend of Zelda ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 135168; Flags: disablenouninstallwarning
Name: "server"; Description: "Server"; Types: full hosting
Name: "client"; Description: "Clients"; Types: full playing
Name: "client/sni"; Description: "SNI Client"; Types: full playing
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/dkc3"; Description: "SNI Client - Donkey Kong Country 3 Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/smw"; Description: "SNI Client - Super Mario World Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/l2ac"; Description: "SNI Client - Lufia II Ancient Cave Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/bizhawk"; Description: "BizHawk Client"; Types: full playing
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "client/kh2"; Description: "Kingdom Hearts 2"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
Name: "client/oot"; Description: "Ocarina of Time"; Types: full playing
Name: "client/ff1"; Description: "Final Fantasy 1"; Types: full playing
Name: "client/pkmn"; Description: "Pokemon Client"
Name: "client/pkmn/red"; Description: "Pokemon Client - Pokemon Red Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/pkmn/blue"; Description: "Pokemon Client - Pokemon Blue Setup"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/mmbn3"; Description: "MegaMan Battle Network 3 Client"; Types: full playing;
Name: "client/ladx"; Description: "Link's Awakening Client"; Types: full playing; ExtraDiskSpaceRequired: 1048576
Name: "client/cf"; Description: "ChecksFinder"; Types: full playing
Name: "client/sc2"; Description: "Starcraft 2"; Types: full playing
Name: "client/wargroove"; Description: "Wargroove"; Types: full playing
Name: "client/zl"; Description: "Zillion"; Types: full playing
Name: "client/tloz"; Description: "The Legend of Zelda"; Types: full playing
Name: "client/advn"; Description: "Adventure"; Types: full playing
Name: "client/ut"; Description: "Undertale"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
Name: "core"; Description: "Archipelago"; Types: full minimal custom; Flags: fixed
Name: "lttp_sprites"; Description: "Download ""A Link to the Past"" player sprites"; Types: full;
[Dirs]
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify;
[Files]
Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp
Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm
Source: "{code:GetDKC3ROMPath}"; DestDir: "{app}"; DestName: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"; Flags: external; Components: client/sni/dkc3 or generator/dkc3
Source: "{code:GetSMWROMPath}"; DestDir: "{app}"; DestName: "Super Mario World (USA).sfc"; Flags: external; Components: client/sni/smw or generator/smw
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
Source: "{code:GetL2ACROMPath}"; DestDir: "{app}"; DestName: "Lufia II - Rise of the Sinistrals (USA).sfc"; Flags: external; Components: generator/l2ac
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: client/oot or generator/oot
Source: "{code:GetZlROMPath}"; DestDir: "{app}"; DestName: "Zillion (UE) [!].sms"; Flags: external; Components: client/zl or generator/zl
Source: "{code:GetRedROMPath}"; DestDir: "{app}"; DestName: "Pokemon Red (UE) [S][!].gb"; Flags: external; Components: client/pkmn/red or generator/pkmn_r
Source: "{code:GetBlueROMPath}"; DestDir: "{app}"; DestName: "Pokemon Blue (UE) [S][!].gb"; Flags: external; Components: client/pkmn/blue or generator/pkmn_b
Source: "{code:GetBN3ROMPath}"; DestDir: "{app}"; DestName: "Mega Man Battle Network 3 - Blue Version (USA).gba"; Flags: external; Components: client/mmbn3
Source: "{code:GetLADXROMPath}"; DestDir: "{app}"; DestName: "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc"; Flags: external; Components: client/ladx or generator/ladx
Source: "{code:GetTLoZROMPath}"; DestDir: "{app}"; DestName: "Legend of Zelda, The (U) (PRG0) [!].nes"; Flags: external; Components: client/tloz or generator/tloz
Source: "{code:GetAdvnROMPath}"; DestDir: "{app}"; DestName: "ADVNTURE.BIN"; Flags: external; Components: client/advn
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni
Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp
Source: "{#source_path}\ArchipelagoLauncher.exe"; DestDir: "{app}"; Flags: ignoreversion;
Source: "{#source_path}\ArchipelagoLauncher(DEBUG).exe"; DestDir: "{app}"; Flags: ignoreversion;
Source: "{#source_path}\ArchipelagoGenerate.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: generator
Source: "{#source_path}\ArchipelagoServer.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: server
Source: "{#source_path}\ArchipelagoFactorioClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/factorio
Source: "{#source_path}\ArchipelagoTextClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/text
Source: "{#source_path}\ArchipelagoSNIClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni
Source: "{#source_path}\ArchipelagoBizHawkClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/bizhawk
Source: "{#source_path}\ArchipelagoLinksAwakeningClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ladx
Source: "{#source_path}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni/lttp or generator/lttp
Source: "{#source_path}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
Source: "{#source_path}\ArchipelagoOoTClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
Source: "{#source_path}\ArchipelagoOoTAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/oot
Source: "{#source_path}\ArchipelagoZillionClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/zl
Source: "{#source_path}\ArchipelagoFF1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ff1
Source: "{#source_path}\ArchipelagoPokemonClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/pkmn
Source: "{#source_path}\ArchipelagoChecksFinderClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/cf
Source: "{#source_path}\ArchipelagoStarcraft2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sc2
Source: "{#source_path}\ArchipelagoMMBN3Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/mmbn3
Source: "{#source_path}\ArchipelagoZelda1Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/tloz
Source: "{#source_path}\ArchipelagoWargrooveClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/wargroove
Source: "{#source_path}\ArchipelagoKH2Client.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/kh2
Source: "{#source_path}\ArchipelagoAdventureClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/advn
Source: "{#source_path}\ArchipelagoUndertaleClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/ut
Source: "{#source_path}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#source_path}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs;
Source: "{#source_path}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs;
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
[Icons]
Name: "{group}\{#MyAppName} Folder"; Filename: "{app}";
Name: "{group}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLauncher.exe"
Name: "{group}\{#MyAppName} Server"; Filename: "{app}\ArchipelagoServer"; Components: server
Name: "{group}\{#MyAppName} Text Client"; Filename: "{app}\ArchipelagoTextClient.exe"; Components: client/text
Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Components: client/sni
Name: "{group}\{#MyAppName} BizHawk Client"; Filename: "{app}\ArchipelagoBizHawkClient.exe"; Components: client/bizhawk
Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
Name: "{group}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Components: client/oot
Name: "{group}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Components: client/zl
Name: "{group}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Components: client/ff1
Name: "{group}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Components: client/pkmn
Name: "{group}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Components: client/cf
Name: "{group}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Components: client/sc2
Name: "{group}\{#MyAppName} MegaMan Battle Network 3 Client"; Filename: "{app}\ArchipelagoMMBN3Client.exe"; Components: client/mmbn3
Name: "{group}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app}\ArchipelagoZelda1Client.exe"; Components: client/tloz
Name: "{group}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Components: client/kh2
Name: "{group}\{#MyAppName} Link's Awakening Client"; Filename: "{app}\ArchipelagoLinksAwakeningClient.exe"; Components: client/ladx
Name: "{group}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Components: client/advn
Name: "{group}\{#MyAppName} Wargroove Client"; Filename: "{app}\ArchipelagoWargrooveClient.exe"; Components: client/wargroove
Name: "{group}\{#MyAppName} Undertale Client"; Filename: "{app}\ArchipelagoUndertaleClient.exe"; Components: client/ut
Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon
Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLauncher.exe"; Tasks: desktopicon
Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\ArchipelagoServer"; Tasks: desktopicon; Components: server
Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Tasks: desktopicon; Components: client/sni
Name: "{commondesktop}\{#MyAppName} BizHawk Client"; Filename: "{app}\ArchipelagoBizHawkClient.exe"; Tasks: desktopicon; Components: client/bizhawk
Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
Name: "{commondesktop}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Tasks: desktopicon; Components: client/minecraft
Name: "{commondesktop}\{#MyAppName} Ocarina of Time Client"; Filename: "{app}\ArchipelagoOoTClient.exe"; Tasks: desktopicon; Components: client/oot
Name: "{commondesktop}\{#MyAppName} Zillion Client"; Filename: "{app}\ArchipelagoZillionClient.exe"; Tasks: desktopicon; Components: client/zl
Name: "{commondesktop}\{#MyAppName} Final Fantasy 1 Client"; Filename: "{app}\ArchipelagoFF1Client.exe"; Tasks: desktopicon; Components: client/ff1
Name: "{commondesktop}\{#MyAppName} Pokemon Client"; Filename: "{app}\ArchipelagoPokemonClient.exe"; Tasks: desktopicon; Components: client/pkmn
Name: "{commondesktop}\{#MyAppName} ChecksFinder Client"; Filename: "{app}\ArchipelagoChecksFinderClient.exe"; Tasks: desktopicon; Components: client/cf
Name: "{commondesktop}\{#MyAppName} Starcraft 2 Client"; Filename: "{app}\ArchipelagoStarcraft2Client.exe"; Tasks: desktopicon; Components: client/sc2
Name: "{commondesktop}\{#MyAppName} MegaMan Battle Network 3 Client"; Filename: "{app}\ArchipelagoMMBN3Client.exe"; Tasks: desktopicon; Components: client/mmbn3
Name: "{commondesktop}\{#MyAppName} The Legend of Zelda Client"; Filename: "{app}\ArchipelagoZelda1Client.exe"; Tasks: desktopicon; Components: client/tloz
Name: "{commondesktop}\{#MyAppName} Wargroove Client"; Filename: "{app}\ArchipelagoWargrooveClient.exe"; Tasks: desktopicon; Components: client/wargroove
Name: "{commondesktop}\{#MyAppName} Kingdom Hearts 2 Client"; Filename: "{app}\ArchipelagoKH2Client.exe"; Tasks: desktopicon; Components: client/kh2
Name: "{commondesktop}\{#MyAppName} Link's Awakening Client"; Filename: "{app}\ArchipelagoLinksAwakeningClient.exe"; Tasks: desktopicon; Components: client/ladx
Name: "{commondesktop}\{#MyAppName} Adventure Client"; Filename: "{app}\ArchipelagoAdventureClient.exe"; Tasks: desktopicon; Components: client/advn
Name: "{commondesktop}\{#MyAppName} Undertale Client"; Filename: "{app}\ArchipelagoUndertaleClient.exe"; Tasks: desktopicon; Components: client/ut
[Run]
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..."
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: client/sni/lttp or generator/lttp
Filename: "{app}\ArchipelagoMinecraftClient.exe"; Parameters: "--install"; StatusMsg: "Installing Forge Server..."; Components: client/minecraft
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Flags: nowait; Components: lttp_sprites
Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden
Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringChange('Launcher', '&', '&&')}}"; Flags: nowait postinstall skipifsilent
@@ -198,7 +80,10 @@ Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringC
Type: dirifempty; Name: "{app}"
[InstallDelete]
Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld"
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
@@ -206,101 +91,102 @@ Type: filesandordirs; Name: "{app}\EnemizerCLI*"
[Registry]
Root: HKCR; Subkey: ".aplttp"; ValueData: "{#MyAppName}patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}patch"; ValueData: "Archipelago Binary Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".aplttp"; ValueData: "{#MyAppName}patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}patch"; ValueData: "Archipelago Binary Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apsm"; ValueData: "{#MyAppName}smpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archipelago Super Metroid Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apsm"; ValueData: "{#MyAppName}smpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archipelago Super Metroid Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apdkc3"; ValueData: "{#MyAppName}dkc3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Archipelago Donkey Kong Country 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apdkc3"; ValueData: "{#MyAppName}dkc3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}dkc3patch"; ValueData: "Archipelago Donkey Kong Country 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}dkc3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smwpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apsmw"; ValueData: "{#MyAppName}smwpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smwpatch"; ValueData: "Archipelago Super Mario World Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smwpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smwpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apzl"; ValueData: "{#MyAppName}zlpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: "{#MyAppName}zlpatch"; ValueData: "Archipelago Zillion Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: "{#MyAppName}zlpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZillionClient.exe,0"; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: "{#MyAppName}zlpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZillionClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/zl
Root: HKCR; Subkey: ".apzl"; ValueData: "{#MyAppName}zlpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}zlpatch"; ValueData: "Archipelago Zillion Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}zlpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZillionClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}zlpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZillionClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smz3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apsmz3"; ValueData: "{#MyAppName}smz3patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smz3patch"; ValueData: "Archipelago SMZ3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smz3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}smz3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apsoe"; ValueData: "{#MyAppName}soepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Archipelago Secret of Evermore Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apsoe"; ValueData: "{#MyAppName}soepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}soepatch"; ValueData: "Archipelago Secret of Evermore Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}soepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}soepatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apl2ac"; ValueData: "{#MyAppName}l2acpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}l2acpatch"; ValueData: "Archipelago Lufia II Ancient Cave Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apl2ac"; ValueData: "{#MyAppName}l2acpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}l2acpatch"; ValueData: "Archipelago Lufia II Ancient Cave Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}l2acpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata\shell\open\command"; ValueData: """{app}\ArchipelagoMinecraftClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata\shell\open\command"; ValueData: """{app}\ArchipelagoMinecraftClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apz5"; ValueData: "{#MyAppName}n64zpf"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/oot
Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archipelago Ocarina of Time Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/oot
Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: ""; Components: client/oot
Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/oot
Root: HKCR; Subkey: ".apz5"; ValueData: "{#MyAppName}n64zpf"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archipelago Ocarina of Time Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}n64zpf\shell\open\command"; ValueData: """{app}\ArchipelagoOoTClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: ".apred"; ValueData: "{#MyAppName}pkmnrpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch"; ValueData: "Archipelago Pokemon Red Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnrpatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoPokemonClient.exe,0"; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoPokemonClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/pkmn
Root: HKCR; Subkey: ".apblue"; ValueData: "{#MyAppName}pkmnbpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch"; ValueData: "Archipelago Pokemon Blue Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnbpatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apbn3"; ValueData: "{#MyAppName}bn3bpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/mmbn3
Root: HKCR; Subkey: "{#MyAppName}bn3bpatch"; ValueData: "Archipelago MegaMan Battle Network 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/mmbn3
Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoMMBN3Client.exe,0"; ValueType: string; ValueName: ""; Components: client/mmbn3
Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\shell\open\command"; ValueData: """{app}\ArchipelagoMMBN3Client.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/mmbn3
Root: HKCR; Subkey: ".apbn3"; ValueData: "{#MyAppName}bn3bpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}bn3bpatch"; ValueData: "Archipelago MegaMan Battle Network 3 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoMMBN3Client.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}bn3bpatch\shell\open\command"; ValueData: """{app}\ArchipelagoMMBN3Client.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apladx"; ValueData: "{#MyAppName}ladxpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/ladx
Root: HKCR; Subkey: "{#MyAppName}ladxpatch"; ValueData: "Archipelago Links Awakening DX Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/ladx
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: ""; Components: client/ladx
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/ladx
Root: HKCR; Subkey: ".apemerald"; ValueData: "{#MyAppName}pkmnepatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnepatch"; ValueData: "Archipelago Pokemon Emerald Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnepatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}pkmnepatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".aptloz"; ValueData: "{#MyAppName}tlozpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/tloz
Root: HKCR; Subkey: "{#MyAppName}tlozpatch"; ValueData: "Archipelago The Legend of Zelda Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/tloz
Root: HKCR; Subkey: "{#MyAppName}tlozpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoZelda1Client.exe,0"; ValueType: string; ValueName: ""; Components: client/tloz
Root: HKCR; Subkey: "{#MyAppName}tlozpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZelda1Client.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/tloz
Root: HKCR; Subkey: ".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\DefaultIcon"; ValueData: "{app}\ArchipelagoLinksAwakeningClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ladxpatch\shell\open\command"; ValueData: """{app}\ArchipelagoLinksAwakeningClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apadvn"; ValueData: "{#MyAppName}advnpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/advn
Root: HKCR; Subkey: "{#MyAppName}advnpatch"; ValueData: "Archipelago Adventure Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/advn
Root: HKCR; Subkey: "{#MyAppName}advnpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoAdventureClient.exe,0"; ValueType: string; ValueName: ""; Components: client/advn
Root: HKCR; Subkey: "{#MyAppName}advnpatch\shell\open\command"; ValueData: """{app}\ArchipelagoAdventureClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/advn
Root: HKCR; Subkey: ".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\DefaultIcon"; ValueData: "{app}\ArchipelagoZelda1Client.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}tlozpatch\shell\open\command"; ValueData: """{app}\ArchipelagoZelda1Client.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: ".apadvn"; ValueData: "{#MyAppName}advnpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}advnpatch"; ValueData: "Archipelago Adventure Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}advnpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoAdventureClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}advnpatch\shell\open\command"; ValueData: """{app}\ArchipelagoAdventureClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Components: client/text
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; Components: client/text
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0"; Components: client/text
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1"""; Components: client/text
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";
Root: HKCR; Subkey: "archipelago\shell\open\command"; ValueType: "string"; ValueData: """{app}\ArchipelagoTextClient.exe"" ""%1""";
[Code]
const
SHCONTCH_NOPROGRESSBOX = 4;
SHCONTCH_RESPONDYESTOALL = 16;
// See: https://stackoverflow.com/a/51614652/2287576
function IsVCRedist64BitNeeded(): boolean;
var
@@ -311,7 +197,7 @@ begin
begin
// Is the installed version at least the packaged one ?
Log('VC Redist x64 Version : found ' + strVersion);
Result := (CompareStr(strVersion, 'v14.32.31332') < 0);
Result := (CompareStr(strVersion, 'v14.38.33130') < 0);
end
else
begin
@@ -320,594 +206,3 @@ begin
Result := True;
end;
end;
var R : longint;
var lttprom: string;
var LttPROMFilePage: TInputFileWizardPage;
var smrom: string;
var SMRomFilePage: TInputFileWizardPage;
var dkc3rom: string;
var DKC3RomFilePage: TInputFileWizardPage;
var smwrom: string;
var SMWRomFilePage: TInputFileWizardPage;
var soerom: string;
var SoERomFilePage: TInputFileWizardPage;
var l2acrom: string;
var L2ACROMFilePage: TInputFileWizardPage;
var ootrom: string;
var OoTROMFilePage: TInputFileWizardPage;
var zlrom: string;
var ZlROMFilePage: TInputFileWizardPage;
var redrom: string;
var RedROMFilePage: TInputFileWizardPage;
var bluerom: string;
var BlueROMFilePage: TInputFileWizardPage;
var bn3rom: string;
var BN3ROMFilePage: TInputFileWizardPage;
var ladxrom: string;
var LADXROMFilePage: TInputFileWizardPage;
var tlozrom: string;
var TLoZROMFilePage: TInputFileWizardPage;
var advnrom: string;
var AdvnROMFilePage: TInputFileWizardPage;
function GetSNESMD5OfFile(const rom: string): string;
var data: AnsiString;
begin
if LoadStringFromFile(rom, data) then
begin
if Length(data) mod 1024 = 512 then
begin
data := copy(data, 513, Length(data)-512);
end;
Result := GetMD5OfString(data);
end;
end;
function GetSMSMD5OfFile(const rom: string): string;
var data: AnsiString;
begin
if LoadStringFromFile(rom, data) then
begin
Result := GetMD5OfString(data);
end;
end;
function CheckRom(name: string; hash: string): string;
var rom: string;
begin
log('Handling ' + name)
rom := FileSearch(name, WizardDirValue());
if Length(rom) > 0 then
begin
log('existing ROM found');
log(IntToStr(CompareStr(GetSNESMD5OfFile(rom), hash)));
if CompareStr(GetSNESMD5OfFile(rom), hash) = 0 then
begin
log('existing ROM verified');
Result := rom;
exit;
end;
log('existing ROM failed verification');
end;
end;
function CheckSMSRom(name: string; hash: string): string;
var rom: string;
begin
log('Handling ' + name)
rom := FileSearch(name, WizardDirValue());
if Length(rom) > 0 then
begin
log('existing ROM found');
log(IntToStr(CompareStr(GetSMSMD5OfFile(rom), hash)));
if CompareStr(GetSMSMD5OfFile(rom), hash) = 0 then
begin
log('existing ROM verified');
Result := rom;
exit;
end;
log('existing ROM failed verification');
end;
end;
function CheckNESRom(name: string; hash: string): string;
var rom: string;
begin
log('Handling ' + name)
rom := FileSearch(name, WizardDirValue());
if Length(rom) > 0 then
begin
log('existing ROM found');
log(IntToStr(CompareStr(GetSMSMD5OfFile(rom), hash)));
if CompareStr(GetSMSMD5OfFile(rom), hash) = 0 then
begin
log('existing ROM verified');
Result := rom;
exit;
end;
log('existing ROM failed verification');
end;
end;
function AddRomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'SNES ROM files|*.sfc;*.smc|All files|*.*',
'.sfc');
end;
function AddGBRomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'GB ROM files|*.gb;*.gbc|All files|*.*',
'.gb');
end;
function AddGBARomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'GBA ROM files|*.gba|All files|*.*',
'.gba');
end;
function AddSMSRomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'SMS ROM files|*.sms|All files|*.*',
'.sms');
end;
function AddNESRomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'NES ROM files|*.nes|All files|*.*',
'.nes');
end;
procedure AddOoTRomPage();
begin
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
if Length(ootrom) > 0 then
begin
log('existing ROM found');
log(IntToStr(CompareStr(GetMD5OfFile(ootrom), '5bd1fe107bf8106b2ab6650abecd54d6'))); // normal
log(IntToStr(CompareStr(GetMD5OfFile(ootrom), '6697768a7a7df2dd27a692a2638ea90b'))); // byteswapped
log(IntToStr(CompareStr(GetMD5OfFile(ootrom), '05f0f3ebacbc8df9243b6148ffe4792f'))); // decompressed
if (CompareStr(GetMD5OfFile(ootrom), '5bd1fe107bf8106b2ab6650abecd54d6') = 0) or (CompareStr(GetMD5OfFile(ootrom), '6697768a7a7df2dd27a692a2638ea90b') = 0) or (CompareStr(GetMD5OfFile(ootrom), '05f0f3ebacbc8df9243b6148ffe4792f') = 0) then
begin
log('existing ROM verified');
exit;
end;
log('existing ROM failed verification');
end;
ootrom := ''
OoTROMFilePage :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your OoT 1.0 ROM located?',
'Select the file, then click Next.');
OoTROMFilePage.Add(
'Location of ROM file:',
'N64 ROM files (*.z64, *.n64)|*.z64;*.n64|All files|*.*',
'.z64');
end;
function AddA26Page(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'A2600 ROM files|*.BIN;*.a26|All files|*.*',
'.BIN');
end;
function NextButtonClick(CurPageID: Integer): Boolean;
begin
if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then
Result := not (LttPROMFilePage.Values[0] = '')
else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then
Result := not (SMROMFilePage.Values[0] = '')
else if (assigned(DKC3ROMFilePage)) and (CurPageID = DKC3ROMFilePage.ID) then
Result := not (DKC3ROMFilePage.Values[0] = '')
else if (assigned(SMWROMFilePage)) and (CurPageID = SMWROMFilePage.ID) then
Result := not (SMWROMFilePage.Values[0] = '')
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
Result := not (SoEROMFilePage.Values[0] = '')
else if (assigned(L2ACROMFilePage)) and (CurPageID = L2ACROMFilePage.ID) then
Result := not (L2ACROMFilePage.Values[0] = '')
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
Result := not (OoTROMFilePage.Values[0] = '')
else if (assigned(BN3ROMFilePage)) and (CurPageID = BN3ROMFilePage.ID) then
Result := not (BN3ROMFilePage.Values[0] = '')
else if (assigned(ZlROMFilePage)) and (CurPageID = ZlROMFilePage.ID) then
Result := not (ZlROMFilePage.Values[0] = '')
else if (assigned(RedROMFilePage)) and (CurPageID = RedROMFilePage.ID) then
Result := not (RedROMFilePage.Values[0] = '')
else if (assigned(BlueROMFilePage)) and (CurPageID = BlueROMFilePage.ID) then
Result := not (BlueROMFilePage.Values[0] = '')
else if (assigned(LADXROMFilePage)) and (CurPageID = LADXROMFilePage.ID) then
Result := not (LADXROMFilePage.Values[0] = '')
else if (assigned(TLoZROMFilePage)) and (CurPageID = TLoZROMFilePage.ID) then
Result := not (TLoZROMFilePage.Values[0] = '')
else if (assigned(AdvnROMFilePage)) and (CurPageID = AdvnROMFilePage.ID) then
Result := not (AdvnROMFilePage.Values[0] = '')
else
Result := True;
end;
function GetROMPath(Param: string): string;
begin
if Length(lttprom) > 0 then
Result := lttprom
else if Assigned(LttPRomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173')
if R <> 0 then
MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := LttPROMFilePage.Values[0]
end
else
Result := '';
end;
function GetSMROMPath(Param: string): string;
begin
if Length(smrom) > 0 then
Result := smrom
else if Assigned(SMRomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675')
if R <> 0 then
MsgBox('Super Metroid ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := SMROMFilePage.Values[0]
end
else
Result := '';
end;
function GetDKC3ROMPath(Param: string): string;
begin
if Length(dkc3rom) > 0 then
Result := dkc3rom
else if Assigned(DKC3RomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(DKC3ROMFilePage.Values[0]), '120abf304f0c40fe059f6a192ed4f947')
if R <> 0 then
MsgBox('Donkey Kong Country 3 ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := DKC3ROMFilePage.Values[0]
end
else
Result := '';
end;
function GetSMWROMPath(Param: string): string;
begin
if Length(smwrom) > 0 then
Result := smwrom
else if Assigned(SMWRomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(SMWROMFilePage.Values[0]), 'cdd3c8c37322978ca8669b34bc89c804')
if R <> 0 then
MsgBox('Super Mario World ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := SMWROMFilePage.Values[0]
end
else
Result := '';
end;
function GetSoEROMPath(Param: string): string;
begin
if Length(soerom) > 0 then
Result := soerom
else if Assigned(SoERomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a')
if R <> 0 then
MsgBox('Secret of Evermore ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := SoEROMFilePage.Values[0]
end
else
Result := '';
end;
function GetOoTROMPath(Param: string): string;
begin
if Length(ootrom) > 0 then
Result := ootrom
else if Assigned(OoTROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '5bd1fe107bf8106b2ab6650abecd54d6') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '6697768a7a7df2dd27a692a2638ea90b') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '05f0f3ebacbc8df9243b6148ffe4792f');
if R <> 0 then
MsgBox('OoT ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := OoTROMFilePage.Values[0]
end
else
Result := '';
end;
function GetL2ACROMPath(Param: string): string;
begin
if Length(l2acrom) > 0 then
Result := l2acrom
else if Assigned(L2ACROMFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(L2ACROMFilePage.Values[0]), '6efc477d6203ed2b3b9133c1cd9e9c5d')
if R <> 0 then
MsgBox('Lufia II ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := L2ACROMFilePage.Values[0]
end
else
Result := '';
end;
function GetZlROMPath(Param: string): string;
begin
if Length(zlrom) > 0 then
Result := zlrom
else if Assigned(ZlROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(ZlROMFilePage.Values[0]), 'd4bf9e7bcf9a48da53785d2ae7bc4270');
if R <> 0 then
MsgBox('Zillion ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := ZlROMFilePage.Values[0]
end
else
Result := '';
end;
function GetRedROMPath(Param: string): string;
begin
if Length(redrom) > 0 then
Result := redrom
else if Assigned(RedROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(RedROMFilePage.Values[0]), '3d45c1ee9abd5738df46d2bdda8b57dc')
if R <> 0 then
MsgBox('Pokemon Red ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := RedROMFilePage.Values[0]
end
else
Result := '';
end;
function GetBlueROMPath(Param: string): string;
begin
if Length(bluerom) > 0 then
Result := bluerom
else if Assigned(BlueROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(BlueROMFilePage.Values[0]), '50927e843568814f7ed45ec4f944bd8b')
if R <> 0 then
MsgBox('Pokemon Blue ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := BlueROMFilePage.Values[0]
end
else
Result := '';
end;
function GetTLoZROMPath(Param: string): string;
begin
if Length(tlozrom) > 0 then
Result := tlozrom
else if Assigned(TLoZROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(TLoZROMFilePage.Values[0]), '337bd6f1a1163df31bf2633665589ab0');
if R <> 0 then
MsgBox('The Legend of Zelda ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := TLoZROMFilePage.Values[0]
end
else
Result := '';
end;
function GetLADXROMPath(Param: string): string;
begin
if Length(ladxrom) > 0 then
Result := ladxrom
else if Assigned(LADXROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(LADXROMFilePage.Values[0]), '07c211479386825042efb4ad31bb525f')
if R <> 0 then
MsgBox('Link''s Awakening DX ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := LADXROMFilePage.Values[0]
end
else
Result := '';
end;
function GetAdvnROMPath(Param: string): string;
begin
if Length(advnrom) > 0 then
Result := advnrom
else if Assigned(AdvnROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(AdvnROMFilePage.Values[0]), '157bddb7192754a45372be196797f284');
if R <> 0 then
MsgBox('Adventure ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := AdvnROMFilePage.Values[0]
end
else
Result := '';
end;
function GetBN3ROMPath(Param: string): string;
begin
if Length(bn3rom) > 0 then
Result := bn3rom
else if Assigned(BN3ROMFilePage) then
begin
R := CompareStr(GetMD5OfFile(BN3ROMFilePage.Values[0]), '6fe31df0144759b34ad666badaacc442')
if R <> 0 then
MsgBox('MegaMan Battle Network 3 Blue ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := BN3ROMFilePage.Values[0]
end
else
Result := '';
end;
procedure InitializeWizard();
begin
AddOoTRomPage();
lttprom := CheckRom('Zelda no Densetsu - Kamigami no Triforce (Japan).sfc', '03a63945398191337e896e5771f77173');
if Length(lttprom) = 0 then
LttPROMFilePage:= AddRomPage('Zelda no Densetsu - Kamigami no Triforce (Japan).sfc');
smrom := CheckRom('Super Metroid (JU).sfc', '21f3e98df4780ee1c667b84e57d88675');
if Length(smrom) = 0 then
SMRomFilePage:= AddRomPage('Super Metroid (JU).sfc');
dkc3rom := CheckRom('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc', '120abf304f0c40fe059f6a192ed4f947');
if Length(dkc3rom) = 0 then
DKC3RomFilePage:= AddRomPage('Donkey Kong Country 3 - Dixie Kong''s Double Trouble! (USA) (En,Fr).sfc');
smwrom := CheckRom('Super Mario World (USA).sfc', 'cdd3c8c37322978ca8669b34bc89c804');
if Length(smwrom) = 0 then
SMWRomFilePage:= AddRomPage('Super Mario World (USA).sfc');
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
if Length(soerom) = 0 then
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
zlrom := CheckSMSRom('Zillion (UE) [!].sms', 'd4bf9e7bcf9a48da53785d2ae7bc4270');
if Length(zlrom) = 0 then
ZlROMFilePage:= AddSMSRomPage('Zillion (UE) [!].sms');
redrom := CheckRom('Pokemon Red (UE) [S][!].gb','3d45c1ee9abd5738df46d2bdda8b57dc');
if Length(redrom) = 0 then
RedROMFilePage:= AddGBRomPage('Pokemon Red (UE) [S][!].gb');
bluerom := CheckRom('Pokemon Blue (UE) [S][!].gb','50927e843568814f7ed45ec4f944bd8b');
if Length(bluerom) = 0 then
BlueROMFilePage:= AddGBRomPage('Pokemon Blue (UE) [S][!].gb');
bn3rom := CheckRom('Mega Man Battle Network 3 - Blue Version (USA).gba','6fe31df0144759b34ad666badaacc442');
if Length(bn3rom) = 0 then
BN3ROMFilePage:= AddGBARomPage('Mega Man Battle Network 3 - Blue Version (USA).gba');
ladxrom := CheckRom('Legend of Zelda, The - Link''s Awakening DX (USA, Europe) (SGB Enhanced).gbc','07c211479386825042efb4ad31bb525f');
if Length(ladxrom) = 0 then
LADXROMFilePage:= AddGBRomPage('Legend of Zelda, The - Link''s Awakening DX (USA, Europe) (SGB Enhanced).gbc');
l2acrom := CheckRom('Lufia II - Rise of the Sinistrals (USA).sfc', '6efc477d6203ed2b3b9133c1cd9e9c5d');
if Length(l2acrom) = 0 then
L2ACROMFilePage:= AddRomPage('Lufia II - Rise of the Sinistrals (USA).sfc');
tlozrom := CheckNESROM('Legend of Zelda, The (U) (PRG0) [!].nes', '337bd6f1a1163df31bf2633665589ab0');
if Length(tlozrom) = 0 then
TLoZROMFilePage:= AddNESRomPage('Legend of Zelda, The (U) (PRG0) [!].nes');
advnrom := CheckSMSRom('ADVNTURE.BIN', '157bddb7192754a45372be196797f284');
if Length(advnrom) = 0 then
AdvnROMFilePage:= AddA26Page('ADVNTURE.BIN');
end;
function ShouldSkipPage(PageID: Integer): Boolean;
begin
Result := False;
if (assigned(LttPROMFilePage)) and (PageID = LttPROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/lttp') or WizardIsComponentSelected('generator/lttp'));
if (assigned(SMROMFilePage)) and (PageID = SMROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
if (assigned(DKC3ROMFilePage)) and (PageID = DKC3ROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/dkc3') or WizardIsComponentSelected('generator/dkc3'));
if (assigned(SMWROMFilePage)) and (PageID = SMWROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/smw') or WizardIsComponentSelected('generator/smw'));
if (assigned(L2ACROMFilePage)) and (PageID = L2ACROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/l2ac') or WizardIsComponentSelected('generator/l2ac'));
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/soe'));
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/oot') or WizardIsComponentSelected('client/oot'));
if (assigned(ZlROMFilePage)) and (PageID = ZlROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/zl') or WizardIsComponentSelected('client/zl'));
if (assigned(RedROMFilePage)) and (PageID = RedROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/pkmn_r') or WizardIsComponentSelected('client/pkmn/red'));
if (assigned(BlueROMFilePage)) and (PageID = BlueROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/pkmn_b') or WizardIsComponentSelected('client/pkmn/blue'));
if (assigned(BN3ROMFilePage)) and (PageID = BN3ROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/mmbn3') or WizardIsComponentSelected('client/mmbn3'));
if (assigned(LADXROMFilePage)) and (PageID = LADXROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/ladx') or WizardIsComponentSelected('client/ladx'));
if (assigned(TLoZROMFilePage)) and (PageID = TLoZROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/tloz') or WizardIsComponentSelected('client/tloz'));
if (assigned(AdvnROMFilePage)) and (PageID = AdvnROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/advn'));
end;

263
kvui.py
View File

@@ -5,12 +5,13 @@ import typing
if sys.platform == "win32":
import ctypes
# kivy 2.2.0 introduced DPI awareness on Windows, but it makes the UI enter an infinitely recursive re-layout
# by setting the application to not DPI Aware, Windows handles scaling the entire window on its own, ignoring kivy's
try:
ctypes.windll.shcore.SetProcessDpiAwareness(0)
except FileNotFoundError: # shcore may not be found on <= Windows 7
pass # TODO: remove silent except when Python 3.8 is phased out.
pass # TODO: remove silent except when Python 3.8 is phased out.
os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1"
@@ -18,14 +19,15 @@ os.environ["KIVY_NO_ARGS"] = "1"
os.environ["KIVY_LOG_ENABLE"] = "0"
import Utils
if Utils.is_frozen():
os.environ["KIVY_DATA_DIR"] = Utils.local_path("data")
from kivy.config import Config
Config.set("input", "mouse", "mouse,disable_multitouch")
Config.set('kivy', 'exit_on_escape', '0')
Config.set('graphics', 'multisamples', '0') # multisamples crash old intel drivers
Config.set("kivy", "exit_on_escape", "0")
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
from kivy.app import App
from kivy.core.window import Window
@@ -58,7 +60,6 @@ from kivy.uix.popup import Popup
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
from Utils import async_start
@@ -77,8 +78,8 @@ class HoverBehavior(object):
border_point = ObjectProperty(None)
def __init__(self, **kwargs):
self.register_event_type('on_enter')
self.register_event_type('on_leave')
self.register_event_type("on_enter")
self.register_event_type("on_leave")
Window.bind(mouse_pos=self.on_mouse_pos)
Window.bind(on_cursor_leave=self.on_cursor_leave)
super(HoverBehavior, self).__init__(**kwargs)
@@ -106,7 +107,7 @@ class HoverBehavior(object):
self.dispatch("on_leave")
Factory.register('HoverBehavior', HoverBehavior)
Factory.register("HoverBehavior", HoverBehavior)
class ToolTip(Label):
@@ -121,6 +122,60 @@ class HovererableLabel(HoverBehavior, Label):
pass
class TooltipLabel(HovererableLabel):
tooltip = None
def create_tooltip(self, text, x, y):
text = text.replace("<br>", "\n").replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]")
if self.tooltip:
# update
self.tooltip.children[0].text = text
else:
self.tooltip = FloatLayout()
tooltip_label = ToolTip(text=text)
self.tooltip.add_widget(tooltip_label)
fade_in_animation.start(self.tooltip)
App.get_running_app().root.add_widget(self.tooltip)
# handle left-side boundary to not render off-screen
x = max(x, 3 + self.tooltip.children[0].texture_size[0] / 2)
# position float layout
self.tooltip.x = x - self.tooltip.width / 2
self.tooltip.y = y - self.tooltip.height / 2 + 48
def remove_tooltip(self):
if self.tooltip:
App.get_running_app().root.remove_widget(self.tooltip)
self.tooltip = None
def on_mouse_pos(self, window, pos):
if not self.get_root_window():
return # Abort if not displayed
super().on_mouse_pos(window, pos)
if self.refs and self.hovered:
tx, ty = self.to_widget(*pos, relative=True)
# Why TF is Y flipped *within* the texture?
ty = self.texture_size[1] - ty
hit = False
for uid, zones in self.refs.items():
for zone in zones:
x, y, w, h = zone
if x <= tx <= w and y <= ty <= h:
self.create_tooltip(uid.split("|", 1)[1], *pos)
hit = True
break
if not hit:
self.remove_tooltip()
def on_enter(self):
pass
def on_leave(self):
self.remove_tooltip()
class ServerLabel(HovererableLabel):
def __init__(self, *args, **kwargs):
super(HovererableLabel, self).__init__(*args, **kwargs)
@@ -189,11 +244,10 @@ class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior,
""" Adds selection and focus behaviour to the view. """
class SelectableLabel(RecycleDataViewBehavior, HovererableLabel):
class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
""" Add selection support to the Label """
index = None
selected = BooleanProperty(False)
tooltip = None
def refresh_view_attrs(self, rv, index, data):
""" Catch and handle the view changes """
@@ -201,56 +255,6 @@ class SelectableLabel(RecycleDataViewBehavior, HovererableLabel):
return super(SelectableLabel, self).refresh_view_attrs(
rv, index, data)
def create_tooltip(self, text, x, y):
text = text.replace("<br>", "\n").replace('&amp;', '&').replace('&bl;', '[').replace('&br;', ']')
if self.tooltip:
# update
self.tooltip.children[0].text = text
else:
self.tooltip = FloatLayout()
tooltip_label = ToolTip(text=text)
self.tooltip.add_widget(tooltip_label)
fade_in_animation.start(self.tooltip)
App.get_running_app().root.add_widget(self.tooltip)
# handle left-side boundary to not render off-screen
x = max(x, 3+self.tooltip.children[0].texture_size[0] / 2)
# position float layout
self.tooltip.x = x - self.tooltip.width / 2
self.tooltip.y = y - self.tooltip.height / 2 + 48
def remove_tooltip(self):
if self.tooltip:
App.get_running_app().root.remove_widget(self.tooltip)
self.tooltip = None
def on_mouse_pos(self, window, pos):
if not self.get_root_window():
return # Abort if not displayed
super().on_mouse_pos(window, pos)
if self.refs and self.hovered:
tx, ty = self.to_widget(*pos, relative=True)
# Why TF is Y flipped *within* the texture?
ty = self.texture_size[1] - ty
hit = False
for uid, zones in self.refs.items():
for zone in zones:
x, y, w, h = zone
if x <= tx <= w and y <= ty <= h:
self.create_tooltip(uid.split("|", 1)[1], *pos)
hit = True
break
if not hit:
self.remove_tooltip()
def on_enter(self):
pass
def on_leave(self):
self.remove_tooltip()
def on_touch_down(self, touch):
""" Add selection on touch down """
if super(SelectableLabel, self).on_touch_down(touch):
@@ -274,7 +278,7 @@ class SelectableLabel(RecycleDataViewBehavior, HovererableLabel):
elif not cmdinput.text and text.startswith("Missing: "):
cmdinput.text = text.replace("Missing: ", "!hint_location ")
Clipboard.copy(text.replace('&amp;', '&').replace('&bl;', '[').replace('&br;', ']'))
Clipboard.copy(text.replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]"))
return self.parent.select_with_touch(self.index, touch)
def apply_selection(self, rv, index, is_selected):
@@ -282,9 +286,68 @@ class SelectableLabel(RecycleDataViewBehavior, HovererableLabel):
self.selected = is_selected
class HintLabel(RecycleDataViewBehavior, BoxLayout):
selected = BooleanProperty(False)
striped = BooleanProperty(False)
index = None
no_select = []
def __init__(self):
super(HintLabel, self).__init__()
self.receiving_text = ""
self.item_text = ""
self.finding_text = ""
self.location_text = ""
self.entrance_text = ""
self.found_text = ""
for child in self.children:
child.bind(texture_size=self.set_height)
def set_height(self, instance, value):
self.height = max([child.texture_size[1] for child in self.children])
def refresh_view_attrs(self, rv, index, data):
self.index = index
if "select" in data and not data["select"] and index not in self.no_select:
self.no_select.append(index)
self.striped = data["striped"]
self.receiving_text = data["receiving"]["text"]
self.item_text = data["item"]["text"]
self.finding_text = data["finding"]["text"]
self.location_text = data["location"]["text"]
self.entrance_text = data["entrance"]["text"]
self.found_text = data["found"]["text"]
self.height = self.minimum_height
return super(HintLabel, self).refresh_view_attrs(rv, index, data)
def on_touch_down(self, touch):
""" Add selection on touch down """
if super(HintLabel, self).on_touch_down(touch):
return True
if self.index not in self.no_select:
if self.collide_point(*touch.pos):
if self.selected:
self.parent.clear_selection()
else:
text = "".join([self.receiving_text, "\'s ", self.item_text, " is at ", self.location_text, " in ",
self.finding_text, "\'s World", (" at " + self.entrance_text)
if self.entrance_text != "Vanilla"
else "", ". (", self.found_text.lower(), ")"])
temp = MarkupLabel(text).markup
text = "".join(
part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
Clipboard.copy(escape_markup(text).replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]"))
return self.parent.select_with_touch(self.index, touch)
def apply_selection(self, rv, index, is_selected):
""" Respond to the selection of items in the view. """
if self.index not in self.no_select:
self.selected = is_selected
class ConnectBarTextInput(TextInput):
def insert_text(self, substring, from_undo=False):
s = substring.replace('\n', '').replace('\r', '')
s = substring.replace("\n", "").replace("\r", "")
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
@@ -302,7 +365,7 @@ class MessageBox(Popup):
def __init__(self, title, text, error=False, **kwargs):
label = MessageBox.MessageBoxLabel(text=text)
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width)+40),
super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width) + 40),
separator_color=separator_color, **kwargs)
self.height += max(0, label.height - 18)
@@ -358,11 +421,14 @@ class GameManager(App):
# top part
server_label = ServerLabel()
self.connect_layout.add_widget(server_label)
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:", size_hint_y=None,
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:",
size_hint_y=None,
height=dp(30), multiline=False, write_tab=False)
def connect_bar_validate(sender):
if not self.ctx.server:
self.connect_button_action(sender)
self.server_connect_bar.bind(on_text_validate=connect_bar_validate)
self.connect_layout.add_widget(self.server_connect_bar)
self.server_connect_button = Button(text="Connect", size=(dp(100), dp(30)), size_hint_y=None, size_hint_x=None)
@@ -383,20 +449,22 @@ class GameManager(App):
bridge_logger = logging.getLogger(logger_name)
panel = TabbedPanelItem(text=display_name)
self.log_panels[display_name] = panel.content = UILog(bridge_logger)
self.tabs.add_widget(panel)
if len(self.logging_pairs) > 1:
# show Archipelago tab if other logging is present
self.tabs.add_widget(panel)
hint_panel = TabbedPanelItem(text="Hints")
self.log_panels["Hints"] = hint_panel.content = HintLog(self.json_to_kivy_parser)
self.tabs.add_widget(hint_panel)
if len(self.logging_pairs) == 1:
self.tabs.default_tab_text = "Archipelago"
self.main_area_container = GridLayout(size_hint_y=1, rows=1)
self.main_area_container.add_widget(self.tabs)
self.grid.add_widget(self.main_area_container)
if len(self.logging_pairs) == 1:
# Hide Tab selection if only one tab
self.tabs.clear_tabs()
self.tabs.do_default_tab = False
self.tabs.current_tab.height = 0
self.tabs.tab_height = 0
# bottom part
bottom_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30))
info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None)
@@ -422,7 +490,7 @@ class GameManager(App):
return self.container
def update_texts(self, dt):
if hasattr(self.tabs.content.children[0], 'fix_heights'):
if hasattr(self.tabs.content.children[0], "fix_heights"):
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \
@@ -499,6 +567,10 @@ class GameManager(App):
if hasattr(self, "energy_link_label"):
self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J"
def update_hints(self):
hints = self.ctx.stored_data[f"_read_hints_{self.ctx.team}_{self.ctx.slot}"]
self.log_panels["Hints"].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
@@ -513,12 +585,12 @@ class LogtoUI(logging.Handler):
def format_compact(record: logging.LogRecord) -> str:
if isinstance(record.msg, Exception):
return str(record.msg)
return (f'{record.exc_info[1]}\n' if record.exc_info else '') + str(record.msg).split("\n")[0]
return (f"{record.exc_info[1]}\n" if record.exc_info else "") + str(record.msg).split("\n")[0]
def handle(self, record: logging.LogRecord) -> None:
if getattr(record, 'skip_gui', False):
if getattr(record, "skip_gui", False):
pass # skip output
elif getattr(record, 'compact_gui', False):
elif getattr(record, "compact_gui", False):
self.on_log(self.format_compact(record))
else:
self.on_log(self.format(record))
@@ -552,6 +624,44 @@ class UILog(RecycleView):
element.height = element.texture_size[1]
class HintLog(RecycleView):
header = {
"receiving": {"text": "[u]Receiving Player[/u]"},
"item": {"text": "[u]Item[/u]"},
"finding": {"text": "[u]Finding Player[/u]"},
"location": {"text": "[u]Location[/u]"},
"entrance": {"text": "[u]Entrance[/u]"},
"found": {"text": "[u]Status[/u]"},
"striped": True,
"select": False,
}
def __init__(self, parser):
super(HintLog, self).__init__()
self.data = [self.header]
self.parser = parser
def refresh_hints(self, hints):
self.data = [self.header]
striped = False
for hint in hints:
self.data.append({
"striped": striped,
"receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})},
"item": {"text": self.parser.handle_node(
{"type": "item_id", "text": hint["item"], "flags": hint["item_flags"]})},
"finding": {"text": self.parser.handle_node({"type": "player_id", "text": hint["finding_player"]})},
"location": {"text": self.parser.handle_node({"type": "location_id", "text": hint["location"]})},
"entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text",
"color": "blue", "text": hint["entrance"]
if hint["entrance"] else "Vanilla"})},
"found": {
"text": self.parser.handle_node({"type": "color", "color": "green" if hint["found"] else "red",
"text": "Found" if hint["found"] else "Not Found"})},
})
striped = not striped
class E(ExceptionHandler):
logger = logging.getLogger("Client")
@@ -599,7 +709,7 @@ class KivyJSONtoTextParser(JSONtoTextParser):
f"Type: {SlotType(slot_info.type).name}"
if slot_info.group_members:
text += f"<br>Members:<br> " + \
'<br> '.join(self.ctx.player_names[player] for player in slot_info.group_members)
"<br> ".join(self.ctx.player_names[player] for player in slot_info.group_members)
node.setdefault("refs", []).append(text)
return super(KivyJSONtoTextParser, self)._handle_player_id(node)
@@ -627,4 +737,3 @@ user_file = Utils.user_path("data", "user.kv")
if os.path.exists(user_file):
logging.info("Loading user.kv into builder.")
Builder.load_file(user_file)

View File

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

View File

@@ -1,12 +1,13 @@
colorama>=0.4.5
websockets>=11.0.3
websockets>=12.0
PyYAML>=6.0.1
jellyfish>=1.0.1
jellyfish>=1.0.3
jinja2>=3.1.2
schema>=0.7.5
kivy>=2.2.0
bsdiff4>=1.2.3
platformdirs>=3.9.1
certifi>=2023.7.22
cython>=0.29.35
kivy>=2.3.0
bsdiff4>=1.2.4
platformdirs>=4.0.0
certifi>=2023.11.17
cython>=3.0.6
cymem>=2.0.8
orjson>=3.9.10

View File

@@ -597,8 +597,8 @@ class ServerOptions(Group):
disable_item_cheat: Union[DisableItemCheat, bool] = False
location_check_points: LocationCheckPoints = LocationCheckPoints(1)
hint_cost: HintCost = HintCost(10)
release_mode: ReleaseMode = ReleaseMode("goal")
collect_mode: CollectMode = CollectMode("goal")
release_mode: ReleaseMode = ReleaseMode("auto")
collect_mode: CollectMode = CollectMode("auto")
remaining_mode: RemainingMode = RemainingMode("goal")
auto_shutdown: AutoShutdown = AutoShutdown(0)
compatibility: Compatibility = Compatibility(2)
@@ -673,7 +673,7 @@ class GeneratorOptions(Group):
spoiler: Spoiler = Spoiler(3)
glitch_triforce_room: GlitchTriforceRoom = GlitchTriforceRoom(1) # why is this here?
race: Race = Race(0)
plando_options: PlandoOptions = PlandoOptions("bosses")
plando_options: PlandoOptions = PlandoOptions("bosses, connections, texts")
class SNIOptions(Group):
@@ -694,6 +694,25 @@ does nothing if not found
snes_rom_start: Union[SnesRomStart, bool] = True
class BizHawkClientOptions(Group):
class EmuHawkPath(UserFilePath):
"""
The location of the EmuHawk you want to auto launch patched ROMs with
"""
is_exe = True
description = "EmuHawk Executable"
class RomStart(str):
"""
Set this to true to autostart a patched ROM in BizHawk with the connector script,
to false to never open the patched rom automatically,
or to a path to an external program to open the ROM file with that instead.
"""
emuhawk_path: EmuHawkPath = EmuHawkPath(None)
rom_start: Union[RomStart, bool] = True
# Top-level group with lazy loading of worlds
class Settings(Group):
@@ -701,6 +720,7 @@ class Settings(Group):
server_options: ServerOptions = ServerOptions()
generator: GeneratorOptions = GeneratorOptions()
sni_options: SNIOptions = SNIOptions()
bizhawkclient_options: BizHawkClientOptions = BizHawkClientOptions()
_filename: Optional[str] = None

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