Compare commits

..

312 Commits
0.3.4 ... 0.3.6

Author SHA1 Message Date
black-sliver
3e6c097348 SoE: update source wheel for py3.11 on windows 2022-11-18 01:43:53 +01:00
Fabian Dill
8ce3fd5518 Core: update cx-Freeze 2022-11-17 23:53:50 +01:00
Alchav
93a354cd81 [Core] Item plando early locations (and non-early locations) (#1228) 2022-11-17 17:40:44 +01:00
espeon65536
774581b7ba HK: fix crash if shop locations are at max and extra shop slots is nonzero 2022-11-17 17:36:42 +01:00
NewSoupVi
95f90851ac The Witness: Update docs, credits, junk hints (#1240)
* Updated Credits & AP-game hints

* Updated docs
2022-11-17 17:35:59 +01:00
PoryGone
1cd1bfea4d SMW: Prevent Killing Bowser on Yoshi Egg Hunt (#1241)
* Make Bowser unkillable on Egg Hunt

Changed a location name.
2022-11-16 20:20:55 +01:00
espeon65536
edd1fff4b7 Core: make early_items internal only (#1177)
Co-authored-by: beauxq <beauxq@yahoo.com>
2022-11-16 17:32:33 +01:00
Zach Parks
4d79920fa6 Merge pull request #944
* WebHost: Remove "Random" as an option and move to separate button in …

* Merge branch 'main' into randomize-button

* Tweaked color and changed text of tooltip.

* Merge branch 'main' into randomize-button
2022-11-12 22:03:44 -05:00
recklesscoder
7665935227 Merge pull request #1231
* Web/Style: Fix code block padding.
2022-11-12 21:58:11 -05:00
Zach Parks
5139475068 Slay the Spire: Correct tool tip for heart_run to match actual behaviour (#1236) 2022-11-12 18:52:36 -08:00
PoryGone
adcee639a2 SMW: Adjust Early Double Levels (#1238) 2022-11-13 01:33:22 +01:00
Magnemania
fde97fca5b SC2: Increment required client version (#1237) 2022-11-13 01:32:33 +01:00
recklesscoder
e108b67ca5 Docs/OoT: Add savestate fix and links to Bizhawk and Logic wiki pages. 2022-11-12 02:19:26 +01:00
NewSoupVi
17da06f763 The Witness: Fix "doors: panels" items referring to the wrong panels 2022-11-12 02:18:51 +01:00
PoryGone
2ff737175f SMW: Fix Baby Yoshi and Rope Jump Exploits (#1226)
* Fix Baby Yoshi and Rope Jump Exploits

* Fix logic error of Forest of Illusion 3 Secret Exit
2022-11-11 11:50:29 +01:00
NewSoupVi
b0b8268249 Witness: Expert logic broken seed fixes (by IHNN) (#1223)
Co-authored-by: metzner <unconfigured@null.spigotmc.org>
2022-11-10 04:05:00 +01:00
espeon65536
4e5c10ad66 OoT: make Bottles and Adult Trade Item hintable groups (#1222)
* OoT: make Bottles and Adult Trade Item hintable groups
2022-11-09 22:07:14 +01:00
Alchav
350e1e6287 Pokemon Red and Blue: lua updates (#1216) 2022-11-09 15:15:16 +01:00
Magnemania
63c0d027e7 SC2: Bugfixes for YAML Option Interactions (#1215)
Early Unit: Now respects Excluded Items when selecting a random unit.

Units Always Have Upgrades: Changed removal cascade behavior; now additionally checks to see if an associated item is already locked when attempting to remove, and locks associated items if so. Occasionally caused issues with starter items in the past, frequently caused issues with yaml-defined Locked Items.
2022-11-09 13:53:55 +01:00
jtoyoda
a014bb4ab7 FF1: Updating FFR Docs for new Archipelago location (#1221) 2022-11-08 18:58:29 -06:00
black-sliver
0d10fec395 DS3: update data_version (#1220) 2022-11-09 01:53:08 +01:00
Ludovic Marechal
0cbee4ac3e DS3: Add progressive locations, fix the randomize_weapons_level option and add some options ( Deathlink ) (#1206)
* Update items_data.py

added `Red and White Round Shield`, `Crystal Scroll`, `Magic Stoneplate Ring`, and `Outrider Knight` gear.

* Update locations_data.py

Added `US: Red and White Round Shield`, `CKG: Magic Stoneplate Ring`, `GA: Outrider Knight` set, and `GA: Crystal Scroll`

* Update __init__.py

Add `Karla's Ashes` requirements

* Update items_data.py

Add `Irithyll Rapier, Hollow's Ashes, Irina's Ashes, Karla's Ashes, Cornyx's Ashes, and Orbeck's Ashes`

* Update locations_data.py

Add `Irithyll Rapier, Hollow's Ashes, Irina's Ashes, Karla's Ashes, Orbeck's Ashes, and Cornyx's Ashes`

* Update items_data.py

removed "hollows ashes"

* Update locations_data.py

remove "hollows ashes"

* Revert "WebHost: Add the DarkSouls3 entry to upload and download the client file"

This reverts commit 5e7c2d4cee.

* ds3: setup progressive locations

* ds3: Use fill_slot_data instead of generate_output

* ds3: Add no_spell_requirements, no_equip_load and death_link option

* ds3: Add some progressive locations

* DS3: Increment data_version

* DS3: Fix item name in rule

* DS3: Set required client version to 0.3.6 and added offsets between items and location tables for backward compatibility

* DS3: Resolve Python 3.8 compatibility

* DS3: Removed useless region for locations IDs consistency

* DS3: Changed i in loop

* DS3: Remove AP.json from the documentation

* DS3: Put back json upload and download

* DS3: Avoid empty downloads

* DS3: Fix randomize_weapons_level option

* DS3: Remove options duplicate entries

* DS3: Change location rule according to review

Co-authored-by: Br00ty <83629348+Br00ty@users.noreply.github.com>
2022-11-09 01:17:43 +01:00
toasterparty
70cab99caf OC2: Fix typo in description (#1214) 2022-11-07 19:50:30 +01:00
Fabian Dill
c1e97bcbff WebHost: allow setting a generation time limit (#1209)
* WebHost: allow setting a generation time limit.
2022-11-06 21:37:11 +01:00
shadow42085
e2eaafbf70 WebHost: Update Super Metroid Tracker Links (#1211)
varia randomizer site updated
2022-11-06 14:31:02 -06:00
NewSoupVi
66d594e95b The Witness: Add required client version (#1212)
Co-authored-by: metzner <unconfigured@null.spigotmc.org>
2022-11-06 14:26:56 -06:00
Fabian Dill
a9bf0008ba Factorio: add rocket-silo as required technology (#1207) 2022-11-06 14:24:03 -06:00
NewSoupVi
f2426ae603 The Witness: Logic Fix (#1210)
Co-authored-by: metzner <unconfigured@null.spigotmc.org>
2022-11-06 17:31:53 +01:00
Zach Parks
462ddce72c Templates: Remove auto wordwrap, fix manual indentation, and reformat docstrings for world options in main. (#1201)
* Fix wrapping too early if docstring is within 120 characters and re-indent other lines.

* Remove auto-word wrapping and tweaked all worlds' option docstrings formatting.

Options should wrap around the 120 character mark to prevent template files from being too long horizontally. This also allows manual-indentation to work again.

* Fix missing '#' on empty lines in output from docstring.
2022-11-06 08:28:16 -06:00
Alchav
d10bb3c6c1 [Pokémon Red and Blue] more improvements (#1208)
* Generated patch includes base patch

* location ID range start match item ID start

* remove unused import

* Change Oak's Aides defaults to be more sync-friendly
2022-11-06 09:07:41 +01:00
Fabian Dill
61232ca756 Factorio: set useless technologies to be researched from the start (#1205) 2022-11-05 14:01:02 -05:00
AkumaGath17
8f325a4f2b [Minecraft] AP Tracker (Items & Advancements) Update to 1.19 (#1168)
* Tracker hud test

* Added Relevant Items Icon

* Minecraft Tracker Update Test

* Minecraft Tracker Update

* Minecraft Tracker Missing Advancements

* Removed Enchanted Books

* Revert fix

* Added Relevant Books

* Tracker Update

* Minecraft Tracker Update (Saddle 3D Model to Icon)

Co-authored-by: AkumaDark17 <akumaspartanmc@hotmail.com>
2022-11-05 06:41:03 -07:00
NewSoupVi
d28738a918 The Witness: Logic Fix & Generation Fix (#1204)
* Logic fix

* static object modification instead of copy error fixed

Co-authored-by: metzner <unconfigured@null.spigotmc.org>
2022-11-04 23:10:29 -05:00
PoryGone
1f3d048462 DKC3: Fix Generation Hang from missed self.world reference (#1203)
* Baseline patching and logic for DKC3

* Client can send, but not yet receive

* Alpha Test Baseline

* Bug Fixes and Starting Lives Option

* Finish BBH, add world hints

* Add music shuffle

* Boomer Costs Text

* Stubbed in Collect behaviour

* Adjust Gyrocopter option

* Add Bonus Coin junk replacement and tracker support

* Delete bad logs

* Undo host.yaml change

* Refactored SNIClient

* Make Swanky Free

* Fix Typo

* Undo SNIClient run_game hack

* Fix Typo

* Remove Bosses from Level Shuffle

* Remove duplicate kivy Data

* Add DKC3 Docs and increment Data version

* Remove dead code

* Fix mislabeled region

* Add Dark Souls 3 to README

* Always force Cog on Rocket Rush Flag

* Fix Single Ski lock and too many DK Coins

* Update Retroarch version number

* Don't send DKC3 through LttP Adjuster

* Comment Location ROM Table

* Change ROM Hash prefix to D3

* Remove redundant constructor

* Add ROM Change Safeguards

* Properly mark WRAM accesses

* Remove outdated region connect

* Fix syntax error

* Fix Game description

* Fix SNES Bank Access

* Add isso_setup for DKC3

* Double Quote strings

* Escape single quotes I guess

* Add two locations to Trade Sequence List

* Remove trace sequence locations from ROM data dict

* Prevent Krematoa Crash, add crash robustness

* Remove print statements

* Don't remove ctx.rom if save file dies

* Consolidate logic for readability

* Add link to tracker on DKC3 Setup doc

* Remove false information from setup guide

* Fix file extension in setup doc

* Bug Fixes

* Add KONGsanity and cheat options

* First Pass Collect behavior

* Fix level unlock data

* Make ! only indicate KONG letters on KONGsanity

* Fix Level Name in locations

* Adjust junk pool logic

* Fix Knautilus Connections

* Fix Wrinkly Softlock

* Fix missed world reference
2022-11-04 21:40:31 -05:00
PoryGone
b161a5241f SMW: Add link to Tracker in setup guide (#1202) 2022-11-04 21:36:58 -05:00
Fabian Dill
208a0c6b08 WebHost: prevent infinite spinup loop of Rooms 2022-11-04 20:07:28 +01:00
Jarno
c3c1ce5827 Sudoku: Fixed setup link (#1200) 2022-11-04 20:03:39 +01:00
recklesscoder
889bc9d1b4 FactorioClient: Warn about Windows console input. 2022-11-04 18:00:22 +01:00
recklesscoder
165a38dd58 Factorio: Added commands for checking energy link from CLI and in game. 2022-11-04 17:59:05 +01:00
recklesscoder
88088dd054 CommonClient & SNIClient: Fixes for reconnecting (#1193)
* CommonClient & SNIClient: Fixes for reconnecting.
- CommonClient: Allow manual reconnect by typing /connect.
- CommonClient: Don't prompt to reconnect if there is nothing to reconnect to.
- CommonClient: Hide the connection loss modal popup when attempting to connect again.
- CommonClient & SNIClient: Cancel auto-reconnect tasks when the user intervenes.

* (Fix imports for linting.)
2022-11-04 17:57:58 +01:00
Doug Hoskisson
c933fa7e34 Core: optimize early items and add unit test (#1197)
* optimize early items and add unit test

* move sorting list init closer to sorting
2022-11-04 17:56:47 +01:00
alwaysintreble
f1123f2662 Core: more useful error for entrance without region (#1186) 2022-11-03 15:17:34 +01:00
Fabian Dill
0f034ddcf7 Factorio: make client use CommonClient location lookup for technologies 2022-11-03 14:19:47 +01:00
recklesscoder
7f3eda4623 Subnautica: Fix creature_scan_logic: removed allowing Cuddlefish. 2022-11-03 10:48:50 +01:00
alwaysintreble
2b0e7f05da Docs: fix broken link in contributing.md (#1185) 2022-11-02 22:02:06 -05:00
recklesscoder
e204deab02 Factorio: Fix saving on exit on Windows.
Fix detecting of server shutdown.
2022-11-03 00:17:26 +01:00
Magnemania
56afd62175 SC2: Bugfix, incorrectly adding to an array rather than appending (#1183) 2022-11-02 17:01:51 -05:00
Alchav
44204ac9be Pokemon Red/Blue: Fix location name in Pokémon Red and Blue rules (#1184) 2022-11-02 17:01:32 -05:00
espeon65536
6c3852a2a9 OoT: lock fast-filled shop locations (#1188)
ensures that progression balancing doesn't move them later
2022-11-02 17:01:03 -05:00
Jarno
124ae198e4 Sudoku: Updated Sudoku docs now we use standalone deployment (#1180) 2022-11-02 20:21:34 +01:00
alwaysintreble
030b767751 LttP: fix triforce piece same reference (#1179) 2022-11-02 20:08:38 +01:00
NewSoupVi
5ca724a454 The Witness: Logic fix
A broken seed was reported where Colored Squares was on a panel locked by Colored Squares. This PR fixes this problem.
2022-11-02 20:07:43 +01:00
Jarno
af3b752093 WebHost: Fixed warning ".gitignore dropped, as it has no valid sprite data." (#1174)
* Fixed warning ".gitignore dropped, as it has no valid sprite data." on webhost

* Changed to exclude files starting with .
2022-11-02 19:06:00 +01:00
alwaysintreble
c378933274 Overcooked 2: Revert OC2Level world references to be named correctly, optimize location scouting. (#1175)
* fix oc2 level world references

* optimize the local items discovery a bit
2022-11-02 16:11:46 +01:00
Doug Hoskisson
da392239a0 MultiServer and clients: async coroutine starter in Utils.py (#1143)
* async coroutine starter in Utils.py

* refactor from static class to function

* async_start docstring
2022-11-02 15:51:35 +01:00
espeon65536
a6e1e14fee Ocarina of Time: Itemlinks and bugfixes (#1157)
* OoT: ER improvements
Include dungeon rewards in itempool to allow for ER improvement
Better validate_world function by checking for multi-entrance incompatibility more efficiently
Fix some generation failures by ensuring all entrances placed with logic
Introduce bias to some interior entrance placement to improve generation rate

* OoT: fix overworld ER spoiler information

* OoT: rewrite dungeon item placement algorithm
in particular, no longer assumes that exactly the number of vanilla keys is present, which lets it place more or fewer items.

* OoT: auto-send more locations
Now should autosend cows, DMT/DMC great fairies, medigoron, and bombchu salesman
This should be every check autosending. these ones are super weird for some reason and didn't get fixed with the others

* OoT: add items forced local by settings to AP's local_items

* OoT: fast-fill shop junk items

* OoT: ensure that Kokiri Shop is always reachable immediately in closed forest
hence Deku Shield can be bought to leave the forest

* OoT: randomize internal connect name
Connect name is now a random 16-character string.
This should prevent any issues with connecting to a room with the wrong ROM with probability almost 1.

* OoT: introduce TrackRandomRange for trials hint and mq dungeon maps

* OoT: enable proper itemlinking of songs and dungeon items, with restricted placements according to player settings

* OoT: barren hint oversight fix

* OoT: allow NL + ER to roll properly

* OoT: 3.8 compatibility
set and list builtins don't have proper typing support until 3.9, apparently

* OoT: remove Gerudo Membership Card location from the pool if fortress open and card not randomized
another long-standing bug squished

* OoT: exclude locations in the itemlink song fill if they aren't also priority

* OoT: prevent data bleed when client isn't closed between different game connections
I don't understand why people keep doing this

* OoT: linter appeasement
it was a real error though

* fixing merge conflicts is hard

* oot merge update #2

* OoT: removed accidentally duplicated code
2022-11-02 09:32:08 +01:00
alwaysintreble
95378233fc Templates: Update template output and add min and max comments for named special_range options. (#1164)
* add min and max comments for named special_range options

* comment all special range options and dictate the min and max in comment block

* make it cleaner

* make it cleanerer

* make it cleanererer

* Reformat template for more consistent comments.

* Fixed missing note on some special settings.

* Small tweak to template.

* Update playerSettings.yaml to match auto-generated template with all ALTTP options.

* Fix edge case with `special_range_cutoff` and revert playerSettings.yaml.

Co-authored-by: Zach Parks <zach@alliware.com>
2022-11-01 18:07:56 -05:00
Fabian Dill
85130f2bbd WebHost: update modules (#1176)
Fix bokeh 3.0.0 attribute removal and touch up stats.py while at it
2022-11-01 17:01:53 -05:00
Ludovic Marechal
ab9f3767e2 DS3: Use slot_data instead of the external Json file (#1155)
* Update items_data.py

added `Red and White Round Shield`, `Crystal Scroll`, `Magic Stoneplate Ring`, and `Outrider Knight` gear.

* Update locations_data.py

Added `US: Red and White Round Shield`, `CKG: Magic Stoneplate Ring`, `GA: Outrider Knight` set, and `GA: Crystal Scroll`

* Update __init__.py

Add `Karla's Ashes` requirements

* Update items_data.py

Add `Irithyll Rapier, Hollow's Ashes, Irina's Ashes, Karla's Ashes, Cornyx's Ashes, and Orbeck's Ashes`

* Update locations_data.py

Add `Irithyll Rapier, Hollow's Ashes, Irina's Ashes, Karla's Ashes, Orbeck's Ashes, and Cornyx's Ashes`

* Update items_data.py

removed "hollows ashes"

* Update locations_data.py

remove "hollows ashes"

* Revert "WebHost: Add the DarkSouls3 entry to upload and download the client file"

This reverts commit 5e7c2d4cee.

* ds3: Use fill_slot_data instead of generate_output

* DS3: Increment data_version

* DS3: Fix item name in rule

* DS3: Set required client version to 0.3.6 and added offsets between items and location tables for backward compatibility

* DS3: Resolve Python 3.8 compatibility

* DS3: Removed useless region for locations IDs consistency

* DS3: Changed i in loop

* DS3: Remove AP.json from the documentation

* DS3: Put back json upload and download

* DS3: Avoid empty downloads

(cherry picked from commit c4c485140d)

Co-authored-by: Br00ty <83629348+Br00ty@users.noreply.github.com>
2022-11-01 22:58:08 +01:00
Zach Parks
bf142b32c9 Rogue Legacy: Rename world to multiworld in local variables and function signatures. (#1169) 2022-11-01 16:14:09 -05:00
lordlou
05c06a57af SMZ3: completion condition fix and more info in spoiler (#1133)
* first working (most of the time) progression generation for SM using VariaRandomizer's rules, items, locations and accessPoint (as regions)

* first working single-world randomized SM rom patches

* - SM now displays message when getting an item outside for someone else (fills ROM item table)

This is dependant on modifications done to sm_randomizer_rom project

* First working MultiWorld SM

* some missing things:

- player name inject in ROM and get in client
- end game get from ROM in client
- send self item to server
- add player names table in ROM

* replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better)

* - reenabled balancing

* post rebase fixes

* updated SmClient.py

* + added VariaRandomizer LICENSE

* + added sm_randomizer_rom project (which builds sm.ips)

* Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning

* properly revert change made to CollectionState and more cleaning

* Fixed multiworld support patch not working with VariaRandomizer's

* missing file commit

* Fixed syntax error in unused code to satisfy Linter

* Revert "Fixed multiworld support patch not working with VariaRandomizer's"

This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b.

* many fixes and improovement

- fixed seeded generation
- fixed broken logic when more than one SM world
- added missing rules for inter-area transitions
- added basic patch presence for logic
- added DoorManager init call to reflect present patches for logic
- moved CollectionState addition out of BaseClasses into SM world
- added condition to apply progitempool presorting only if SM world is present
- set Bosses item id to None to prevent them going into multidata
- now use get_game_players

* first working (most of the time) progression generation for SM using VariaRandomizer's rules, items, locations and accessPoint (as regions)

* first working single-world randomized SM rom patches

* - SM now displays message when getting an item outside for someone else (fills ROM item table)

This is dependant on modifications done to sm_randomizer_rom project

* First working MultiWorld SM

* some missing things:

- player name inject in ROM and get in client
- end game get from ROM in client
- send self item to server
- add player names table in ROM

* replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better)

* - reenabled balancing

* post rebase fixes

* updated SmClient.py

* + added VariaRandomizer LICENSE

* + added sm_randomizer_rom project (which builds sm.ips)

* Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning

* properly revert change made to CollectionState and more cleaning

* Fixed multiworld support patch not working with VariaRandomizer's

* missing file commit

* Fixed syntax error in unused code to satisfy Linter

* Revert "Fixed multiworld support patch not working with VariaRandomizer's"

This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b.

* many fixes and improovement

- fixed seeded generation
- fixed broken logic when more than one SM world
- added missing rules for inter-area transitions
- added basic patch presence for logic
- added DoorManager init call to reflect present patches for logic
- moved CollectionState addition out of BaseClasses into SM world
- added condition to apply progitempool presorting only if SM world is present
- set Bosses item id to None to prevent them going into multidata
- now use get_game_players

* Fixed multiworld support patch not working with VariaRandomizer's

Added stage_fill_hook to set morph first in progitempool
Added back VariaRandomizer's standard patches

* + added missing files from variaRandomizer project

* + added missing variaRandomizer files (custom sprites)

+ started integrating VariaRandomizer options (WIP)

* Some fixes for player and server name display

- fixed player name of 16 characters reading too far in SM client
- fixed 12 bytes SM player name limit (now 16)
- fixed server name not being displayed in SM when using server cheat ( now displays RECEIVED FROM ARCHIPELAGO)
- request: temporarly changed default seed names displayed in SM main menu to OWTCH

* Fixed Goal completion not triggering in smClient

* integrated VariaRandomizer's options into AP (WIP)

- startAP is working
- door rando is working
- skillset is working

* - fixed itemsounds.ips crash by always including nofanfare.ips into multiworld.ips (itemsounds is now always applied and "itemsounds" preset must always be "off")

* skillset are now instanced per player instead of being a singleton class

* RomPatches are now instanced per player instead of being a singleton class

* DoorManager is now instanced per player instead of being a singleton class

* - fixed the last bugs that prevented generation of >1 SM world

* fixed crash when no skillset preset is specified in randoPreset (default to "casual")

* maxDifficulty support and itemsounds removal

- added support for maxDifficulty
- removed itemsounds patch as its always applied from multiworld patch for now

* Fixed bad merge

* Post merge adaptation

* fixed player name length fix that got lost with the merge

* fixed generation with other game type than SM

* added default randoPreset json for SM in playerSettings.yaml

* fixed broken SM client following merge

* beautified json skillset presets

* Fixed ArchipelagoSmClient not building

* Fixed conflict between mutliworld patch and beam_doors_plms patch

- doorsColorsRando now working

* SM generation now outputs APBP

- Fixed paths for patches and presets when frozen

* added missing file and fixed multithreading issue

* temporarily set data_version = 0

* more work

- added support for AP starting items
- fixed client crash with gamemode being None
- patch.py "compatible_version" is now 3

* commited missing asm files

fixed start item reserve breaking game (was using bad write offset when patching)

* Nothing item are now handled game-side. the game will now skip displaying a message box for received Nothing item (but the client will still receive it).

fixed crash in SMClient when loosing connection to SNI

* fixed No Energy Item missing its ID

fixed Plando

* merge post fixes

* fixed start item Grapple, XRay and Reserve HUD, as well as graphic beams (except ice palette color)

* fixed freeze in blue brinstar caused by Varia's custom PLM not being filled with proper Multiworld PLM address (altLocsAddresses)

* fixed start item x-ray HUD display

* Fixed start items being sent by the server (is all handled in ROM)

Start items are now not removed from itempool anymore
Nothing Item is now local_items so no player will ever pickup Nothing. Doing so reduces contribution of this world to the Multiworld the more Nothing there is though.
Fixed crash (and possibly passing but broken) at generation where the static list of IPSPatches used by all SM worlds was being modified

* fixed settings that could be applied to any SM players

* fixed auth to server only using player name (now does as ALTTP to authenticate)

* - fixed End Credits broken text

* added non SM item name display

* added all supported SM options in playerSettings.yaml

* fixed locations needing a list of parent regions (now generate a region for each location with one-way exits to each (previously) parent region

did some cleaning (mainly reverts on unnecessary core classes

* minor setting fixes and tweaks

- merged Area and lightArea settings
- made missileQty, superQty and powerBombQty use value from 10 to 90 and divide value by float(10) when generating
- fixed inverted layoutPatch setting

* added option start_inventory_removes_from_pool

fixed option names formatting
fixed lint errors
small code and repo cleanup

* Hopefully fixed ROR2 that could not send any items

* - fixed missing required change to ROR2

* fixed 0 hp when respawning without having ever saved (start items were not updating the save checksum)

* fixed typo with doors_colors_rando

* fixed checksum

* added custom sprites for off-world items (progression or not)

the original AP sprite was made with PierRoulette's SM Item Sprite Utility by ijwu

* - added missing change following upstream merge

- changed patch filename extension from apbp to apm3 so patch can be used with the new client

* added morph placement options: early means local and sphere 1

* fixed failing unit tests

* - fixed broken custom_preset options

* - big cleanup to remove unnecessary or unsupported features

* - more cleanup

* - moved sm_randomizer_rom and all always applied patches into an external project that outputs basepatch.ips

- small cleanup

* - added comment to refer to project for generating basepatch.ips (https://github.com/lordlou/SMBasepatch)

* fixed g4_skip patch that can be not applied if hud is enabled

* - fixed off world sprite that can have broken graphics (restricted to use only first 2 palette)

* - updated basepatch to reflect g4_skip removal

- moved more asm files to SMBasepatch project

* - tourian grey doors at baby metroid are now always flashing (allowing to go back if needed)

* fixed wrong path if using built as exe

* - cleaned exposed maxDifficulty options

- removed always enabled Knows

* Merged LttPClient and SMClient into SNIClient

* added varia_custom Preset Option that fetch a preset (read from a new varia_custom_preset Option) from varia's web service

* small doc precision

* - added death_link support

- fixed broken Goal Completion
- post merge fix

* - removed now useless presets

* - fixed bad internal mapping with maxDiff

- increases maxDiff if only Bosses is preventing beating the game

* - added support for lowercase custom preset sections (knows, settings and controller)

- fixed controller settings not applying to ROM

* - fixed death loop when dying with Door rando, bomb or speed booster as starting items

- varia's backup save should now be usable (automatically enabled when doing door rando)

* -added docstring for generated yaml

* fixed bad merge

* fixed broken infinity max difficulty

* commented debug prints

* adjusted credits to mark progression speed and difficulty as Non Available

* added support for more than 255 players (will print Archipelago for higher player number)

* fixed missing cleanup

* added support for 65535 different player names in ROM

* fixed generations failing when only bosses are unreachable

* - replaced setting maxDiff to infinity with a bool only affecting boss logics if only bosses are left to finish

* fixed failling generations when using 'fun' settings

Accessibility checks are forced to 'items' if restricted locations are used by VARIA following usage of 'fun' settings

* fixed debug logger

* removed unsupported "suits_restriction" option

* fixed generations failing when only bosses are unreachable (using a less intrusive approach for AP)

* - fixed deathlink emptying reserves

- added death_link_survive option that lets player survive when receiving a deathlink if the have non-empty reserves

* - merged death_link and death_link_survive options

* fixed death_link

* added a fallback default starting location instead of failing generation if an invalid one was chosen

* added Nothing and NoEnergy as hint blacklist

added missing NoEnergy as local items and removed it from progression

* added missing completion_condition when TowerCrystals is lower than GanonCrystals

added Rewards and Medallions infos to spoiler

* Update worlds/smz3/__init__.py

Yes, indeed. Thank you!

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

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
Co-authored-by: Zach Parks <zach@alliware.com>
2022-11-01 14:52:04 -05:00
beauxq
0f7adaaf7b Zillion: LAGPL license 2022-11-01 20:40:57 +01:00
Doug Hoskisson
962e48c078 Zillion: fix unreproducible seeds (#1166)
* fix zillion unreproducible seeds

* world to multiworld merge
2022-11-01 14:45:17 +01:00
black-sliver
95ea0541e6 Core: improve fulfills_accessibility performance 2022-11-01 14:08:24 +01:00
black-sliver
0ed3baabd4 Core: add generic handling of excluded locations
Currently there can be locations that are marked as excluded,
but don't have rules to enforce it, while fill has special handling
for excluded locations already.

This change removes special rules, and adds a generic rule instead.
2022-11-01 14:08:24 +01:00
black-sliver
2db55ac50b Core: make fulfills_accessibility deterministic and fix some typing 2022-11-01 14:08:24 +01:00
black-sliver
bea8d37a3c SoE: fix false positives in early sphere collection (#1165) 2022-11-01 13:14:38 +01:00
Alchav
813015e007 [Pokemon] Fixes and updates (#1108)
* [Pokemon] Logic fixes

* [Pokemon] Fix seed name length

* [Pokemon] Location name changes

* [Pokemon] Hidden Item Nurse Bed logic fix

* Badges Needed description update

* Ensure player name does not exceed 16 bytes

* Player name check fix

* Remove unique items in start_inventory from item pool

* Vending Machine Drinks will not be created as filler

* Skip trainer text

* Badges needed for viridian gym text

* Add slot data for trackers

* free fly map in slot data and old_man = vanilla allows more free fly maps

* Re-add mistakenly removed slot data item

* Add tracker link to setup doc

* Doc fix

* Fix base patch

* Change pre_fill to generate_basic so items are pre-filled before item links

* Rename some hidden locations

* missing file from commit and revert one errant location name change
2022-11-01 07:02:15 +01:00
recklesscoder
c1d7abd06e CommonClient: Some GUI polish (#1139)
* CommonClient: Focus text field when requesting input.

* CommonClient: Store and prefill last server address.

* CommonClient: Focus and select portion of server address upon start.

* CommonClient: Don't allow editing of address while connected.

* CommonClient: Don't make pressing Enter in the address bar disconnect you.

* CommonClient: Use TextInput.text_validate_unfocus over jank workaround.

* CommonClient: Fixed hang when closing after failed handshake.

* CommonClient: Made scrollbar wider and interactable.
2022-11-01 06:54:40 +01:00
strotlog
655f287d42 SM: Fix unobtainable items in remote items+item links combo (#1151)
* SM: fix using item links together with remote items

* SM: write 0 index for excess player ids

* some style and minor fixes (strotlog/Archipelago#1)

* more typing in SM patching

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2022-11-01 06:42:11 +01:00
Alchav
802119502d Core: Fix early items bug with priority locations (#1167) 2022-10-31 22:26:55 -05:00
alwaysintreble
2af510328e Core: rename world to multiworld (#931)
* rename references to `Multiworld` in core to `multiworld` instead of `world`

* fix smz3

* fix oot

* fix low hanging fruit

* revert mysteriously broken spacing in world api.md

* fix more randomly broken spacing

* hate

* that better be all of it

* begrudgingly move over smw

* ._.

* missed some worlds

* this is getting tedious now

* Missed some self.world definitions

Co-authored-by: espeon65536 <espeon65536@gmail.com>
Co-authored-by: Zach Parks <zach@alliware.com>
2022-10-31 21:41:21 -05:00
black-sliver
87f4a97f1e Core: make player name case-insensitive 2022-10-30 17:12:00 +01:00
black-sliver
1bb99d391d Factorio: deterministic locations 2022-10-30 17:11:34 +01:00
Zach Parks
1cad51b1af Rogue Legacy: World folder clean up and generation improvements. (#1148)
* Minor cleanup and renaming of some files/functions.

* Rename `LegacyWorld` and `LegacyWeb` to RLWorld and RLWeb.

* Undo accidental change to comment.

* Undo accidental change to comment.

* Restructure Items.py format and combine all tables into one.

* Restructure Locations.py format and combine all tables into one.

* Split boss event items into separate boss entries.

* Remove definitions folder.

* Reformatted __init__.py for Rogue Legacy.

* Allow fairy chests to be disabled.

* Add working prefill logic for early vendors.

* Re-introduce Early Architect setting.

* Revamped rules and regions and can now generate games.

* Fix normal vendors breaking everything.

* Fix early vendor logic and add fairy chest logic to require Dragons or Enchantress + runes.

* Fix issue with duplicate items being created.

* Move event placement into __init__.py and fix duplicate Vendors.

* Tweak weights and spacing.

* Update documentation and include bug report link.

* Fix relative link for template file.

* Increase amount of chest locations in `location_table`.

* Correct a refactor rename gone wrong.

* Remove unused reference in imports.

* Tweak mistake in boss name in place_events.

* English is hard.

* Tweak some lines in __init__.py to use `.settings()` method.

* Add unique id tests for Rogue Legacy.

IDs are mixed around, so let's try to avoid accidentally using the same identifier twice.

* Fix typo in doc.

* Simplify `fill_slot_data`.

* Change prefix on `_place_events` to maintain convention.

* Remove items that are **not** progression from rules.
2022-10-29 22:15:06 -05:00
Doug Hoskisson
09d8c4b912 MultiServer: fix case sensitivity in server commands (#1156)
* fix case sensitivity in server commands

* improve ambiguous match breakout

* worried about accidentally swapping team and slot

* Remove now unused import
2022-10-30 00:41:07 +02:00
Br00ty
ed23a426ec DS3: fix shield item/location (#1158)
* fixed item id

fixed item id for "Blessed Red and White Shield"

* fixed location id

fixed location id for "US: Blessed Red and White Shield"
2022-10-29 19:06:56 +02:00
Ludovic Marechal
c711264d1a DS3: Added a few new items and locations (#1059)
* Update items_data.py

added `Red and White Round Shield`, `Crystal Scroll`, `Magic Stoneplate Ring`, and `Outrider Knight` gear.

* Update locations_data.py

Added `US: Red and White Round Shield`, `CKG: Magic Stoneplate Ring`, `GA: Outrider Knight` set, and `GA: Crystal Scroll`

* Update __init__.py

Add `Karla's Ashes` requirements

* Update items_data.py

Add `Irithyll Rapier, Hollow's Ashes, Irina's Ashes, Karla's Ashes, Cornyx's Ashes, and Orbeck's Ashes`

* Update locations_data.py

Add `Irithyll Rapier, Hollow's Ashes, Irina's Ashes, Karla's Ashes, Orbeck's Ashes, and Cornyx's Ashes`

* Update items_data.py

removed "hollows ashes"

* Update locations_data.py

remove "hollows ashes"

* DS3: Increment data_version

* DS3: Fix item name in rule

* DS3: Set required client version to 0.3.6 and added offsets between items and location tables for backward compatibility

* DS3: Resolve Python 3.8 compatibility

* DS3: Removed useless region for locations IDs consistency

* DS3: Changed i in loop

Co-authored-by: Br00ty <83629348+Br00ty@users.noreply.github.com>
2022-10-29 13:35:33 +02:00
black-sliver
3dfbbc5057 Doc: Clarify annotations in style guide (#1149)
* Doc: Clarify annotations in style guide

* Fix typo

* Update docs/style.md

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

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2022-10-28 23:02:23 +02:00
Doug Hoskisson
f298b8d6e7 Zillion: validate rescue item links (#1140) 2022-10-28 21:56:50 +02:00
Fabian Dill
53974d568b Factorio: revamped location system (#1147) 2022-10-28 21:00:06 +02:00
black-sliver
ec0389eefb CI: use build_exe in automated build 2022-10-28 20:59:06 +02:00
Ryan
0c54c47023 Docs: Add ArchipelagoRS to the Network Protocol docs (#1153) 2022-10-28 19:24:08 +02:00
CaitSith2
80db8a33af Don't leak info about what exists or not if player can't afford the hint (#1146) 2022-10-28 02:45:18 -07:00
AkumaGath17
e6c6b00109 Minecraft: Two by two logical requirement fix (#1152)
* [Minecraft] Two by two logical requirement fix

* Two by two update

* Two by Two logical fix [Description in order]

* Two by Two fix [Bucket only= False]

Along with the others isolated items checks
2022-10-28 10:34:46 +02:00
Alchav
813ea6ef8b [SM64] Separate Entrance Shuffle pools option and MIPS cost option improvement (#1137)
* Add separate pool option for entrance shuffle and swap MIPS costs if MIPS1Cost is greater

* Changes based on N00by's suggestions

* split into secret_entrance_ids and course_entrance_ids
2022-10-28 01:43:02 +02:00
Doug Hoskisson
c09e089f9d Docs: Zillion: RetroArch core and early_items recommendation. (#1150)
* add Genesis Plus GX to Zillion docs

* zillion early items recommendation
2022-10-28 01:35:18 +02:00
recklesscoder
cfff12d8d7 Factorio: Added ability to chat from within the game. (#1068)
* Factorio: Added ability to chat from within the game.
This also allows using commands such as !hint from within the game.

* Factorio: Only prepend player names to chat in multiplayer.

* Factorio: Mirror chat sent from the FactorioClient UI to the Factorio server.

* Factorio: Remove local coordinates from outgoing chat.

* Factorio: Added setting to disable bridging chat out.
Added client command to toggle this setting at run-time.

* Factorio: Added in-game command to toggle chat bridging setting at run-time.

* .

* Factorio: Document toggle for chat bridging feature.

* (Removed superfluous type annotations.)

* (Removed hard to read regex.)

* Docs/Factorio: Fix display of multiline code snippets.
2022-10-28 00:45:26 +02:00
recklesscoder
924f484be0 Factorio: Add optional filtering for item sends displayed in-game (#1142)
* Factorio: Added feature to filter item sends displayed in-game.

* Factorio: Document item send filter feature.

* Factorio: Fix item send filter for item links.

* (Removed superfluous type annotations.)

* CommonClient: Added is_uninteresting_item_send helper.
2022-10-28 00:07:57 +02:00
Doug Hoskisson
aeb78eaa10 Zillion: map tracker in client (#1136)
* Option RangeWithSpecialMax

* amendment to typing in web options

* compare string with number

* lots of work on zillion

* fix zillion fill logic

* fix a few more issues in zillion fill logic

* can make zillion patch and use it

* put multi items in zillion rom

* work on ZillionClient

* logging and auth in client

* work on sending and receiving items

* implement item_handling flag

* fix locations ids to NuktiServer package

* use rewrite of zri

* cache logic rule data for performance

* use new id maps

* fix some problems with the big recent merge

* ZillionClient: use new context manager for Memory class

* fix ItemClassification for Zillion items
and some debug statements for asserts,
documentation on running scripts for manual testing
type correction in CommonContext

* fix some issues in client, start on docs, put rescue and item ram addresses in slot data

* use new location name system
fix item locations getting out of sync in progression balancing

* zillion client can read slot name from game

* zillion: new item names

* remove extra unneeded import

* newer options (room gen and starting cards)

* update comment in zillion patch

* zillion non static regions

* change some logging, update some comments

* allow ZillionClient to exit in certain situations

* todo note to fix options doc strings

* don't force auto forfeit

* rework validation of floppy requirement and item counts
and fix race condition in generate_output

* reorganize Zillion component structure
with System class

* documentation updates for Zillion

* attempt inno_setup.iss

* remove todo comment for something done

* update comment

* rework item count zillion options
and some small cleanups

* fix location check count

* data package version 1

* Zillion can pass unit tests without rom

* fix freeze if closing ZillionClient while it's waiting for server login

* specify commit hash for zilliandomizer package

* some changes to options validation

* Zillion doors saved on multiworld server

* add missing function in inno_setup
and name of vanilla continues in options

* rework zillion sync task and context

* Apply documentation suggestions from SoldierofOrder

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* update zillion package

* workaround for asyncio udp bug

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 the user asks for it with /sms

* a few of the smaller suggestions from review

* logic only looks at my locations
instead of all the multiworld locations

* some adjustments from pull request discussion
and some unit tests

* patch webhost changes from pull request discussion

* zillion logic tests

* better vblr test

* test interaction of character rescue items with logic

* move unit tests to new worlds folder

* comment improvements

* fix minor logic issue
and add memory read timeout

* capitalization in option display names
Opa-Opa is a proper noun

* client toggle side panel with /map

* displays map

* fix map transparency

* fix broken launcher

* better way to specify grid container

* start kivy typing

* have a map that updates with item checks

but it breaks other parts of the UI

* fix layout bug

* aspect ratio of image
and some type checking details

* Fix loading of map for compiled builds

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>
Co-authored-by: Doug Hoskisson <doughoskisson@novuslabs.com>
Co-authored-by: CaitSith2 <d_good@caitsith2.com>
2022-10-27 02:30:22 -07:00
toasterparty
6134578c60 Overcooked! 2: slightly relax 3-star logic (#1144) 2022-10-27 11:19:48 +02:00
Fabian Dill
b57ca33c31 Logging: more digits for IDs and counts (#1141)
* Logging: we now need 9 digits for IDs

* Logging: we now need {dynamic} digits for IDs

* Logging: we now need {dynamic} digits for counts
2022-10-27 09:18:25 +02:00
Alchav
4b18920819 Early Items option (#1086)
* Early Items option

* Early Items description update

* Change Early Items to dict

* Rewrite early items as extra fill steps

* Move if statement up

* Document early_items

* Move early_items handling before fill_hook

* Apply suggestions from code review

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

* Subnautica pre-fill moved to early_items

* Subnautica early items fix

* Remove unit test bug workaround

* beauxq's pr

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2022-10-27 09:00:24 +02:00
Magnemania
700fe8b75e SC2: New Settings, Logic improvements (#1110)
* Switched mission item group to a list comprehension to fix missile shuffle errors

* Logic for reducing mission and item counts

* SC2: Piercing the Shroud/Maw of the Void requirements now DRY

* SC2: Logic for All-In, may need further refinement

* SC2: Additional mission orders and starting locations

* SC2: New Mission Order options for shorter campaigns and smaller item pools

* Using location table for hardcoded starter unit

* SC2: Options to curate random item pool and control early unit placement

* SC2: Proper All-In logic

* SC2: Grid, Mini Grid and Blitz mission orders

* SC2: Required Tactics and Unit Upgrade options, better connected item handling

* SC2: Client compatibility with Grid settings

* SC2: Mission rando now uses world random

* SC2: Alternate final missions, new logic, fixes

* SC2: Handling alternate final missions, identifying final mission on client

* SC2: Minor changes to handle edge-case generation failures

* SC2: Removed invalid type hints for Python 3.8

* Revert "SC2: Removed invalid type hints for Python 3.8"

This reverts commit 7851b9f7a3.

* SC2: Removed invalid type hints for Python 3.8

* SC2: Removed invalid type hints for Python 3.8

* SC2: Removed invalid type hints for Python 3.8

* SC2: Removed invalid type hints for Python 3.8

* SC2: Changed location loop to enumerate

* SC2: Passing category names through slot data

* SC2: Cleaned up unnecessary _create_items method

* SC2: Removed vestigial extra_locations field from MissionInfo

* SC2: Client backwards compatibility

* SC2: Fixed item generation issue where item is present in both locked and unlocked inventories

* SC2: Removed Missile Turret from defense rating on maps without air

* SC2: No logic locations point to same access rule

Co-authored-by: michaelasantiago <michael.alec.santiago@gmail.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2022-10-26 12:24:54 +02:00
PoryGone
d5efc71344 Core: SNI Client Refactor (#1083)
* First Pass removal of game-specific code

* SMW, DKC3, and SM hooked into AutoClient

* All SNES autoclients functional

* Fix ALttP Deathlink

* Don't default to being ALttP, and properly error check ctx.game

* Adjust variable naming

* In response to:
> we should probably document usage somewhere. I'm open to suggestions of where this should be documented.

I think the most valuable documentation for APIs is docstrings and full typing.

about websockets change in imports - from websockets documentation:
> For convenience, many public APIs can be imported from the websockets package. However, this feature is incompatible with static code analysis. It breaks autocompletion in an IDE or type checking with mypy. If you’re using such tools, use the real import paths.

* todo note for python 3.11
typing.NotRequired

* missed staging in previous commit

* added missing death Game States for DeathLink

Co-authored-by: beauxq <beauxq@users.noreply.github.com>
Co-authored-by: lordlou <87331798+lordlou@users.noreply.github.com>
2022-10-25 19:54:43 +02:00
Fabian Dill
6535836e5c Subnautica: don't override plando during pre_fill 2022-10-25 01:35:01 +02:00
lordlou
89d1a80e01 SM: morph first in pool remove (#1134)
* first working (most of the time) progression generation for SM using VariaRandomizer's rules, items, locations and accessPoint (as regions)

* first working single-world randomized SM rom patches

* - SM now displays message when getting an item outside for someone else (fills ROM item table)

This is dependant on modifications done to sm_randomizer_rom project

* First working MultiWorld SM

* some missing things:

- player name inject in ROM and get in client
- end game get from ROM in client
- send self item to server
- add player names table in ROM

* replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better)

* - reenabled balancing

* post rebase fixes

* updated SmClient.py

* + added VariaRandomizer LICENSE

* + added sm_randomizer_rom project (which builds sm.ips)

* Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning

* properly revert change made to CollectionState and more cleaning

* Fixed multiworld support patch not working with VariaRandomizer's

* missing file commit

* Fixed syntax error in unused code to satisfy Linter

* Revert "Fixed multiworld support patch not working with VariaRandomizer's"

This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b.

* many fixes and improovement

- fixed seeded generation
- fixed broken logic when more than one SM world
- added missing rules for inter-area transitions
- added basic patch presence for logic
- added DoorManager init call to reflect present patches for logic
- moved CollectionState addition out of BaseClasses into SM world
- added condition to apply progitempool presorting only if SM world is present
- set Bosses item id to None to prevent them going into multidata
- now use get_game_players

* first working (most of the time) progression generation for SM using VariaRandomizer's rules, items, locations and accessPoint (as regions)

* first working single-world randomized SM rom patches

* - SM now displays message when getting an item outside for someone else (fills ROM item table)

This is dependant on modifications done to sm_randomizer_rom project

* First working MultiWorld SM

* some missing things:

- player name inject in ROM and get in client
- end game get from ROM in client
- send self item to server
- add player names table in ROM

* replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better)

* - reenabled balancing

* post rebase fixes

* updated SmClient.py

* + added VariaRandomizer LICENSE

* + added sm_randomizer_rom project (which builds sm.ips)

* Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning

* properly revert change made to CollectionState and more cleaning

* Fixed multiworld support patch not working with VariaRandomizer's

* missing file commit

* Fixed syntax error in unused code to satisfy Linter

* Revert "Fixed multiworld support patch not working with VariaRandomizer's"

This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b.

* many fixes and improovement

- fixed seeded generation
- fixed broken logic when more than one SM world
- added missing rules for inter-area transitions
- added basic patch presence for logic
- added DoorManager init call to reflect present patches for logic
- moved CollectionState addition out of BaseClasses into SM world
- added condition to apply progitempool presorting only if SM world is present
- set Bosses item id to None to prevent them going into multidata
- now use get_game_players

* Fixed multiworld support patch not working with VariaRandomizer's

Added stage_fill_hook to set morph first in progitempool
Added back VariaRandomizer's standard patches

* + added missing files from variaRandomizer project

* + added missing variaRandomizer files (custom sprites)

+ started integrating VariaRandomizer options (WIP)

* Some fixes for player and server name display

- fixed player name of 16 characters reading too far in SM client
- fixed 12 bytes SM player name limit (now 16)
- fixed server name not being displayed in SM when using server cheat ( now displays RECEIVED FROM ARCHIPELAGO)
- request: temporarly changed default seed names displayed in SM main menu to OWTCH

* Fixed Goal completion not triggering in smClient

* integrated VariaRandomizer's options into AP (WIP)

- startAP is working
- door rando is working
- skillset is working

* - fixed itemsounds.ips crash by always including nofanfare.ips into multiworld.ips (itemsounds is now always applied and "itemsounds" preset must always be "off")

* skillset are now instanced per player instead of being a singleton class

* RomPatches are now instanced per player instead of being a singleton class

* DoorManager is now instanced per player instead of being a singleton class

* - fixed the last bugs that prevented generation of >1 SM world

* fixed crash when no skillset preset is specified in randoPreset (default to "casual")

* maxDifficulty support and itemsounds removal

- added support for maxDifficulty
- removed itemsounds patch as its always applied from multiworld patch for now

* Fixed bad merge

* Post merge adaptation

* fixed player name length fix that got lost with the merge

* fixed generation with other game type than SM

* added default randoPreset json for SM in playerSettings.yaml

* fixed broken SM client following merge

* beautified json skillset presets

* Fixed ArchipelagoSmClient not building

* Fixed conflict between mutliworld patch and beam_doors_plms patch

- doorsColorsRando now working

* SM generation now outputs APBP

- Fixed paths for patches and presets when frozen

* added missing file and fixed multithreading issue

* temporarily set data_version = 0

* more work

- added support for AP starting items
- fixed client crash with gamemode being None
- patch.py "compatible_version" is now 3

* commited missing asm files

fixed start item reserve breaking game (was using bad write offset when patching)

* Nothing item are now handled game-side. the game will now skip displaying a message box for received Nothing item (but the client will still receive it).

fixed crash in SMClient when loosing connection to SNI

* fixed No Energy Item missing its ID

fixed Plando

* merge post fixes

* fixed start item Grapple, XRay and Reserve HUD, as well as graphic beams (except ice palette color)

* fixed freeze in blue brinstar caused by Varia's custom PLM not being filled with proper Multiworld PLM address (altLocsAddresses)

* fixed start item x-ray HUD display

* Fixed start items being sent by the server (is all handled in ROM)

Start items are now not removed from itempool anymore
Nothing Item is now local_items so no player will ever pickup Nothing. Doing so reduces contribution of this world to the Multiworld the more Nothing there is though.
Fixed crash (and possibly passing but broken) at generation where the static list of IPSPatches used by all SM worlds was being modified

* fixed settings that could be applied to any SM players

* fixed auth to server only using player name (now does as ALTTP to authenticate)

* - fixed End Credits broken text

* added non SM item name display

* added all supported SM options in playerSettings.yaml

* fixed locations needing a list of parent regions (now generate a region for each location with one-way exits to each (previously) parent region

did some cleaning (mainly reverts on unnecessary core classes

* minor setting fixes and tweaks

- merged Area and lightArea settings
- made missileQty, superQty and powerBombQty use value from 10 to 90 and divide value by float(10) when generating
- fixed inverted layoutPatch setting

* added option start_inventory_removes_from_pool

fixed option names formatting
fixed lint errors
small code and repo cleanup

* Hopefully fixed ROR2 that could not send any items

* - fixed missing required change to ROR2

* fixed 0 hp when respawning without having ever saved (start items were not updating the save checksum)

* fixed typo with doors_colors_rando

* fixed checksum

* added custom sprites for off-world items (progression or not)

the original AP sprite was made with PierRoulette's SM Item Sprite Utility by ijwu

* - added missing change following upstream merge

- changed patch filename extension from apbp to apm3 so patch can be used with the new client

* added morph placement options: early means local and sphere 1

* fixed failing unit tests

* - fixed broken custom_preset options

* - big cleanup to remove unnecessary or unsupported features

* - more cleanup

* - moved sm_randomizer_rom and all always applied patches into an external project that outputs basepatch.ips

- small cleanup

* - added comment to refer to project for generating basepatch.ips (https://github.com/lordlou/SMBasepatch)

* fixed g4_skip patch that can be not applied if hud is enabled

* - fixed off world sprite that can have broken graphics (restricted to use only first 2 palette)

* - updated basepatch to reflect g4_skip removal

- moved more asm files to SMBasepatch project

* - tourian grey doors at baby metroid are now always flashing (allowing to go back if needed)

* fixed wrong path if using built as exe

* - cleaned exposed maxDifficulty options

- removed always enabled Knows

* Merged LttPClient and SMClient into SNIClient

* added varia_custom Preset Option that fetch a preset (read from a new varia_custom_preset Option) from varia's web service

* small doc precision

* - added death_link support

- fixed broken Goal Completion
- post merge fix

* - removed now useless presets

* - fixed bad internal mapping with maxDiff

- increases maxDiff if only Bosses is preventing beating the game

* - added support for lowercase custom preset sections (knows, settings and controller)

- fixed controller settings not applying to ROM

* - fixed death loop when dying with Door rando, bomb or speed booster as starting items

- varia's backup save should now be usable (automatically enabled when doing door rando)

* -added docstring for generated yaml

* fixed bad merge

* fixed broken infinity max difficulty

* commented debug prints

* adjusted credits to mark progression speed and difficulty as Non Available

* added support for more than 255 players (will print Archipelago for higher player number)

* fixed missing cleanup

* added support for 65535 different player names in ROM

* fixed generations failing when only bosses are unreachable

* - replaced setting maxDiff to infinity with a bool only affecting boss logics if only bosses are left to finish

* fixed failling generations when using 'fun' settings

Accessibility checks are forced to 'items' if restricted locations are used by VARIA following usage of 'fun' settings

* fixed debug logger

* removed unsupported "suits_restriction" option

* fixed generations failing when only bosses are unreachable (using a less intrusive approach for AP)

* - fixed deathlink emptying reserves

- added death_link_survive option that lets player survive when receiving a deathlink if the have non-empty reserves

* - merged death_link and death_link_survive options

* fixed death_link

* added a fallback default starting location instead of failing generation if an invalid one was chosen

* added Nothing and NoEnergy as hint blacklist

added missing NoEnergy as local items and removed it from progression

* removed now unecessary sorting of Morph balls at end of item pool

Its messing with priority locations feature.
2022-10-24 10:28:08 +02:00
beauxq
ad445629bd Zillion: fix unit tests
previous fix was incorrect
2022-10-24 01:28:06 +02:00
Doug Hoskisson
37c5865c0e Core: Options: fix shared default instances (#1130) 2022-10-23 18:28:09 +02:00
Doug Hoskisson
52726139b4 Zillion: support unicode player names (#1131)
* work on unicode and seed verification

* update zilliandomizer

* fix log message
2022-10-23 18:18:05 +02:00
Doug Hoskisson
24105ac249 Tests: fix random failures on Zillion tests (#1128)
* tests: fix random failures on Zillion tests

Normally there's a low probably that the game doesn't require a power-up that it usually requires.
This makes sure it always has that requirement for tests.

* better type narrowing
2022-10-22 03:42:16 +02:00
Alchav
f18df4c1df [Core] Fix priority location handling in accessibility corrections (#1121)
* Fix priority location handling in accessibility corrections

* Don't lock empty locations

* black sliver's suggested change

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

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-10-22 03:29:20 +02:00
black-sliver
04b6c31076 SoE: update to v042 and balancing changes (#1125)
* SoE: rebalancing and cleanup

* ModuleUpdate: make url detection more generic

* SoE: change item rules to depend on target player difficulty

* SoE: Update to pyevermizer 0.41.0

* adds footknight
* adds location difficulty

* SoE: minor optimization in item rule

if .. in is faster with sets

* SoE: drop support of patch format v3

* SoE: fix some typing and warnings

* SoE: cleanup imports
2022-10-21 23:26:40 +02:00
Fabian Dill
40c3ef35c7 LttP: fix Inverted Big Bomb Shop indirect connection rule 2022-10-21 23:25:52 +02:00
Fabian Dill
28483a6c14 Generate: don't try to include meta or filler weights file as player 2022-10-21 23:25:26 +02:00
ScootyPuffJr1
fa077defe0 [Factorio] Minor fix for typo on setup doc 2022-10-21 08:58:12 +02:00
Chris Wilson
47b4e2782b WebHost: Fix weighted-settings to not save full set of range options to localStorage (#1100) 2022-10-20 20:10:38 -05:00
Doug Hoskisson
265ee7098a New Game: Zillion (#1081)
* Option RangeWithSpecialMax

* amendment to typing in web options

* compare string with number

* lots of work on zillion

* fix zillion fill logic

* fix a few more issues in zillion fill logic

* can make zillion patch and use it

* put multi items in zillion rom

* work on ZillionClient

* logging and auth in client

* work on sending and receiving items

* implement item_handling flag

* fix locations ids to NuktiServer package

* use rewrite of zri

* cache logic rule data for performance

* use new id maps

* fix some problems with the big recent merge

* ZillionClient: use new context manager for Memory class

* fix ItemClassification for Zillion items
and some debug statements for asserts,
documentation on running scripts for manual testing
type correction in CommonContext

* fix some issues in client, start on docs, put rescue and item ram addresses in slot data

* use new location name system
fix item locations getting out of sync in progression balancing

* zillion client can read slot name from game

* zillion: new item names

* remove extra unneeded import

* newer options (room gen and starting cards)

* update comment in zillion patch

* zillion non static regions

* change some logging, update some comments

* allow ZillionClient to exit in certain situations

* todo note to fix options doc strings

* don't force auto forfeit

* rework validation of floppy requirement and item counts
and fix race condition in generate_output

* reorganize Zillion component structure
with System class

* documentation updates for Zillion

* attempt inno_setup.iss

* remove todo comment for something done

* update comment

* rework item count zillion options
and some small cleanups

* fix location check count

* data package version 1

* Zillion can pass unit tests without rom

* fix freeze if closing ZillionClient while it's waiting for server login

* specify commit hash for zilliandomizer package

* some changes to options validation

* Zillion doors saved on multiworld server

* add missing function in inno_setup
and name of vanilla continues in options

* rework zillion sync task and context

* Apply documentation suggestions from SoldierofOrder

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* update zillion package

* workaround for asyncio udp bug

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 the user asks for it with /sms

* a few of the smaller suggestions from review

* logic only looks at my locations
instead of all the multiworld locations

* some adjustments from pull request discussion
and some unit tests

* patch webhost changes from pull request discussion

* zillion logic tests

* better vblr test

* test interaction of character rescue items with logic

* move unit tests to new worlds folder

* comment improvements

* fix minor logic issue
and add memory read timeout

* capitalization in option display names
Opa-Opa is a proper noun

* redirect zz stdout to debug

* fix option validation bug making unbeatable seeds

* remove line that does nothing

* attach logic cache to world

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>
Co-authored-by: Doug Hoskisson <doughoskisson@novuslabs.com>
2022-10-20 19:41:11 +02:00
black-sliver
ed76c13961 Core: assert that items have a single reference (#1075)
* Core: assert that items have a single reference

* Fix duplicate item reference in The Witness

* Ori: fix duplicate item references

* DKC3: fix duplicate item references

* RL: fix duplicate item references

* SA2B: fix duplicate item references

* SMW: fix duplicate item references

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2022-10-20 10:42:33 +02:00
NewSoupVi
40b7e78178 The Witness: More Puzzle Skips, Softlock Fix (#1117)
* Softlock fix: 'Swamp Maze Controls' item also locks maze control other side now

* Increase default & max amount of Puzzle Skips (in response to client side changes to how they work)

* Logically require shortbox lasers to start the elevator
2022-10-20 05:05:36 +02:00
Sunny Bat
1900d9382a Raft: Update rules to account for navigation (#1118) 2022-10-19 08:47:33 +02:00
Doug Hoskisson
f12b73f487 Tests: world test base class (#1116)
* world test base class

* game not instance property

* I would have guessed that this only collected 1.

* game property

* move SoE tests into worlds

* don't force auto world setup
2022-10-18 18:54:41 +02:00
black-sliver
49ae79e5ce Tests: add fill tests for #1109 and #1114 (#1115)
* Test: for fill issues fixed in PR #1109

* Test: for double sweep collect fixed in PR #1114
2022-10-18 10:20:57 +02:00
Fabian Dill
4da6a0bb98 Fill: fix duplicate event pickups 2022-10-18 08:59:51 +02:00
Fabian Dill
af0cfc5a38 Fill: Priority locks when placing and does not swap. (#1099)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-10-18 01:07:06 +02:00
Fabian Dill
1aa3e431c8 LttP: fix ganons tower trash fill deleting items that did not fit (#1113) 2022-10-17 09:52:34 +02:00
Fabian Dill
b533ffb9e8 Locality: rewrite for linear memory consumption, from quadratic (#1091) 2022-10-17 03:22:02 +02:00
Fabian Dill
bb46ee7fc1 WebHost: optimize imports 2022-10-17 01:24:02 +02:00
black-sliver
acf7fda26a Main: Fill: more opportunities for swapping 2022-10-17 00:45:51 +02:00
Alchav
f7fc6fa7aa Core: Fix forbid_important_item_rule with parenthesis (#1107) 2022-10-16 07:32:17 +02:00
Alchav
5e97463bdc [Pokemon R/B] Fix inno_setup mistake (#1105) 2022-10-16 02:53:07 +02:00
Doug Hoskisson
ca9c3d05d6 Docs: information on Retrieved packet (#1101) 2022-10-15 13:44:39 +02:00
espeon65536
51f65f4b9e OoT: ER algorithm improvements (#1103)
* OoT: ER improvements
Include dungeon rewards in itempool to allow for ER improvement
Better validate_world function by checking for multi-entrance incompatibility more efficiently
Fix some generation failures by ensuring all entrances placed with logic
Introduce bias to some interior entrance placement to improve generation rate

* OoT: fix overworld ER spoiler information

* OoT: rewrite dungeon item placement algorithm
in particular, no longer assumes that exactly the number of vanilla keys is present, which lets it place more or fewer items.
2022-10-15 12:39:04 +02:00
recklesscoder
1f01404ca4 WebHost: Fixed scrolling to anchors (#1085)
* WebHost: Modernized anchor code

* WebHost: Fixed scrolling to anchors

* WebHost: Fixed scrolling to anchors when fonts are being loaded

* WebHost: Anchor PR changes requested by LegendaryLinux
2022-10-14 17:09:17 -04:00
Fabian Dill
bbb6ee89cf Hylics 2: add to readme (#1094) 2022-10-14 22:53:49 +02:00
Fabian Dill
0aea1e780f Fill: create minimal excluded location rule only once 2022-10-14 22:53:16 +02:00
Fabian Dill
722b3c5369 Core: make add_rule set if it finds an empty rule (#1093)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-10-14 22:52:45 +02:00
black-sliver
097ac189e4 SoE: add tests ... (#1097)
* SoE: add tests ...

... for goals, bronze axe and bronze spear+

* SoE: fix tests
2022-10-14 19:35:53 +02:00
toasterparty
7f3f886e41 Overcooked! 2: Implementation (#1046)
Overcooked! 2 is a couch co-op arcade game with a very high skill ceiling. It has a small but occult following, and the community craves a reason to keep coming back besides just grinding high scores. as such, this PR represents 3 major milestones in one:

 * The launch of OC2 Modding, a modding framework which is the first public mod for the game beyond simple RAM trainers
 * The launch of OC2 Randomizer
 * The integration of OC2 Randomizer in Archipelago
2022-10-13 19:57:50 +02:00
Alchav
3bd4ef3f3d [Pokémon R/B] Fixes (#1096)
* Prevent legendaries from being shuffled into restless soul encounter

* Prevent Poke Tower 6F wild mons from being same as restless soul

* fix non-deterministic generation
2022-10-13 19:55:21 +02:00
Yussur Mustafa Oraji
6b9073acd7 sm64ex: Update min client version 2022-10-13 11:36:04 +02:00
Jarno
e708bea819 [Sudoku] Added new BK mode game (#910)
Co-authored-by: Hussein Farran <hmfarran@gmail.com>
Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>
2022-10-13 07:55:00 +02:00
Trevor L
b014ce082b Hylics 2: Implement new game (#1058) 2022-10-13 07:51:25 +02:00
Alchav
30a4bcbbbe [Pokemon Red and Blue] Initial implementation (#1016) 2022-10-13 07:45:52 +02:00
Alchav
0afb7096de Core: improve sweep_for_events efficiency (#1092) 2022-10-13 05:46:07 +02:00
alwaysintreble
f909576813 core: Generic boss plando handler (#1044)
* fix some blunders i made when implementing this

* move generic functions to core class

* move lttp specific stuff out and split up from_text a bit for more modularity

* slightly optimize from_text call order

* don't make changes on github apparently. reading hard

* Metaclass Magic

* do a check against the base class

* copy paste strikes again

* use option default instead of hardcoded "none". check locations and bosses aren't reused.

* throw dupe location error for lttp

* generic singularity support with a bool

* forgot to enable it for lttp

* better error handling

* PlandoBosses: fix inheritance of singularity

* Tests: PlandoBosses

* fix case insensitive tests

* Tests: cleanup PlandoBosses tests

* f in the chat

* oop

* split location into a different variable

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

* pass the list of options as `option_list`

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

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-10-12 20:28:32 +02:00
lordlou
9f684b3dc0 SMZ3: Shield Upgrade typo fix (#1088)
fixed "Shield Uprade" typo

See lordlou/alttp_sm_combo_randomizer_rom@3da5313
2022-10-11 08:38:00 +02:00
recklesscoder
37a40499fa Docs/sm64ex: address common questions
- Added bolded note that one must use a new file with each new seed
- Added troubleshooting section for when one didn't use a new file
- Worded compilation step 8 more explicitly
- Added link to compilation options list
- Added link to TextClient for using commands
2022-10-10 09:14:13 +02:00
recklesscoder
099c4fca3c Docs: Polished Trigger and Plando guides (#1080)
* Docs: Polished Trigger and Plando guides

* Docs: Trigger/Plando guide polish PR suggestions by SoldierofOrder

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* Docs: More Trigger/Plando guide polish

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>
2022-10-10 09:10:01 +02:00
Joethepic
106d630ad7 HK: changes dreamers to skip prog balance (#1077) 2022-10-10 02:26:33 +02:00
alwaysintreble
4c0c93b083 core: allow string defaults in yaml templates (#1051)
* allow string defaults in yaml templates

* have default_converter handle strings

* handle all default values in the yaml

* allow for random range options

* yaml dump dicts

* strip the whities

* rip out the converter

* accidentally stripped the dicts

* goodbye readability
2022-10-10 00:16:59 +02:00
Doug Hoskisson
3cbbf905d1 Docs: how to run web host and generate template yamls (#1071) 2022-10-09 04:20:01 +02:00
SoldierofOrder
414ebf2640 SC2: Add an automated installation process for the maps and mod within SC2Client. (#928) 2022-10-09 04:19:17 +02:00
NewSoupVi
3297be7902 The Witness: Expert & Hints (#1072) 2022-10-09 04:13:52 +02:00
recklesscoder
7b3ef012b9 Factorio: Prevent pipes from breaking on invalid UTF-8 in client (#1078) 2022-10-09 04:10:22 +02:00
black-sliver
af6a72c3c3 AppImage: provide LD_LIBRARY_PATH
this fixes libssl1.1 not being found
2022-10-07 22:24:14 +02:00
recklesscoder
38b7bdfe60 WebHost: Fixed some document titles (#1063) 2022-10-06 18:01:07 -04:00
Gertimoshka
4c266e6eff hostRoom.css Changes (#957)
* hostRoom.css Changes

Makes the console be a scrollable object, for easier use with commands

* Update hostRoom.css

* Requested Change

Requested Change
2022-10-06 17:53:20 -04:00
Fabian Dill
8a6c9ff4b8 WebHost: clear yaml template folder before populating it 2022-10-03 08:48:53 +02:00
Doug Hoskisson
fdd7ffb089 Core: move output file name logic into core (#1066)
* move output file name logic into core

I see the same logic with small variations in each different world implementation.
It seems to me, it would be better in the core to keep it consistent.

* missed a few

* remove review comment

* + smw

* double quote strings

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

* revert change to DS3 output file name

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-10-02 16:53:18 +02:00
black-sliver
b8e467fbb8 ModuleUpdate: skip disabled/hidden folders (#1070)
* ModuleUpdate: skip non-worlds

* ModuleUpdate: don't skip _* folders

- _* folders may be used for libraries
- this means to properly disable a world, it has to be renamed with a preceding `.`
2022-10-01 17:38:39 +02:00
Fabian Dill
411cd51a92 SC2: dynamically create Beat <mission_name> Events, preventing copy-paste errors (#1023) 2022-10-01 16:22:24 +02:00
Fabian Dill
e9e15e854d SC2: make apworld compatible (#1024)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-10-01 15:24:05 +02:00
black-sliver
4943d26160 APWorld: make it behave more like a regular world
- set sys.modules so it can be imported with worlds.*
- overwrite __package__ so it can reference ..generic
- fix deprecation warning
2022-10-01 11:51:42 +02:00
Fabian Dill
060a04700d Core: allow generic access to indirect_connections (#1056) 2022-09-30 04:58:19 +02:00
Fabian Dill
61e39f355d Core remove legacy patch (#1047)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-09-30 00:36:30 +02:00
Yussur Mustafa Oraji
8ab0b410c3 sm64ex: Document new connection status notifications 2022-09-29 23:56:46 +02:00
Fabian Dill
d897aaade2 Docs: Ensure Discord links are permanent. (#1064) 2022-09-29 23:15:12 +02:00
black-sliver
0191df88d7 Doc: network protocol: clarify want_reply 2022-09-29 21:15:34 +02:00
recklesscoder
bee1fd9b5a Subnautica: Updated Setup Guide (#1062)
- Added sections for console commands and known issues.
- Updated "Resuming" section to reflect current functionality.
- Removed implication that one might have to create the QMods folder. (If it's missing, then you've already messed up step 1.)
- Renamed "Connect Menu" to "connect form" to be less confusing. Generally fixed word capitalization to conform to standard English. Minor wording changes.
2022-09-29 21:04:04 +02:00
Jarno
dd7d3a02a4 WebHost: Fixed Oculus Ring from showing up on tracker (#1065) 2022-09-29 20:18:21 +02:00
PoryGone
13edfa60be Super Mario World: Implement New Game (#1045) 2022-09-29 20:16:59 +02:00
Alchav
885c8d3fcc Fix minimal accessibility failures (#726) 2022-09-29 19:59:57 +02:00
black-sliver
e6a4925f0c Doc: update apclientpp to header-only (#1054) 2022-09-29 00:09:04 +02:00
Doug Hoskisson
c96b6d7b95 Core: some typing and docs in various parts of the interface (#1060)
* some typing and docs in various parts of the interface

* fix whitespace in docstring

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

* suggested changes from discussion

* remove redundant import

* adjust type for json messages

* for options module detection:
 module.lower().endswith("options")

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-09-28 23:54:10 +02:00
alwaysintreble
8bc8b412a3 Core: fix unweighted options for meta files (#1053) 2022-09-28 23:02:42 +02:00
PoryGone
b4b9ff5d82 Docs: Update snes9x Links (#1048) 2022-09-27 13:26:33 +02:00
black-sliver
b21b5cceb8 Doc, SoE: Logic mixin: no underscore for public members (#1049)
* Doc: logic mixin, drop underscore, clarify

conventionally, we added a leading underscore to logic mixins' function
names. This is noisy in the warning section of IDEs. Leading underscores
should only be used for private/protected functions.

In addition, the use of self.world and/or requirement to (no) pass in stuff
was not made clear earlier.

* SoE: fix _ warnings for logic mixin
2022-09-25 18:00:22 +02:00
CaitSith2
813ee5ee3b Factorio: Add explicit support for factory-levels mod. (#1050)
* Factorio: Add explicit support for factory-levels mod.

* Fix inconsistent space/tabs
2022-09-24 02:43:00 -07:00
Fabian Dill
be1158ad78 Windows: update VC Redistributable to 14.32.31332 from 14.29.30037 2022-09-22 08:46:48 +02:00
black-sliver
6d5ddf3cad MultiServer: allow using IDs for hints 2022-09-20 18:38:31 +02:00
black-sliver
809bda02d1 Test: item/location name must not be numeric 2022-09-20 18:38:16 +02:00
black-sliver
2d5ec6ce22 Doc: item/location name must not be numeric 2022-09-20 18:38:16 +02:00
black-sliver
a95d0ce9ef Doc: clarify requirements.txt in world api.md 2022-09-20 09:48:30 +02:00
alwaysintreble
267d9234e5 core: fix options with "random" as default value not generating (#1033)
* core: fix options with "random" as default value not generating

when option is missing from the player yaml,

Using this in #893 and tested there.

* remove if

* OptionSets default to frozenset so handle that

* range had some specific instances of assuming default as a valid value so change this here to call the from_any

* isinstance instead of type

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

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-09-19 22:40:15 +02:00
black-sliver
4686881566 WebHost: CustomServer: use defaultdicts
also change non_hintable to defaultdict in MultiServer and add some typing
2022-09-19 01:20:36 +02:00
SoldierofOrder
101dab0ea4 SC2: Add helpful feedback when failing to locate SC2 (#1032)
* SC2: The client now throws a descriptive error when ExecuteInfo.txt exists but is empty, and offers more helpful suggestions when the file doesn't exist.

* SC2: Replaced the new RuntimeError with a warning in the logger to keep things consistent.

* Removed communism

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

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-09-18 16:48:36 +02:00
Fabian Dill
c2d69cb05e Core: add generic interface to add ER data to hints (#1014) 2022-09-18 14:30:43 +02:00
black-sliver
58f66e0f42 autoworld: don't load files/folders starting with '.' (#1030)
* autoworld: don't load files/folders starting with '.'

The imports fail if the folder has a '.' in the name, with a somewhat obscure error, and adding a '.' in front of it is what a linux user might expect to use when disabling a world temporarily.

* autoworld: use tuple to filter .* and _*
2022-09-18 13:02:05 +02:00
Fabian Dill
0215e1fa28 SC2: always show uncollected locations (#1007) 2022-09-18 12:40:35 +02:00
black-sliver
1c0a93acad doc: update use of relative/absolute imports
it matters for apworlds to function
2022-09-18 10:22:17 +02:00
NewSoupVi
4fcde135e5 The Witness: Renaming, Options, Logic Fixes (#1000)
Fixes to postgame detection for "shuffle_postgame"
Renamed many locations to be symbol-independent ("Outside Tutorial Dots Introduction" becomes "Outside Tutorial Shed Row"). This is to set up future alternate modes, like Sigma Expert, which use completely different symbols.
Renamed most door items to be shorter, more consistent, and less... stupid. ("Bunker Bunker Entry Door" -> "Bunker Entry")
Removed "shuffle_uncommon"
Many logic fixes
2022-09-18 04:20:59 +02:00
alwaysintreble
332dde154f core: new freetext and textchoice options (#728)
* add freetext and freetextchoice options

* fix textchoice. create plando_bosses bool so worlds can check if boss plando is enabled

* remove strange unneccessary \ escapes

* lttp: rip boss plando out of core

* fix broken text methods so they read the data correctly

* revert `None` key in boss_shuffle_options. fix failing tests

* lttp: rewrite boss plando

* lttp: rewrite boss shuffle

* add generic verification step and allow options to set a plando module

* add default typing to plando_options set

* use PlandoSettings intflag for lttp boss plando

* fix plandosettings boss flag check

* minor lttp init cleanup

* make suggested changes. account for "random" existing within plando boss options

* override eq operator

* Please document me!

* Forgot to mention it supports plando

* remove auto_display_name

* Throw warning alerting user to which shuffle is being used if plando is off. Set the remaining boss shuffle in init and boss placement cleanup

* move the convoluted string matching to `from_text`

* remove unneccessary text lowering and actually turn off plando option when it's disabled

* typing

* strong typing for verify method and reorder

* typing is your friend

* log warning correctly

* 3.8 support :(

* also list apparently

* rip out old boss shuffle spoiler code

* verification step for plando bosses and locations

* update plando guide to reference new supported behavior

* empty string is not `None`. remove unneccessary error throw

* Fix bad ordering

* validate boss_shuffle only contains a normal boss option at the end

* get random choice from a list dummy

* >:(

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

* minor textchoice cleanup

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-09-17 02:55:33 +02:00
black-sliver
8d51205e8f Alttp: only check item.type for own items with retro_cave 2022-09-17 02:25:09 +02:00
black-sliver
ff05e9d7d5 MultiServer: produce nicer output ...
... for headless and when cancelling the file open dialog
2022-09-17 02:24:51 +02:00
N00byKing
516a52c041 sm64ex: Fix WDW 1Up Block Logic 2022-09-17 02:13:32 +02:00
Alchav
9daa64741b New, smarter fast_fill function (#646)
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2022-09-17 02:06:25 +02:00
Fabian Dill
af11fa5150 Core: auto alias (#1022)
* Test: check that default templates can be parsed into Option objects
2022-09-16 00:32:30 +02:00
TheBigSalarius
156e9e0e43 FF1: Throw exception for Noverworld 2022-09-12 03:48:07 +02:00
Fabian Dill
ef46979bd8 SC2: fix client freezing on exit while SC2 is running (#1011) 2022-09-12 02:08:33 +02:00
Fabian Dill
b2aa251c47 SC2: fix bitflag overflow when multiple instances of an Item are acquired (#1008) 2022-09-12 01:51:25 +02:00
Fabian Dill
e204a0fce6 Subnautica: fix missed item and correct other item pool counts to fit it 2022-09-12 01:44:10 +02:00
TheBigSalarius
bb386d3bd7 FF1: fix FF1Client messaging and scoped lua messaging with printjson
Corrects the issue causing the client and lua messaging not displaying properly after the printjson changes
2022-09-12 01:19:51 +02:00
Fabian Dill
88a225764a FF1: fix printjson 2022-09-12 01:19:51 +02:00
espeon65536
99d2caa57d ALttP: remove link_palettes option (#1004)
* ALttP: remove link_palettes option
It doesn't work anyway so better to have it not visible.
2022-09-07 20:16:32 +02:00
lordlou
ade82e3d60 SM: varia tracker fix (#1006) 2022-09-06 19:56:23 +02:00
Fabian Dill
7c04e7e06f MultiServer: save goal completion flag 2022-09-05 22:11:26 +02:00
Fabian Dill
baf51e5959 SC2: fix Launching Mission: text pulling the unshuffled ID. (#1001)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-09-05 21:09:03 +02:00
toasterparty
8aad75ed23 Tests: Check for Holes in the Item Pool (#992)
* test for holes in the item pool

* Update test/general/TestItems.py

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

* Update test/general/TestItems.py

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

* Update test/general/TestItems.py

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

* Update test/general/TestItems.py

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

Co-authored-by: alwaysintreble <mmmcheese158@gmail.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-09-05 10:02:40 +02:00
black-sliver
1792b66b3a CI: fix automated builds, update SNI and Enemizer
* Launcher.py always running ModuleUpdate breaks setup.py build --yes
* Use env variables in github workflows
* Update SNI and Enemizer versions in github workflows
* Minor cleanup in workflows
* Silence pycharm warning in Launcher.py
2022-09-05 09:23:08 +02:00
wildham0
5e8ac74b2a FFR: fix NoOverworld mode (#999)
* Add Sigil/Mark to item list
2022-09-05 09:21:00 +02:00
PoryGone
2acc129381 SA2B: Fix typo in doc string (#997) 2022-09-04 14:45:45 +02:00
lordlou
0cbb3c2839 SMZ3: data package fix (#996) 2022-09-03 23:52:09 +02:00
espeon65536
539d2e80f1 OoT: prevent glitched + mq dungeons
this combo is not allowed on main ootr, so we won't have it here either
2022-09-03 21:26:31 +02:00
lordlou
f9e28004a0 SMZ3: item link gt fill fix (#995) 2022-09-03 21:25:55 +02:00
Sunny Bat
b7cfcc9272 New features and fixes for Raft (#984)
* Add DeathLink, small logic changes

* Fix generation, rules, use bool for slotData

* Add more island options

* Update Shovel-related logic

* Update docs
2022-09-03 21:25:04 +02:00
Fabian Dill
4b6d46fd74 Core: update modules 2022-09-03 09:55:47 +02:00
Alchav
b45d8bf221 Patch: Save patch file extension in archipelago.json (#968) 2022-09-02 23:37:37 +02:00
Fabian Dill
f7d107fc0c Subnautica: add some more missed aggressive creatures 2022-09-02 09:06:33 +02:00
alwaysintreble
b14d694e1e templates: fix bug report label 2022-09-01 22:33:22 +02:00
skrawpie
8d2333006a Minecraft: Added shuffled recipe list to en_Minecraft.md (#980)
Co-authored-by: KonoTyran <Kono.Tyran@gmail.com>
2022-09-01 21:26:04 +02:00
Fabian Dill
e413619c26 Tests: verify that a world doesn't use the same ID multiple times (#985) 2022-09-01 21:25:06 +02:00
Yussur Mustafa Oraji
03f66a922d sm64ex: Fix a Location (#979) 2022-09-01 21:21:53 +02:00
black-sliver
b115bdafe7 CI/Doc: Use pytest subtests (#986)
* CI/Doc: use pytest-subtests

* CI: clean up pip installs a bit

* make lint and unittests install the same stuff
* make sure to install wheel, which is a recommended (not required) dependency for everything pip
2022-09-01 09:30:28 +02:00
lordlou
0444fdc379 SM: wasteland ap (#983) 2022-09-01 02:20:30 +02:00
Fabian Dill
c617bba959 SC2: client revamp (#967)
SC2 client now relies almost entirely on the map file and server for the locations and just facilitates them, should make it significantly more resilient to objectives being added or removed


* SC2: fix client crash on printjson messages with more [ than ]

* SC2: move text to queue, that actually clears memory

* SC2: Announce which mission is being loaded


Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-08-31 20:55:15 +02:00
lordlou
8da1cfeeb7 SM: remove events from data package (#973) 2022-08-31 06:14:17 +02:00
black-sliver
fcfc2c2e10 WebHost: fix local_path on python 3.8 (#981)
* WebHost: fix local_path on python 3.8

`__file__` is relative in 3.8, so `os.path.dirname(__file__)` ends up being an empty string breaking calls to `local_path()` (without arguments)

* WebHost: add comment to local_path override
2022-08-31 00:10:18 +02:00
espeon65536
a753905ee4 OoT bug fixes (#955)
* OoT: fix shop patching crash due to Item changes

* OoT: more informative failure in triforce piece replacement

* OoT: in triforce hunt, remove ganon BK from pool and lock the door

* OoT: no longer store trap information on the item
2022-08-30 20:54:40 +02:00
strotlog
2a7babce68 SM+SMZ3: don't abandon checks that happen while disconnected from AP (#946) 2022-08-30 17:16:21 +02:00
Fabian Dill
60d1a27079 Subnautica: revamp aggressive creature scans (#966)
* add forgotten aggressive creatures
* fix logic requirements
* added option to opt out of aggressive creature scans
2022-08-30 17:14:34 +02:00
Fabian Dill
4a2a184db1 Core: remove game-specific arguments from Generate (#971)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-08-30 17:12:33 +02:00
alwaysintreble
45fb735320 Clients: allow games without datapackage (#978) 2022-08-30 00:16:13 +02:00
PoryGone
3eb9e7050f DKC3: Fix Wrinkly Softlock (#963) 2022-08-29 20:04:02 +02:00
CaitSith2
26aed9351e Factorio: Fix a bug with single craft free samples. (#974) 2022-08-29 05:58:26 +02:00
Fabian Dill
b1ffbc49c9 LttPAdjuster: fix GUI for invalid sprite files (#885)
* LttPAdjuster: ignore invalid sprite files

* LttPAdjuster: ignore .gitignore in sprites

* LttPAdjuster: log and show message for invalid sprites

* Alttp: set sprite.valid to False for bad zspr and apsprite ...

... when throwing exceptions

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-08-28 18:30:19 +02:00
Fabian Dill
6d6111de2a Launcher: add ModuleUpdate 2022-08-27 11:13:33 +02:00
Fabian Dill
cc8ce32c61 Options: fix corner case where Toggle.value and Toggle.__int__ would be bool
Which lead to a connect failure in Raft
2022-08-27 11:12:28 +02:00
strotlog
4c94bb0ad5 WebHost: sort game list case-insensitively again 2022-08-26 18:20:37 +02:00
strotlog
af19180ff0 SM: Fix rolling saves, add SRAM features
- fix receiving items in an old save (issue #855) by moving receive queue's read pointer to a per-saveslot value
- clear SRAM over $70:2000, and invalidate save data, when booting a new seed number for the first time
- copy important ROM data to SRAM so future clients don't have to read ROM
2022-08-26 10:32:22 +02:00
CaitSith2
a175aa93e7 Factorio: Detect if more than one AP factorio mod is loaded. (#964) 2022-08-26 10:31:30 +02:00
Zach Parks
a78863fde1 Docs: Update community supported libraries in api doc (#788)
* Docs: Update client supported libraries in api doc

* left align table column

* Update table of languages to include Haxe lib and remarks

* Reformat table

* Changed verbiage on SNI remark
2022-08-26 02:12:37 -05:00
Fabian Dill
0d6cbd9093 Core: convert item name groups to frozenset
Some worlds define them in lists, this speeds up lookup via state.has_group() or similar
2022-08-24 00:19:27 +02:00
Magnemania
1aaf89ff2c SC2: Switched mission item group to a list comprehension to fix missile shuffle errors (#959) 2022-08-23 23:20:39 +02:00
Fabian Dill
295ea97544 Subnautica: increment client version 2022-08-23 23:19:46 +02:00
Fabian Dill
33103b209d WebHost: fix error on save 2022-08-23 23:19:19 +02:00
Fabian Dill
fab12dca0b SC2: add anti air to Devil's Playground Victory
People seem to be on the mission long enough to get attacked by Mutalisks, so Victory should require anti air.
Optional Objectives are doable quite comfortably before Mutalisks show up, allowing the anti-air to be on them for later in the mission.
2022-08-23 23:06:58 +02:00
Fabian Dill
c390801c4c Test: verify file webhost file creations work to some degree (#953)
WebHost: fix some file creation paths
2022-08-23 01:07:17 +02:00
Fabian Dill
e548abd332 Subnautica: use correct option parent class (#954)
* Subnautica: use correct option parent class

* Update Options.py
2022-08-22 19:02:29 -04:00
Jarno
0a5b24be2b [Core] Phase out Print packets and added Countdown type to print json (#812)
* [Core] Added Countdown type to print json to distinct the count down message from other types

* Added backward compatibility check

* Fixed review comments

* Updated header category

* Apply suggestions from code review

Co-authored-by: Hussein Farran <hmfarran@gmail.com>

* Completely phased out Print in favor of PrintJson

* Updated docs to warn about phasing out of Print

* Removed faulty import

Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-08-23 01:02:10 +02:00
Chris Wilson
7f41cafffc Explaining the "Style Lockdown" (#940)
* First pass at a contribution guide for the website. Suggestions are welcome.

* Attempt to make the WebHost change guide describe the intent of the style restrictions more accurately.

* Try to improve the explanation of the intention behind the style restrictions.
2022-08-22 19:01:21 -04:00
alwaysintreble
d66f981be6 Github: templates and new user interface (#870)
* move some docs out of readme and link with the headers

* PR template

* bug report template

* task and feature request templates

* md cleanup

* forgot the template

* make expected results separate section

* move pr template to .github. remove assignment field on tasks

* add headers to pr template

* Requested changes

* suggested changes from @black-sliver and @SoldierofOrder

* Update docs/code_of_conduct.md

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* Update docs/contributing.md

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* Update docs/contributing.md

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

Co-authored-by: Hussein Farran <hmfarran@gmail.com>
Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>
2022-08-23 00:39:55 +02:00
alwaysintreble
b66a265726 Docs: Make webworld attribute descriptions docstrings instead of comments for nice IDE things (#929) 2022-08-22 23:50:16 +02:00
Fabian Dill
c695f91198 Subnautica: add Options to Creature Scans (#950) 2022-08-22 23:35:41 +02:00
CaitSith2
11cbc0b40b Factorio: Make the energy bridge a different color. (#952) 2022-08-22 23:30:42 +02:00
N00byKing
87d91aeef3 sm64ex: Option for 1Up Block Rando 2022-08-22 17:52:56 +02:00
Fabian Dill
6a6dfcbaff Core: add some types to generic.Rules 2022-08-22 17:51:06 +02:00
NewSoupVi
9553627136 Witness: More bug fixes (#937)
* Fixed disable_non_randomized and other bugs

* Slight performance & code sensibility increase

* Added River Shortcut to Garden as a disabled check in disable_non_randomized

* Changed no progression items exception to a warning

* Added a list of disabled panels to slot_data for disable_non_randomized, so the client can automatically disable the right panels in the future

* Made no progression exception conditional on playercount
2022-08-22 05:50:01 +02:00
PoryGone
a4a8894d22 Add /SNI to .gitignore (#949) 2022-08-22 01:20:35 +02:00
wordfcuk
bf217dcf85 RoR2: Fixed the link to the game settings page (#945) 2022-08-21 17:30:30 +02:00
CaitSith2
484ee9f065 OoT: More item.type bugs. (#930) 2022-08-21 01:55:41 +02:00
Zach Parks
bba82ccd6c WebHost: Remove "Wiki" link from footer (#943) 2022-08-20 19:17:23 -04:00
alwaysintreble
fb122df5f5 RoR2: code cleanup and styling consistency (#833)
* build locations dict dynamically from the TotalLocations option. Minor styling cleanup

* Minor items styling cleanup. remove unused event items

* minor options cleanup. clarify preset toggle slightly better

* make items.py more readable. add chaos weights dict to use as reference point for generation

* small rules styling and consistency cleanup

* create less regions and other init cleanup

* move region creation to less function calls and move revivals calculation

* typing

* use enum instead of hardcoded ints. fix bug i introduced

* better typing
2022-08-20 19:09:35 -04:00
KonoTyran
be8c3131d8 fix allay advancements requiring note block on the wrong one. (#896) 2022-08-20 19:02:50 -04:00
Fabian Dill
9341332379 WebHost: allow newlines in data-tooltip (#921)
* WebHost: allow newlines in data-tooltip

* WebHost: Tooltips: strip surrounding whitespace

* WebHost: unify tooltips behaviour

* WebHost: unify labels around tooltips

* WebHost: changing tooltips width to max-width to allow small tooltips to not have empty space.

* Minor modifications to tooltips

- Reduce tooltip target to (?) spans
- Set fixed width of 260px on tooltips
- Add space between : and (?) on player-settings
- Removed cursor:pointer on tooltips
- Fix labels for checkboxes on generate.html

Co-authored-by: Chris Wilson <chris@legendserver.info>
2022-08-20 18:58:46 -04:00
Fabian Dill
83bcb441bf Factorio: typo 2022-08-21 00:34:36 +02:00
PoryGone
a074d16297 DKC3 v1.1.0 (#938)
Features:

* KONGsanity option (Collect all KONG letters in each level for a check)
* Autosave option
* Difficulty option
* MERRY option
* Handle collected/co-op locations


Bugfixes:

 * Fixed Mekanos softlock
 * Prevent Brothers Bear giving extra Banana Birds
 * Fixed Banana Bird Mother check sending prematurely
 * Fix Logic bug with Krematoa level costs
2022-08-20 16:46:44 +02:00
TheCondor07
89ab4aff9c SC2: Logic changes and fixes, 6 new locations, 2 removed locations (#933) 2022-08-19 22:50:44 +02:00
lordlou
0ac67bfe76 Smz3 early sword fix (#939) 2022-08-19 15:02:39 +02:00
Fabian Dill
0d61192c67 Factorio: make apworld compatible(#935) 2022-08-18 01:33:40 +02:00
Fabian Dill
a1aa9c17ff Core: convert is_zip to zip_path 2022-08-18 01:20:30 +02:00
Henrique Gemignani Passos Lima
d0faa36eef Fix CommonClient.server_loop with nogui
When running client without a gui, ctx.ui is None
2022-08-18 01:18:01 +02:00
Fabian Dill
22c8153ba8 WebHost: fix indentation in tracker.py 2022-08-17 22:15:56 +02:00
CaitSith2
6602c580f4 Fix another item.type crash bug. (#927)
* Fix another item.type crash bug.

* Another location that can crash, in the instance of plando fixed.
2022-08-17 07:16:14 -07:00
Joethepic
431a9b7023 Docs: Mc: fix version in setup guide (#873)
Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>
2022-08-17 09:59:22 +02:00
Fabian Dill
d426226bce LttP: run optimize imports on __init__ 2022-08-16 23:57:59 +02:00
Fabian Dill
09afdc2553 Webhost: prevent tracker crashes with LttP key itemlinks (#922) 2022-08-16 23:57:26 +02:00
Fabian Dill
ca83905d9f Core: allow loading worlds from zip modules (#747)
* Core: allow loading worlds from zip modules
RoR2: make it zipimport compatible (remove relative imports beyond local top-level)

* WebHost: add support for .apworld
2022-08-15 23:52:03 +02:00
black-sliver
086295adbb AutoWorld: add preliminary .apworld specification (#903)
* AutoWorld: add preliminary .apworld specification

* Doc: apworld specification: fix typo
2022-08-15 23:47:32 +02:00
alwaysintreble
81cf1508e0 Core: Refactor Autoworld.options to Autoworld.option_definitions (#906)
* refactor `world.options` -> `world.option_definitions`

* rename world api reference

* missed some self.options
2022-08-15 23:46:59 +02:00
Fabian Dill
8484193151 Core: crash if non_local pool is too big 2022-08-15 23:36:07 +02:00
Fabian Dill
d10fbf8263 Minecraft: update requests 2022-08-15 23:35:51 +02:00
Fabian Dill
f73b3d71bf Factorio: fix typo 2022-08-15 23:03:03 +02:00
Fabian Dill
d48d775a59 Subnautica: fix 2 logic/locations bugs and add a bit of docs (#917) 2022-08-15 22:53:59 +02:00
Yussur Mustafa Oraji
f716bfc58f sm64ex: Fix Second Floor Door Cost (#909) 2022-08-15 17:29:35 +02:00
Jarno Westhof
97b388747a Docs: Added DS3 & DK3 to network graph 2022-08-15 16:56:55 +02:00
lordlou
898fa203ad Smz3 updated to version 11.3 (#886) 2022-08-15 16:48:13 +02:00
CaitSith2
c02c6ee58c Fix generation failure for Final Fantasy 1 and Dark Souls 3. (#907)
* Fix generation failure for Final Fantasy 1.

* Fix spoiler log giving "Location (Player x): Item (Player y)" for FF1.

* Dark Soul 3 Items/Locations now get player names in spoiler log.
2022-08-14 12:34:46 -07:00
black-sliver
23b04b5069 SM: correctly check if items are SM items 2022-08-14 13:38:52 +02:00
Ludovic Marechal
0ed0d17f38 DS3: Update the setup guide (#878)
* Merge pull request #1 from eudaimonistic/patch-2

Update setup_en.md

(cherry picked from commit 41567697fb89e74301afe651fbde0bafca5946e0)

* DS3: Update english documentation

* DS3: Add French setup guide

* DS3: Fix space formatting in doc

* DS3: Resolve comment
2022-08-14 00:07:36 +02:00
espeon65536
645ede869f OoT: Fix blind item.type reference (#905)
* oot: remove blind reference to item.type

* oot: logical reasoning is hard

* oot: fix blind item.type reference
2022-08-13 04:36:06 +02:00
black-sliver
f5e48c850d Utils: lazy decimal import
decimal is kinda big, there is no noticable difference in performance and the import is unused by webhost's customserver
2022-08-13 00:20:08 +02:00
Fabian Dill
9bd035a19d WebHost: make a fresh Room reload page once if port is not assigned yet 2022-08-12 16:01:02 +02:00
Fabian Dill
2e428f906c Core: document KeyedDefaultDict 2022-08-12 08:34:33 +02:00
black-sliver
b702ae482b Core: clean up Utils.py
* fix import order
* lazy import shutil
* lazy import jellyfish (also speed-up by 0.8%, probably because of inlining)
* yaml:
  * explicitely call Loader UnsafeLoader
  * use CDumper, twice as fast
  * stop leaking leak imported names load and load_all
* open_file: use absolute path
* replace quotes in touched code
* add some typing in touched code
* stringify type hinting for non-imports
* %s/.format -> f
* freeze safe_builtins
* remove double-caching in get_options()
* get rid of some warnings
2022-08-12 08:07:45 +02:00
black-sliver
b8ca41b45f Utils: SI: fix rounding problems (#895)
* Utils: SI: fix rounding problems

999.999 would give 1000.00 instead of 1.00k

* Tests: add Utils: SI tests
2022-08-12 00:46:11 +02:00
CaitSith2
adc16fdd3d Factorio: Don't send researches completed by editor extensions testing forces. (#894) 2022-08-11 18:11:34 +02:00
NewSoupVi
b32d0efe6d Witness: Logic fix for Treehouse in Doors (#892) 2022-08-11 15:57:33 +02:00
black-sliver
c96acbfa23 TextClient: receive all items
By popular demand, this makes /received work again.

Closes #887
2022-08-11 01:06:58 +02:00
black-sliver
ffe528467e Generate: remove period for easy copy&paste
Double-clicking in terminal may select the period, resulting in a bad filename in clipboard.
Also fixing quotes.
2022-08-11 01:06:43 +02:00
Fabian Dill
b989698740 WebHost: fix datapackage typo 2022-08-11 01:04:53 +02:00
Fabian Dill
29e0975832 Clients: prepare for removal of players key in RoomInfo 2022-08-11 00:48:38 +02:00
CaitSith2
e1e2526322 LttP: Do a check for enemizer much earlier in generation. (#875) 2022-08-10 22:21:52 +02:00
Fabian Dill
f2e83c37e9 WebHost: use title-typical sorting for game titles (#883) 2022-08-09 22:21:45 +02:00
Fabian Dill
debda5d111 MultiServer: swap auto-forfeit with auto-collect order
That way the forfeit for items for players that are still playing appear last in the log, which is the visible text in at least the py clients
2022-08-09 16:58:02 +02:00
alwaysintreble
2c4e819010 docs: plando update (#861)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-08-09 10:47:01 +02:00
alwaysintreble
b3700dabf2 Core: Fix meta.yaml and allow the None game category for common options (#845) 2022-08-09 02:29:00 +02:00
TheCondor07
fb2979d9ef SC2: Added Difficulty Override to Client (#863) 2022-08-09 00:20:51 +02:00
Fabian Dill
a378d62dfd SC2: fix Moebius Factor rescue condition (#882) 2022-08-08 23:20:18 +02:00
lordlou
eb5ba72cfc Smz3 min accessibility fix (#880) 2022-08-08 22:23:22 +02:00
Fabian Dill
c1e9d0ab4f WebHost: allow customserver to skip importing worlds subsystem for hosting a Room (#877)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-08-07 18:28:50 +02:00
black-sliver
181cc47079 Core: cleanup BaseClasses.Location
This is just cleanup and has virtually no performance impact.
2022-08-07 13:11:12 +02:00
Zach Parks
04eef669f9 StS: Add a description for the game. (#876) 2022-08-06 21:36:32 -05:00
PoryGone
9167e5363d DKC3: Correct File Extension in Setup Guide (#872) 2022-08-06 13:26:02 +02:00
Zach Parks
f1c5c9a148 WebHost: Fix OptionDicts that define valid_keys from outputting as [] on Weighted Settings export. (#874)
* WebHost: Fix OptionDicts that define valid_keys from outputting as [] on Weighted Settings export
2022-08-06 13:25:37 +02:00
Joethepic
69e5627cd7 HK: fix indentation on mimic grubs (#868) 2022-08-06 02:11:10 +02:00
PoryGone
ae3e6c29e3 DKC3: Add Link to Tracker from Setup Guide (#871) 2022-08-06 00:53:48 +02:00
black-sliver
f6da81ac70 Core: cleanup Item classes (#849) 2022-08-06 00:49:54 +02:00
Jarno Westhof
dd6e212519 [Core] Colorama fix 2022-08-05 17:17:40 +02:00
Fabian Dill
95bba50223 WebHost: fix filename rename in flask update 2022-08-05 17:16:26 +02:00
Fabian Dill
21f7c6c0ad Core: optimize away Item.world (#840)
* Core: optimize away Item.world

* Update test/general/TestFill.py

* Test: undo unnecessary changes

* lttp: remove two more Item.world writes

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-08-05 17:09:21 +02:00
Fabian Dill
d15c30f63b Stats: limit to recognized games 2022-08-05 17:01:02 +02:00
449 changed files with 37229 additions and 9661 deletions

35
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Bug Report
description: File a bug report.
title: "Bug: "
labels:
- bug / fix
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report! If this bug occurred during local generation check your
Archipelago install for a log (probably `C:\ProgramData\Archipelago\logs`)
and upload it with this report, as well as all yaml files used.
- type: textarea
id: what-happened
attributes:
label: What happened?
validations:
required: true
- type: textarea
id: expected-results
attributes:
label: What were the expected results?
validations:
required: true
- type: dropdown
id: version
attributes:
label: Software
description: Where did this bug occur?
options:
- Website
- Local generation
- While playing
validations:
required: true

View File

@@ -0,0 +1,17 @@
name: Feature Request
description: Request a feature!
title: "Category: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Please replace `Category` in the title with what this feature will be targeting, such as Core generation,
website, documentation, or a game.
Note: this is not for requesting new games to be added. If you would like to request a game, the best place to
ask is about it is in the [discord](https://archipelago.gg/discord).
- type: textarea
id: feature
attributes:
label: What feature would you like to see?

10
.github/ISSUE_TEMPLATE/task.yaml vendored Normal file
View File

@@ -0,0 +1,10 @@
name: Task
description: Submit a task to be done. If this is not targeting core, it should likely be elsewhere.
title: "Core: "
labels:
- core
- enhancement
body:
- type: textarea
attributes:
label: What task needs to be completed?

12
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,12 @@
Please format your title with what portion of the project this pull request is
targeting and what it's changing.
ex. "MyGame4: implement new game" or "Docs: add new guide for customizing MyGame3"
## What is this fixing or adding?
## How was this tested?
## If this makes graphical changes, please attach screenshots.

View File

@@ -4,6 +4,11 @@ name: Build
on: workflow_dispatch on: workflow_dispatch
env:
SNI_VERSION: v0.0.84
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
jobs: jobs:
# build-release-macos: # LF volunteer # build-release-macos: # LF volunteer
@@ -17,15 +22,15 @@ jobs:
python-version: '3.8' python-version: '3.8'
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-windows-amd64.zip -OutFile sni.zip Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/${Env:SNI_VERSION}/sni-${Env:SNI_VERSION}-windows-amd64.zip -OutFile sni.zip
Expand-Archive -Path sni.zip -DestinationPath SNI -Force Expand-Archive -Path sni.zip -DestinationPath SNI -Force
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/7.0.1/win-x64.zip -OutFile enemizer.zip Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
- name: Build - name: Build
run: | run: |
python -m pip install --upgrade pip setuptools python -m pip install --upgrade pip setuptools
pip install -r requirements.txt pip install -r requirements.txt
python setup.py build --yes python setup.py build_exe --yes
$NAME="$(ls build)".Split('.',2)[1] $NAME="$(ls build)".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z" $ZIP_NAME="Archipelago_$NAME.7z"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
@@ -43,6 +48,7 @@ jobs:
build-ubuntu1804: build-ubuntu1804:
runs-on: ubuntu-18.04 runs-on: ubuntu-18.04
steps: steps:
# - copy code below to release.yml -
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Install base dependencies - name: Install base dependencies
run: | run: |
@@ -56,18 +62,18 @@ jobs:
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.9" >> $GITHUB_ENV echo "PYTHON=python3.9" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool chmod a+rx appimagetool
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz tar xf sni-*.tar.xz
rm sni-*.tar.xz rm sni-*.tar.xz
mv sni-* SNI mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z 7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build - name: Build
run: | run: |
@@ -76,7 +82,7 @@ jobs:
"${{ env.PYTHON }}" -m venv venv "${{ env.PYTHON }}" -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
python setup.py build --yes bdist_appimage --yes python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`" echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`" echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
@@ -84,6 +90,7 @@ jobs:
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME") (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
- name: Store AppImage - name: Store AppImage
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:

View File

@@ -18,8 +18,8 @@ jobs:
python-version: 3.9 python-version: 3.9
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip wheel
pip install flake8 pytest pip install flake8 pytest pytest-subtests
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8 - name: Lint with flake8
run: | run: |

View File

@@ -7,6 +7,11 @@ on:
tags: tags:
- '*.*.*' - '*.*.*'
env:
SNI_VERSION: v0.0.84
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
jobs: jobs:
create-release: create-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -44,22 +49,23 @@ jobs:
- name: Install build-time dependencies - name: Install build-time dependencies
run: | run: |
echo "PYTHON=python3.9" >> $GITHUB_ENV echo "PYTHON=python3.9" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract ./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool chmod a+rx appimagetool
- name: Download run-time dependencies - name: Download run-time dependencies
run: | run: |
wget -nv https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz tar xf sni-*.tar.xz
rm sni-*.tar.xz rm sni-*.tar.xz
mv sni-* SNI mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z 7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build - name: Build
run: | run: |
"${{ env.PYTHON }}" -m pip install --upgrade pip setuptools virtualenv PyGObject # pygobject should probably move to requirements # pygobject is an optional dependency for kivy that's not in requirements
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
"${{ env.PYTHON }}" -m venv venv "${{ env.PYTHON }}" -m venv venv
source venv/bin/activate source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt

View File

@@ -32,8 +32,8 @@ jobs:
python-version: ${{ matrix.python.version }} python-version: ${{ matrix.python.version }}
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip wheel
pip install flake8 pytest pip install flake8 pytest pytest-subtests
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Unittests - name: Unittests
run: | run: |

7
.gitignore vendored
View File

@@ -13,6 +13,10 @@
*.z64 *.z64
*.n64 *.n64
*.nes *.nes
*.sms
*.gb
*.gbc
*.gba
*.wixobj *.wixobj
*.lck *.lck
*.db3 *.db3
@@ -28,6 +32,7 @@ README.html
.vs/ .vs/
EnemizerCLI/ EnemizerCLI/
/Players/ /Players/
/SNI/
/options.yaml /options.yaml
/config.yaml /config.yaml
/logs/ /logs/
@@ -124,7 +129,7 @@ ipython_config.py
# Environments # Environments
.env .env
.venv .venv*
env/ env/
venv/ venv/
ENV/ ENV/

View File

@@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from argparse import Namespace
import copy import copy
from enum import unique, IntEnum, IntFlag from enum import unique, IntEnum, IntFlag
@@ -40,16 +41,23 @@ class MultiWorld():
plando_connections: List plando_connections: List
worlds: Dict[int, auto_world] worlds: Dict[int, auto_world]
groups: Dict[int, Group] groups: Dict[int, Group]
regions: List[Region]
itempool: List[Item] itempool: List[Item]
is_race: bool = False is_race: bool = False
precollected_items: Dict[int, List[Item]] precollected_items: Dict[int, List[Item]]
state: CollectionState state: CollectionState
accessibility: Dict[int, Options.Accessibility] accessibility: Dict[int, Options.Accessibility]
early_items: Dict[int, Dict[str, int]]
local_early_items: Dict[int, Dict[str, int]]
local_items: Dict[int, Options.LocalItems] local_items: Dict[int, Options.LocalItems]
non_local_items: Dict[int, Options.NonLocalItems] non_local_items: Dict[int, Options.NonLocalItems]
progression_balancing: Dict[int, Options.ProgressionBalancing] progression_balancing: Dict[int, Options.ProgressionBalancing]
completion_condition: Dict[int, Callable[[CollectionState], bool]] completion_condition: Dict[int, Callable[[CollectionState], bool]]
indirect_connections: Dict[Region, Set[Entrance]]
exclude_locations: Dict[int, Options.ExcludeLocations]
game: Dict[int, str]
class AttributeProxy(): class AttributeProxy():
def __init__(self, rule): def __init__(self, rule):
@@ -87,6 +95,9 @@ class MultiWorld():
self.customitemarray = [] self.customitemarray = []
self.shuffle_ganon = True self.shuffle_ganon = True
self.spoiler = Spoiler(self) self.spoiler = Spoiler(self)
self.early_items = {player: {} for player in self.player_ids}
self.local_early_items = {player: {} for player in self.player_ids}
self.indirect_connections = {}
self.fix_trock_doors = self.AttributeProxy( self.fix_trock_doors = self.AttributeProxy(
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted') lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
self.fix_skullwoods_exit = self.AttributeProxy( self.fix_skullwoods_exit = self.AttributeProxy(
@@ -166,7 +177,7 @@ class MultiWorld():
self.player_types[new_id] = NetUtils.SlotType.group self.player_types[new_id] = NetUtils.SlotType.group
self._region_cache[new_id] = {} self._region_cache[new_id] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[game] world_type = AutoWorld.AutoWorldRegister.world_types[game]
for option_key, option in world_type.options.items(): for option_key, option in world_type.option_definitions.items():
getattr(self, option_key)[new_id] = option(option.default) getattr(self, option_key)[new_id] = option(option.default)
for option_key, option in Options.common_options.items(): for option_key, option in Options.common_options.items():
getattr(self, option_key)[new_id] = option(option.default) getattr(self, option_key)[new_id] = option(option.default)
@@ -195,7 +206,7 @@ class MultiWorld():
self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in
range(1, self.players + 1)} range(1, self.players + 1)}
def set_options(self, args): def set_options(self, args: Namespace) -> None:
for option_key in Options.common_options: for option_key in Options.common_options:
setattr(self, option_key, getattr(args, option_key, {})) setattr(self, option_key, getattr(args, option_key, {}))
for option_key in Options.per_game_common_options: for option_key in Options.per_game_common_options:
@@ -204,7 +215,7 @@ class MultiWorld():
for player in self.player_ids: for player in self.player_ids:
self.custom_data[player] = {} self.custom_data[player] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]] world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
for option_key in world_type.options: for option_key in world_type.option_definitions:
setattr(self, option_key, getattr(args, option_key, {})) setattr(self, option_key, getattr(args, option_key, {}))
self.worlds[player] = world_type(self, player) self.worlds[player] = world_type(self, player)
@@ -295,9 +306,16 @@ class MultiWorld():
def get_file_safe_player_name(self, player: int) -> str: def get_file_safe_player_name(self, player: int) -> str:
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*') return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
def get_out_file_name_base(self, player: int) -> str:
""" the base name (without file extension) for each player's output file for a seed """
return f"AP_{self.seed_name}_P{player}" \
+ (f"_{self.get_file_safe_player_name(player).replace(' ', '_')}"
if (self.player_name[player] != f"Player{player}")
else '')
def initialize_regions(self, regions=None): def initialize_regions(self, regions=None):
for region in regions if regions else self.regions: for region in regions if regions else self.regions:
region.world = self region.multiworld = self
self._region_cache[region.player][region.name] = region self._region_cache[region.player][region.name] = region
@functools.cached_property @functools.cached_property
@@ -384,7 +402,6 @@ class MultiWorld():
return self.worlds[player].create_item(item_name) return self.worlds[player].create_item(item_name)
def push_precollected(self, item: Item): def push_precollected(self, item: Item):
item.world = self
self.precollected_items[item.player].append(item) self.precollected_items[item.player].append(item)
self.state.collect(item, True) self.state.collect(item, True)
@@ -392,7 +409,6 @@ class MultiWorld():
assert location.can_fill(self.state, item, False), f"Cannot place {item} into {location}." assert location.can_fill(self.state, item, False), f"Cannot place {item} into {location}."
location.item = item location.item = item
item.location = location item.location = location
item.world = self # try to not have this here anymore and create it with item?
if collect: if collect:
self.state.collect(item, location.event, location) self.state.collect(item, location.event, location)
@@ -406,6 +422,11 @@ class MultiWorld():
def clear_entrance_cache(self): def clear_entrance_cache(self):
self._cached_entrances = None self._cached_entrances = None
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) -> List[Location]: def get_locations(self) -> List[Location]:
if self._cached_locations is None: if self._cached_locations is None:
self._cached_locations = [location for region in self.regions for location in region.locations] self._cached_locations = [location for region in self.regions for location in region.locations]
@@ -523,15 +544,17 @@ class MultiWorld():
"""Check if accessibility rules are fulfilled with current or supplied state.""" """Check if accessibility rules are fulfilled with current or supplied state."""
if not state: if not state:
state = CollectionState(self) state = CollectionState(self)
players = {"minimal": set(), players: Dict[str, Set[int]] = {
"items": set(), "minimal": set(),
"locations": set()} "items": set(),
"locations": set()
}
for player, access in self.accessibility.items(): for player, access in self.accessibility.items():
players[access.current_key].add(player) players[access.current_key].add(player)
beatable_fulfilled = False beatable_fulfilled = False
def location_conditition(location: Location): def location_condition(location: Location):
"""Determine if this location has to be accessible, location is already filtered by location_relevant""" """Determine if this location has to be accessible, location is already filtered by location_relevant"""
if location.player in players["minimal"]: if location.player in players["minimal"]:
return False return False
@@ -545,20 +568,21 @@ class MultiWorld():
return True return True
return False return False
def all_done(): def all_done() -> bool:
"""Check if all access rules are fulfilled""" """Check if all access rules are fulfilled"""
if beatable_fulfilled: if not beatable_fulfilled:
if any(location_conditition(location) for location in locations): return False
return False # still locations required to be collected if any(location_condition(location) for location in locations):
return True return False # still locations required to be collected
return True
locations = {location for location in self.get_locations() if location_relevant(location)} locations = [location for location in self.get_locations() if location_relevant(location)]
while locations: while locations:
sphere = set() sphere: List[Location] = []
for location in locations: for n in range(len(locations) - 1, -1, -1):
if location.can_reach(state): if locations[n].can_reach(state):
sphere.add(location) sphere.append(locations.pop(n))
if not sphere: if not sphere:
# ran out of places and did not finish yet, quit # ran out of places and did not finish yet, quit
@@ -567,8 +591,8 @@ class MultiWorld():
return False return False
for location in sphere: for location in sphere:
locations.remove(location) if location.item:
state.collect(location.item, True, location) state.collect(location.item, True, location)
if self.has_beaten_game(state): if self.has_beaten_game(state):
beatable_fulfilled = True beatable_fulfilled = True
@@ -584,7 +608,7 @@ PathValue = Tuple[str, Optional["PathValue"]]
class CollectionState(): class CollectionState():
prog_items: typing.Counter[Tuple[str, int]] prog_items: typing.Counter[Tuple[str, int]]
world: MultiWorld multiworld: MultiWorld
reachable_regions: Dict[int, Set[Region]] reachable_regions: Dict[int, Set[Region]]
blocked_connections: Dict[int, Set[Entrance]] blocked_connections: Dict[int, Set[Entrance]]
events: Set[Location] events: Set[Location]
@@ -596,7 +620,7 @@ class CollectionState():
def __init__(self, parent: MultiWorld): def __init__(self, parent: MultiWorld):
self.prog_items = Counter() self.prog_items = Counter()
self.world = parent self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()} self.reachable_regions = {player: set() for player in parent.get_all_ids()}
self.blocked_connections = {player: set() for player in parent.get_all_ids()} self.blocked_connections = {player: set() for player in parent.get_all_ids()}
self.events = set() self.events = set()
@@ -610,15 +634,14 @@ class CollectionState():
self.collect(item, True) self.collect(item, True)
def update_reachable_regions(self, player: int): def update_reachable_regions(self, player: int):
from worlds.alttp.EntranceShuffle import indirect_connections
self.stale[player] = False self.stale[player] = False
rrp = self.reachable_regions[player] rrp = self.reachable_regions[player]
bc = self.blocked_connections[player] bc = self.blocked_connections[player]
queue = deque(self.blocked_connections[player]) queue = deque(self.blocked_connections[player])
start = self.world.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 # init on first call - this can't be done on construction since the regions don't exist yet
if not start in rrp: if start not in rrp:
rrp.add(start) rrp.add(start)
bc.update(start.exits) bc.update(start.exits)
queue.extend(start.exits) queue.extend(start.exits)
@@ -630,7 +653,7 @@ class CollectionState():
if new_region in rrp: if new_region in rrp:
bc.remove(connection) bc.remove(connection)
elif connection.can_reach(self): elif connection.can_reach(self):
assert new_region, "tried to search through an Entrance with no Region" assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
rrp.add(new_region) rrp.add(new_region)
bc.remove(connection) bc.remove(connection)
bc.update(new_region.exits) bc.update(new_region.exits)
@@ -638,13 +661,12 @@ class CollectionState():
self.path[new_region] = (new_region.name, self.path.get(connection, None)) self.path[new_region] = (new_region.name, self.path.get(connection, None))
# Retry connections if the new region can unblock them # Retry connections if the new region can unblock them
if new_region.name in indirect_connections: for new_entrance in self.multiworld.indirect_connections.get(new_region, set()):
new_entrance = self.world.get_entrance(indirect_connections[new_region.name], player)
if new_entrance in bc and new_entrance not in queue: if new_entrance in bc and new_entrance not in queue:
queue.append(new_entrance) queue.append(new_entrance)
def copy(self) -> CollectionState: def copy(self) -> CollectionState:
ret = CollectionState(self.world) ret = CollectionState(self.multiworld)
ret.prog_items = self.prog_items.copy() ret.prog_items = self.prog_items.copy()
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
self.reachable_regions} self.reachable_regions}
@@ -665,25 +687,25 @@ class CollectionState():
assert isinstance(player, int), "can_reach: player is required if spot is str" assert isinstance(player, int), "can_reach: player is required if spot is str"
# try to resolve a name # try to resolve a name
if resolution_hint == 'Location': if resolution_hint == 'Location':
spot = self.world.get_location(spot, player) spot = self.multiworld.get_location(spot, player)
elif resolution_hint == 'Entrance': elif resolution_hint == 'Entrance':
spot = self.world.get_entrance(spot, player) spot = self.multiworld.get_entrance(spot, player)
else: else:
# default to Region # default to Region
spot = self.world.get_region(spot, player) spot = self.multiworld.get_region(spot, player)
return spot.can_reach(self) return spot.can_reach(self)
def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None: def sweep_for_events(self, key_only: bool = False, locations: Optional[Iterable[Location]] = None) -> None:
if locations is None: if locations is None:
locations = self.world.get_filled_locations() locations = self.multiworld.get_filled_locations()
new_locations = True reachable_events = True
# since the loop has a good chance to run more than once, only filter the events once # since the loop has a good chance to run more than once, only filter the events once
locations = {location for location in locations if location.event and locations = {location for location in locations if location.event and location not in self.events and
not key_only or getattr(location.item, "locked_dungeon_item", False)} not key_only or getattr(location.item, "locked_dungeon_item", False)}
while new_locations: while reachable_events:
reachable_events = {location for location in locations if location.can_reach(self)} reachable_events = {location for location in locations if location.can_reach(self)}
new_locations = reachable_events - self.events locations -= reachable_events
for event in new_locations: for event in reachable_events:
self.events.add(event) self.events.add(event)
assert isinstance(event.item, Item), "tried to collect Event with no Item" assert isinstance(event.item, Item), "tried to collect Event with no Item"
self.collect(event.item, True, event) self.collect(event.item, True, event)
@@ -702,7 +724,7 @@ class CollectionState():
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool: def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
found: int = 0 found: int = 0
for item_name in self.world.worlds[player].item_name_groups[item_name_group]: for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player] found += self.prog_items[item_name, player]
if found >= count: if found >= count:
return True return True
@@ -710,17 +732,17 @@ class CollectionState():
def count_group(self, item_name_group: str, player: int) -> int: def count_group(self, item_name_group: str, player: int) -> int:
found: int = 0 found: int = 0
for item_name in self.world.worlds[player].item_name_groups[item_name_group]: for item_name in self.multiworld.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player] found += self.prog_items[item_name, player]
return found return found
def can_buy_unlimited(self, item: str, player: int) -> bool: def can_buy_unlimited(self, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for
shop in self.world.shops) shop in self.multiworld.shops)
def can_buy(self, item: str, player: int) -> bool: def can_buy(self, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for
shop in self.world.shops) shop in self.multiworld.shops)
def item_count(self, item: str, player: int) -> int: def item_count(self, item: str, player: int) -> int:
return self.prog_items[item, player] return self.prog_items[item, player]
@@ -740,7 +762,7 @@ class CollectionState():
return self.has('Power Glove', player) or self.has('Titans Mitts', player) return self.has('Power Glove', player) or self.has('Titans Mitts', player)
def bottle_count(self, player: int) -> int: def bottle_count(self, player: int) -> int:
return min(self.world.difficulty_requirements[player].progressive_bottle_limit, return min(self.multiworld.difficulty_requirements[player].progressive_bottle_limit,
self.count_group("Bottles", player)) self.count_group("Bottles", player))
def has_hearts(self, player: int, count: int) -> int: def has_hearts(self, player: int, count: int) -> int:
@@ -749,7 +771,7 @@ class CollectionState():
def heart_count(self, player: int) -> int: def heart_count(self, player: int) -> int:
# Warning: This only considers items that are marked as advancement items # Warning: This only considers items that are marked as advancement items
diff = self.world.difficulty_requirements[player] diff = self.multiworld.difficulty_requirements[player]
return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \ return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \
+ self.item_count('Sanctuary Heart Container', player) \ + self.item_count('Sanctuary Heart Container', player) \
+ min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \ + min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
@@ -766,9 +788,9 @@ class CollectionState():
elif self.has('Magic Upgrade (1/2)', player): elif self.has('Magic Upgrade (1/2)', player):
basemagic = 16 basemagic = 16
if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player): if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player):
if self.world.item_functionality[player] == 'hard' and not fullrefill: if self.multiworld.item_functionality[player] == 'hard' and not fullrefill:
basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player)) basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player))
elif self.world.item_functionality[player] == 'expert' and not fullrefill: elif self.multiworld.item_functionality[player] == 'expert' and not fullrefill:
basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player)) basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player))
else: else:
basemagic = basemagic + basemagic * self.bottle_count(player) basemagic = basemagic + basemagic * self.bottle_count(player)
@@ -783,12 +805,12 @@ class CollectionState():
or (self.has('Bombs (10)', player) and enemies < 6)) or (self.has('Bombs (10)', player) and enemies < 6))
def can_shoot_arrows(self, player: int) -> bool: def can_shoot_arrows(self, player: int) -> bool:
if self.world.retro_bow[player]: if self.multiworld.retro_bow[player]:
return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player) return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player)
return self.has('Bow', player) or self.has('Silver Bow', player) return self.has('Bow', player) or self.has('Silver Bow', player)
def can_get_good_bee(self, player: int) -> bool: def can_get_good_bee(self, player: int) -> bool:
cave = self.world.get_region('Good Bee Cave', player) cave = self.multiworld.get_region('Good Bee Cave', player)
return ( return (
self.has_group("Bottles", player) and self.has_group("Bottles", player) and
self.has('Bug Catching Net', player) and self.has('Bug Catching Net', player) and
@@ -799,7 +821,7 @@ class CollectionState():
def can_retrieve_tablet(self, player: int) -> bool: def can_retrieve_tablet(self, player: int) -> bool:
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
(self.world.swordless[player] and (self.multiworld.swordless[player] and
self.has("Hammer", player))) self.has("Hammer", player)))
def has_sword(self, player: int) -> bool: def has_sword(self, player: int) -> bool:
@@ -821,7 +843,7 @@ class CollectionState():
def can_melt_things(self, player: int) -> bool: def can_melt_things(self, player: int) -> bool:
return self.has('Fire Rod', player) or \ return self.has('Fire Rod', player) or \
(self.has('Bombos', player) and (self.has('Bombos', player) and
(self.world.swordless[player] or (self.multiworld.swordless[player] or
self.has_sword(player))) self.has_sword(player)))
def can_avoid_lasers(self, player: int) -> bool: def can_avoid_lasers(self, player: int) -> bool:
@@ -831,7 +853,7 @@ class CollectionState():
if self.has('Moon Pearl', player): if self.has('Moon Pearl', player):
return True return True
return region.is_light_world if self.world.mode[player] != 'inverted' else region.is_dark_world return region.is_light_world if self.multiworld.mode[player] != 'inverted' else region.is_dark_world
def can_reach_light_world(self, player: int) -> bool: def can_reach_light_world(self, player: int) -> bool:
if True in [i.is_light_world for i in self.reachable_regions[player]]: if True in [i.is_light_world for i in self.reachable_regions[player]]:
@@ -844,24 +866,24 @@ class CollectionState():
return False return False
def has_misery_mire_medallion(self, player: int) -> bool: def has_misery_mire_medallion(self, player: int) -> bool:
return self.has(self.world.required_medallions[player][0], player) return self.has(self.multiworld.required_medallions[player][0], player)
def has_turtle_rock_medallion(self, player: int) -> bool: def has_turtle_rock_medallion(self, player: int) -> bool:
return self.has(self.world.required_medallions[player][1], player) return self.has(self.multiworld.required_medallions[player][1], player)
def can_boots_clip_lw(self, player: int) -> bool: def can_boots_clip_lw(self, player: int) -> bool:
if self.world.mode[player] == 'inverted': if self.multiworld.mode[player] == 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player) return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player) return self.has('Pegasus Boots', player)
def can_boots_clip_dw(self, player: int) -> bool: def can_boots_clip_dw(self, player: int) -> bool:
if self.world.mode[player] != 'inverted': if self.multiworld.mode[player] != 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player) return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player) return self.has('Pegasus Boots', player)
def can_get_glitched_speed_lw(self, player: int) -> bool: def can_get_glitched_speed_lw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])] rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.world.mode[player] == 'inverted': if self.multiworld.mode[player] == 'inverted':
rules.append(self.has('Moon Pearl', player)) rules.append(self.has('Moon Pearl', player))
return all(rules) return all(rules)
@@ -870,7 +892,7 @@ class CollectionState():
def can_get_glitched_speed_dw(self, player: int) -> bool: def can_get_glitched_speed_dw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])] rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.world.mode[player] != 'inverted': if self.multiworld.mode[player] != 'inverted':
rules.append(self.has('Moon Pearl', player)) rules.append(self.has('Moon Pearl', player))
return all(rules) return all(rules)
@@ -881,7 +903,7 @@ class CollectionState():
if location: if location:
self.locations_checked.add(location) self.locations_checked.add(location)
changed = self.world.worlds[item.player].collect(self, item) changed = self.multiworld.worlds[item.player].collect(self, item)
if not changed and event: if not changed and event:
self.prog_items[item.name, item.player] += 1 self.prog_items[item.name, item.player] += 1
@@ -895,7 +917,7 @@ class CollectionState():
return changed return changed
def remove(self, item: Item): def remove(self, item: Item):
changed = self.world.worlds[item.player].remove(self, item) changed = self.multiworld.worlds[item.player].remove(self, item)
if changed: if changed:
# invalidate caches, nothing can be trusted anymore now # invalidate caches, nothing can be trusted anymore now
self.reachable_regions[item.player] = set() self.reachable_regions[item.player] = set()
@@ -922,7 +944,7 @@ class Region:
type: RegionType type: RegionType
hint_text: str hint_text: str
player: int player: int
world: Optional[MultiWorld] multiworld: Optional[MultiWorld]
entrances: List[Entrance] entrances: List[Entrance]
exits: List[Entrance] exits: List[Entrance]
locations: List[Location] locations: List[Location]
@@ -940,7 +962,7 @@ class Region:
self.entrances = [] self.entrances = []
self.exits = [] self.exits = []
self.locations = [] self.locations = []
self.world = world self.multiworld = world
self.hint_text = hint self.hint_text = hint
self.player = player self.player = player
@@ -957,11 +979,18 @@ class Region:
return True return True
return False return False
def get_connecting_entrance(self, is_main_entrance: typing.Callable[[Entrance], bool]) -> Entrance:
for entrance in self.entrances:
if is_main_entrance(entrance):
return entrance
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
def __repr__(self): def __repr__(self):
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})' return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
class Entrance: class Entrance:
@@ -988,7 +1017,7 @@ class Entrance:
return False return False
def connect(self, region: Region, addresses=None, target=None): def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
self.connected_region = region self.connected_region = region
self.target = target self.target = target
self.addresses = addresses self.addresses = addresses
@@ -998,7 +1027,7 @@ class Entrance:
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
world = self.parent_region.world if self.parent_region else None world = self.parent_region.multiworld if self.parent_region else None
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
@@ -1012,7 +1041,7 @@ class Dungeon(object):
self.dungeon_items = dungeon_items self.dungeon_items = dungeon_items
self.bosses = dict() self.bosses = dict()
self.player = player self.player = player
self.world = None self.multiworld = None
@property @property
def boss(self) -> Optional[Boss]: def boss(self) -> Optional[Boss]:
@@ -1042,7 +1071,7 @@ class Dungeon(object):
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})' return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
class Boss(): class Boss():
@@ -1066,35 +1095,36 @@ class LocationProgressType(IntEnum):
class Location: class Location:
# If given as integer, then this is the shop's inventory index game: str = "Generic"
shop_slot: Optional[int] = None player: int
shop_slot_disabled: bool = False name: str
address: Optional[int]
parent_region: Optional[Region]
event: bool = False event: bool = False
locked: bool = False locked: bool = False
game: str = "Generic"
show_in_spoiler: bool = True show_in_spoiler: bool = True
crystal: bool = False
progress_type: LocationProgressType = LocationProgressType.DEFAULT progress_type: LocationProgressType = LocationProgressType.DEFAULT
always_allow = staticmethod(lambda item, state: False) always_allow = staticmethod(lambda item, state: False)
access_rule = staticmethod(lambda state: True) access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
item_rule = staticmethod(lambda item: True) item_rule = staticmethod(lambda item: True)
item: Optional[Item] = None item: Optional[Item] = None
parent_region: Optional[Region]
def __init__(self, player: int, name: str = '', address: int = None, parent=None): def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
self.name: str = name self.player = player
self.address: Optional[int] = address self.name = name
self.address = address
self.parent_region = parent self.parent_region = parent
self.player: int = player
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool: def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state))) return (self.always_allow(state, item)
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
and self.item_rule(item)
and (not check_access or self.can_reach(state))))
def can_reach(self, state: CollectionState) -> bool: def can_reach(self, state: CollectionState) -> bool:
# self.access_rule computes faster on average, so placing it first for faster abort # self.access_rule computes faster on average, so placing it first for faster abort
if self.access_rule(state) and self.parent_region.can_reach(state): assert self.parent_region, "Can't reach location without region"
return True return self.access_rule(state) and self.parent_region.can_reach(state)
return False
def place_locked_item(self, item: Item): def place_locked_item(self, item: Item):
if self.item: if self.item:
@@ -1102,14 +1132,13 @@ class Location:
self.item = item self.item = item
item.location = self item.location = self
self.event = item.advancement self.event = item.advancement
self.item.world = self.parent_region.world
self.locked = True self.locked = True
def __repr__(self): def __repr__(self):
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
world = self.parent_region.world if self.parent_region and self.parent_region.world else None world = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})'
def __hash__(self): def __hash__(self):
@@ -1147,39 +1176,28 @@ class ItemClassification(IntFlag):
class Item: class Item:
location: Optional[Location] = None
world: Optional[MultiWorld] = None
code: Optional[int] = None # an item with ID None is called an Event, and does not get written to multidata
name: str
game: str = "Generic" game: str = "Generic"
type: str = None __slots__ = ("name", "classification", "code", "player", "location")
name: str
classification: ItemClassification classification: ItemClassification
code: Optional[int]
# need to find a decent place for these to live and to allow other games to register texts if they want. """an item with code None is called an Event, and does not get written to multidata"""
pedestal_credit_text: str = "and the Unknown Item" player: int
sickkid_credit_text: Optional[str] = None location: Optional[Location]
magicshop_credit_text: Optional[str] = None
zora_credit_text: Optional[str] = None
fluteboy_credit_text: Optional[str] = None
# hopefully temporary attributes to satisfy legacy LttP code, proper implementation in subclass ALttPItem
smallkey: bool = False
bigkey: bool = False
map: bool = False
compass: bool = False
def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int): def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int):
self.name = name self.name = name
self.classification = classification self.classification = classification
self.player = player self.player = player
self.code = code self.code = code
self.location = None
@property @property
def hint_text(self): def hint_text(self) -> str:
return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " ")) return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
@property @property
def pedestal_hint_text(self): def pedestal_hint_text(self) -> str:
return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " ")) return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " "))
@property @property
@@ -1205,7 +1223,7 @@ class Item:
def __eq__(self, other): def __eq__(self, other):
return self.name == other.name and self.player == other.player return self.name == other.name and self.player == other.player
def __lt__(self, other: Item): def __lt__(self, other: Item) -> bool:
if other.player != self.player: if other.player != self.player:
return other.player < self.player return other.player < self.player
return self.name < other.name return self.name < other.name
@@ -1213,19 +1231,21 @@ class Item:
def __hash__(self): def __hash__(self):
return hash((self.name, self.player)) return hash((self.name, self.player))
def __repr__(self): def __repr__(self) -> str:
return self.__str__() return self.__str__()
def __str__(self): def __str__(self) -> str:
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})' if self.location and self.location.parent_region and self.location.parent_region.multiworld:
return self.location.parent_region.multiworld.get_name_string_for_object(self)
return f"{self.name} (Player {self.player})"
class Spoiler(): class Spoiler():
world: MultiWorld multiworld: MultiWorld
unreachables: Set[Location] unreachables: Set[Location]
def __init__(self, world): def __init__(self, world):
self.world = world self.multiworld = world
self.hashes = {} self.hashes = {}
self.entrances = OrderedDict() self.entrances = OrderedDict()
self.medallions = {} self.medallions = {}
@@ -1237,7 +1257,7 @@ class Spoiler():
self.bosses = OrderedDict() self.bosses = OrderedDict()
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int): def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
if self.world.players == 1: if self.multiworld.players == 1:
self.entrances[(entrance, direction, player)] = OrderedDict( self.entrances[(entrance, direction, player)] = OrderedDict(
[('entrance', entrance), ('exit', exit_), ('direction', direction)]) [('entrance', entrance), ('exit', exit_), ('direction', direction)])
else: else:
@@ -1246,45 +1266,45 @@ class Spoiler():
def parse_data(self): def parse_data(self):
self.medallions = OrderedDict() self.medallions = OrderedDict()
for player in self.world.get_game_players("A Link to the Past"): for player in self.multiworld.get_game_players("A Link to the Past"):
self.medallions[f'Misery Mire ({self.world.get_player_name(player)})'] = \ self.medallions[f'Misery Mire ({self.multiworld.get_player_name(player)})'] = \
self.world.required_medallions[player][0] self.multiworld.required_medallions[player][0]
self.medallions[f'Turtle Rock ({self.world.get_player_name(player)})'] = \ self.medallions[f'Turtle Rock ({self.multiworld.get_player_name(player)})'] = \
self.world.required_medallions[player][1] self.multiworld.required_medallions[player][1]
self.locations = OrderedDict() self.locations = OrderedDict()
listed_locations = set() listed_locations = set()
lw_locations = [loc for loc in self.world.get_locations() if lw_locations = [loc for loc in self.multiworld.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.LightWorld and loc.show_in_spoiler] loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.LightWorld and loc.show_in_spoiler]
self.locations['Light World'] = OrderedDict( self.locations['Light World'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
lw_locations]) lw_locations])
listed_locations.update(lw_locations) listed_locations.update(lw_locations)
dw_locations = [loc for loc in self.world.get_locations() if dw_locations = [loc for loc in self.multiworld.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.DarkWorld and loc.show_in_spoiler] loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.DarkWorld and loc.show_in_spoiler]
self.locations['Dark World'] = OrderedDict( self.locations['Dark World'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
dw_locations]) dw_locations])
listed_locations.update(dw_locations) listed_locations.update(dw_locations)
cave_locations = [loc for loc in self.world.get_locations() if cave_locations = [loc for loc in self.multiworld.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.Cave and loc.show_in_spoiler] loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.Cave and loc.show_in_spoiler]
self.locations['Caves'] = OrderedDict( self.locations['Caves'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
cave_locations]) cave_locations])
listed_locations.update(cave_locations) listed_locations.update(cave_locations)
for dungeon in self.world.dungeons.values(): for dungeon in self.multiworld.dungeons.values():
dungeon_locations = [loc for loc in self.world.get_locations() if dungeon_locations = [loc for loc in self.multiworld.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler] loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler]
self.locations[str(dungeon)] = OrderedDict( self.locations[str(dungeon)] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in [(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
dungeon_locations]) dungeon_locations])
listed_locations.update(dungeon_locations) listed_locations.update(dungeon_locations)
other_locations = [loc for loc in self.world.get_locations() if other_locations = [loc for loc in self.multiworld.get_locations() if
loc not in listed_locations and loc.show_in_spoiler] loc not in listed_locations and loc.show_in_spoiler]
if other_locations: if other_locations:
self.locations['Other Locations'] = OrderedDict( self.locations['Other Locations'] = OrderedDict(
@@ -1294,7 +1314,7 @@ class Spoiler():
self.shops = [] self.shops = []
from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display
for shop in self.world.shops: for shop in self.multiworld.shops:
if not shop.custom: if not shop.custom:
continue continue
shopdata = { shopdata = {
@@ -1323,34 +1343,34 @@ class Spoiler():
index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}" index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}"
self.shops.append(shopdata) self.shops.append(shopdata)
for player in self.world.get_game_players("A Link to the Past"): for player in self.multiworld.get_game_players("A Link to the Past"):
self.bosses[str(player)] = OrderedDict() self.bosses[str(player)] = OrderedDict()
self.bosses[str(player)]["Eastern Palace"] = self.world.get_dungeon("Eastern Palace", player).boss.name self.bosses[str(player)]["Eastern Palace"] = self.multiworld.get_dungeon("Eastern Palace", player).boss.name
self.bosses[str(player)]["Desert Palace"] = self.world.get_dungeon("Desert Palace", player).boss.name self.bosses[str(player)]["Desert Palace"] = self.multiworld.get_dungeon("Desert Palace", player).boss.name
self.bosses[str(player)]["Tower Of Hera"] = self.world.get_dungeon("Tower of Hera", player).boss.name self.bosses[str(player)]["Tower Of Hera"] = self.multiworld.get_dungeon("Tower of Hera", player).boss.name
self.bosses[str(player)]["Hyrule Castle"] = "Agahnim" self.bosses[str(player)]["Hyrule Castle"] = "Agahnim"
self.bosses[str(player)]["Palace Of Darkness"] = self.world.get_dungeon("Palace of Darkness", self.bosses[str(player)]["Palace Of Darkness"] = self.multiworld.get_dungeon("Palace of Darkness",
player).boss.name player).boss.name
self.bosses[str(player)]["Swamp Palace"] = self.world.get_dungeon("Swamp Palace", player).boss.name self.bosses[str(player)]["Swamp Palace"] = self.multiworld.get_dungeon("Swamp Palace", player).boss.name
self.bosses[str(player)]["Skull Woods"] = self.world.get_dungeon("Skull Woods", player).boss.name self.bosses[str(player)]["Skull Woods"] = self.multiworld.get_dungeon("Skull Woods", player).boss.name
self.bosses[str(player)]["Thieves Town"] = self.world.get_dungeon("Thieves Town", player).boss.name self.bosses[str(player)]["Thieves Town"] = self.multiworld.get_dungeon("Thieves Town", player).boss.name
self.bosses[str(player)]["Ice Palace"] = self.world.get_dungeon("Ice Palace", player).boss.name self.bosses[str(player)]["Ice Palace"] = self.multiworld.get_dungeon("Ice Palace", player).boss.name
self.bosses[str(player)]["Misery Mire"] = self.world.get_dungeon("Misery Mire", player).boss.name self.bosses[str(player)]["Misery Mire"] = self.multiworld.get_dungeon("Misery Mire", player).boss.name
self.bosses[str(player)]["Turtle Rock"] = self.world.get_dungeon("Turtle Rock", player).boss.name self.bosses[str(player)]["Turtle Rock"] = self.multiworld.get_dungeon("Turtle Rock", player).boss.name
if self.world.mode[player] != 'inverted': if self.multiworld.mode[player] != 'inverted':
self.bosses[str(player)]["Ganons Tower Basement"] = \ self.bosses[str(player)]["Ganons Tower Basement"] = \
self.world.get_dungeon('Ganons Tower', player).bosses['bottom'].name self.multiworld.get_dungeon('Ganons Tower', player).bosses['bottom'].name
self.bosses[str(player)]["Ganons Tower Middle"] = self.world.get_dungeon('Ganons Tower', player).bosses[ self.bosses[str(player)]["Ganons Tower Middle"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
'middle'].name 'middle'].name
self.bosses[str(player)]["Ganons Tower Top"] = self.world.get_dungeon('Ganons Tower', player).bosses[ self.bosses[str(player)]["Ganons Tower Top"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
'top'].name 'top'].name
else: else:
self.bosses[str(player)]["Ganons Tower Basement"] = \ self.bosses[str(player)]["Ganons Tower Basement"] = \
self.world.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name
self.bosses[str(player)]["Ganons Tower Middle"] = \ self.bosses[str(player)]["Ganons Tower Middle"] = \
self.world.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name
self.bosses[str(player)]["Ganons Tower Top"] = \ self.bosses[str(player)]["Ganons Tower Top"] = \
self.world.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name
self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2" self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2"
self.bosses[str(player)]["Ganon"] = "Ganon" self.bosses[str(player)]["Ganon"] = "Ganon"
@@ -1380,7 +1400,7 @@ class Spoiler():
return 'Yes' if variable else 'No' return 'Yes' if variable else 'No'
def write_option(option_key: str, option_obj: type(Options.Option)): def write_option(option_key: str, option_obj: type(Options.Option)):
res = getattr(self.world, option_key)[player] res = getattr(self.multiworld, option_key)[player]
display_name = getattr(option_obj, "display_name", option_key) display_name = getattr(option_obj, "display_name", option_key)
try: try:
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n') outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
@@ -1390,60 +1410,59 @@ class Spoiler():
with open(filename, 'w', encoding="utf-8-sig") as outfile: with open(filename, 'w', encoding="utf-8-sig") as outfile:
outfile.write( outfile.write(
'Archipelago Version %s - Seed: %s\n\n' % ( 'Archipelago Version %s - Seed: %s\n\n' % (
Utils.__version__, self.world.seed)) Utils.__version__, self.multiworld.seed))
outfile.write('Filling Algorithm: %s\n' % self.world.algorithm) outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
outfile.write('Players: %d\n' % self.world.players) outfile.write('Players: %d\n' % self.multiworld.players)
AutoWorld.call_stage(self.world, "write_spoiler_header", outfile) AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
for player in range(1, self.world.players + 1): for player in range(1, self.multiworld.players + 1):
if self.world.players > 1: if self.multiworld.players > 1:
outfile.write('\nPlayer %d: %s\n' % (player, self.world.get_player_name(player))) outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.world.game[player]) outfile.write('Game: %s\n' % self.multiworld.game[player])
for f_option, option in Options.per_game_common_options.items(): for f_option, option in Options.per_game_common_options.items():
write_option(f_option, option) write_option(f_option, option)
options = self.world.worlds[player].options options = self.multiworld.worlds[player].option_definitions
if options: if options:
for f_option, option in options.items(): for f_option, option in options.items():
write_option(f_option, option) write_option(f_option, option)
AutoWorld.call_single(self.world, "write_spoiler_header", player, outfile) AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)
if player in self.world.get_game_players("A Link to the Past"): if player in self.multiworld.get_game_players("A Link to the Past"):
outfile.write('%s%s\n' % ('Hash: ', self.hashes[player])) outfile.write('%s%s\n' % ('Hash: ', self.hashes[player]))
outfile.write('Logic: %s\n' % self.world.logic[player]) outfile.write('Logic: %s\n' % self.multiworld.logic[player])
outfile.write('Dark Room Logic: %s\n' % self.world.dark_room_logic[player]) outfile.write('Dark Room Logic: %s\n' % self.multiworld.dark_room_logic[player])
outfile.write('Mode: %s\n' % self.world.mode[player]) outfile.write('Mode: %s\n' % self.multiworld.mode[player])
outfile.write('Goal: %s\n' % self.world.goal[player]) outfile.write('Goal: %s\n' % self.multiworld.goal[player])
if "triforce" in self.world.goal[player]: # triforce hunt if "triforce" in self.multiworld.goal[player]: # triforce hunt
outfile.write("Pieces available for Triforce: %s\n" % outfile.write("Pieces available for Triforce: %s\n" %
self.world.triforce_pieces_available[player]) self.multiworld.triforce_pieces_available[player])
outfile.write("Pieces required for Triforce: %s\n" % outfile.write("Pieces required for Triforce: %s\n" %
self.world.triforce_pieces_required[player]) self.multiworld.triforce_pieces_required[player])
outfile.write('Difficulty: %s\n' % self.world.difficulty[player]) outfile.write('Difficulty: %s\n' % self.multiworld.difficulty[player])
outfile.write('Item Functionality: %s\n' % self.world.item_functionality[player]) outfile.write('Item Functionality: %s\n' % self.multiworld.item_functionality[player])
outfile.write('Entrance Shuffle: %s\n' % self.world.shuffle[player]) outfile.write('Entrance Shuffle: %s\n' % self.multiworld.shuffle[player])
if self.world.shuffle[player] != "vanilla": if self.multiworld.shuffle[player] != "vanilla":
outfile.write('Entrance Shuffle Seed %s\n' % self.world.worlds[player].er_seed) outfile.write('Entrance Shuffle Seed %s\n' % self.multiworld.worlds[player].er_seed)
outfile.write('Shop inventory shuffle: %s\n' % outfile.write('Shop inventory shuffle: %s\n' %
bool_to_text("i" in self.world.shop_shuffle[player])) bool_to_text("i" in self.multiworld.shop_shuffle[player]))
outfile.write('Shop price shuffle: %s\n' % outfile.write('Shop price shuffle: %s\n' %
bool_to_text("p" in self.world.shop_shuffle[player])) bool_to_text("p" in self.multiworld.shop_shuffle[player]))
outfile.write('Shop upgrade shuffle: %s\n' % outfile.write('Shop upgrade shuffle: %s\n' %
bool_to_text("u" in self.world.shop_shuffle[player])) bool_to_text("u" in self.multiworld.shop_shuffle[player]))
outfile.write('New Shop inventory: %s\n' % outfile.write('New Shop inventory: %s\n' %
bool_to_text("g" in self.world.shop_shuffle[player] or bool_to_text("g" in self.multiworld.shop_shuffle[player] or
"f" in self.world.shop_shuffle[player])) "f" in self.multiworld.shop_shuffle[player]))
outfile.write('Custom Potion Shop: %s\n' % outfile.write('Custom Potion Shop: %s\n' %
bool_to_text("w" in self.world.shop_shuffle[player])) bool_to_text("w" in self.multiworld.shop_shuffle[player]))
outfile.write('Boss shuffle: %s\n' % self.world.boss_shuffle[player]) outfile.write('Enemy health: %s\n' % self.multiworld.enemy_health[player])
outfile.write('Enemy health: %s\n' % self.world.enemy_health[player]) outfile.write('Enemy damage: %s\n' % self.multiworld.enemy_damage[player])
outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player])
outfile.write('Prize shuffle %s\n' % outfile.write('Prize shuffle %s\n' %
self.world.shuffle_prizes[player]) self.multiworld.shuffle_prizes[player])
if self.entrances: if self.entrances:
outfile.write('\n\nEntrances:\n\n') outfile.write('\n\nEntrances:\n\n')
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.world.get_player_name(entry["player"])}: ' outfile.write('\n'.join(['%s%s %s %s' % (f'{self.multiworld.get_player_name(entry["player"])}: '
if self.world.players > 1 else '', entry['entrance'], if self.multiworld.players > 1 else '', entry['entrance'],
'<=>' if entry['direction'] == 'both' else '<=>' if entry['direction'] == 'both' else
'<=' if entry['direction'] == 'exit' else '=>', '<=' if entry['direction'] == 'exit' else '=>',
entry['exit']) for entry in self.entrances.values()])) entry['exit']) for entry in self.entrances.values()]))
@@ -1453,7 +1472,7 @@ class Spoiler():
for dungeon, medallion in self.medallions.items(): for dungeon, medallion in self.medallions.items():
outfile.write(f'\n{dungeon}: {medallion}') outfile.write(f'\n{dungeon}: {medallion}')
AutoWorld.call_all(self.world, "write_spoiler", outfile) AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
outfile.write('\n\nLocations:\n\n') outfile.write('\n\nLocations:\n\n')
outfile.write('\n'.join( outfile.write('\n'.join(
@@ -1466,11 +1485,11 @@ class Spoiler():
item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if
item)) for shop in self.shops)) item)) for shop in self.shops))
for player in self.world.get_game_players("A Link to the Past"): for player in self.multiworld.get_game_players("A Link to the Past"):
if self.world.boss_shuffle[player] != 'none': if self.multiworld.boss_shuffle[player] != 'none':
bossmap = self.bosses[str(player)] if self.world.players > 1 else self.bosses bossmap = self.bosses[str(player)] if self.multiworld.players > 1 else self.bosses
outfile.write( outfile.write(
f'\n\nBosses{(f" ({self.world.get_player_name(player)})" if self.world.players > 1 else "")}:\n') f'\n\nBosses{(f" ({self.multiworld.get_player_name(player)})" if self.multiworld.players > 1 else "")}:\n')
outfile.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()])) outfile.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()]))
outfile.write('\n\nPlaythrough:\n\n') outfile.write('\n\nPlaythrough:\n\n')
outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join( outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join(
@@ -1494,7 +1513,7 @@ class Spoiler():
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines))) path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
outfile.write('\n'.join(path_listings)) outfile.write('\n'.join(path_listings))
AutoWorld.call_all(self.world, "write_spoiler_end", outfile) AutoWorld.call_all(self.multiworld, "write_spoiler_end", outfile)
class Tutorial(NamedTuple): class Tutorial(NamedTuple):

View File

@@ -5,6 +5,7 @@ import urllib.parse
import sys import sys
import typing import typing
import time import time
import functools
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -17,11 +18,15 @@ if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client") Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
from Utils import Version, stream_input ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
import os import os
if typing.TYPE_CHECKING:
import kvui
logger = logging.getLogger("Client") logger = logging.getLogger("Client")
# without terminal, we have to use gui mode # without terminal, we have to use gui mode
@@ -42,16 +47,18 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_connect(self, address: str = "") -> bool: def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server""" """Connect to a MultiWorld Server"""
self.ctx.server_address = None if address:
self.ctx.username = None self.ctx.server_address = None
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting") self.ctx.username = None
elif not self.ctx.server_address:
self.output("Please specify an address.")
return False
async_start(self.ctx.connect(address if address else None), name="connecting")
return True return True
def _cmd_disconnect(self) -> bool: def _cmd_disconnect(self) -> bool:
"""Disconnect from a MultiWorld Server""" """Disconnect from a MultiWorld Server"""
self.ctx.server_address = None async_start(self.ctx.disconnect(), name="disconnecting")
self.ctx.username = None
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
return True return True
def _cmd_received(self) -> bool: def _cmd_received(self) -> bool:
@@ -89,12 +96,18 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_items(self): def _cmd_items(self):
"""List all item names for the currently running game.""" """List all item names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing items.")
return False
self.output(f"Item Names for {self.ctx.game}") self.output(f"Item Names for {self.ctx.game}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id: for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name) self.output(item_name)
def _cmd_locations(self): def _cmd_locations(self):
"""List all location names for the currently running game.""" """List all location names for the currently running game."""
if not self.ctx.game:
self.output("No game set, cannot determine existing locations.")
return False
self.output(f"Location Names for {self.ctx.game}") self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id: for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name) self.output(location_name)
@@ -108,12 +121,12 @@ class ClientCommandProcessor(CommandProcessor):
else: else:
state = ClientStatus.CLIENT_CONNECTED state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.") self.output("Unreadied.")
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") async_start(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
def default(self, raw: str): def default(self, raw: str):
raw = self.ctx.on_user_say(raw) raw = self.ctx.on_user_say(raw)
if raw: if raw:
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say") async_start(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext: class CommonContext:
@@ -130,37 +143,48 @@ class CommonContext:
# defaults # defaults
starting_reconnect_delay: int = 5 starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay current_reconnect_delay: int = starting_reconnect_delay
command_processor: type(CommandProcessor) = ClientCommandProcessor command_processor: typing.Type[CommandProcessor] = ClientCommandProcessor
ui = None ui = None
ui_task: typing.Optional[asyncio.Task] = None ui_task: typing.Optional["asyncio.Task[None]"] = None
input_task: typing.Optional[asyncio.Task] = None input_task: typing.Optional["asyncio.Task[None]"] = None
keep_alive_task: typing.Optional[asyncio.Task] = None keep_alive_task: typing.Optional["asyncio.Task[None]"] = None
server_task: typing.Optional[asyncio.Task] = None server_task: typing.Optional["asyncio.Task[None]"] = None
autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None
disconnected_intentionally: bool = False
server: typing.Optional[Endpoint] = None server: typing.Optional[Endpoint] = None
server_version: Version = Version(0, 0, 0) server_version: Version = Version(0, 0, 0)
current_energy_link_value: int = 0 # to display in UI, gets set by server current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
last_death_link: float = time.time() # last send/received death link on AP layer last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info # remaining type info
slot_info: typing.Dict[int, NetworkSlot] slot_info: typing.Dict[int, NetworkSlot]
server_address: str server_address: typing.Optional[str]
password: typing.Optional[str] password: typing.Optional[str]
hint_cost: typing.Optional[int] hint_cost: typing.Optional[int]
player_names: typing.Dict[int, str] player_names: typing.Dict[int, str]
finished_game: bool
ready: bool
auth: typing.Optional[str]
seed_name: typing.Optional[str]
# locations # locations
locations_checked: typing.Set[int] # local state locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int] locations_scouted: typing.Set[int]
missing_locations: typing.Set[int] items_received: typing.List[NetworkItem]
missing_locations: typing.Set[int] # server state
checked_locations: typing.Set[int] # server state checked_locations: typing.Set[int] # server state
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
locations_info: typing.Dict[int, NetworkItem] locations_info: typing.Dict[int, NetworkItem]
# internals # internals
# current message box through kvui # current message box through kvui
_messagebox = None _messagebox: typing.Optional["kvui.MessageBox"] = None
# message box reporting a loss of connection
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
def __init__(self, server_address, password): def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
# server state # server state
self.server_address = server_address self.server_address = server_address
self.username = None self.username = None
@@ -184,8 +208,9 @@ class CommonContext:
self.locations_checked = set() # local state self.locations_checked = set() # local state
self.locations_scouted = set() self.locations_scouted = set()
self.items_received = [] self.items_received = []
self.missing_locations = set() self.missing_locations = set() # server state
self.checked_locations = set() # server state self.checked_locations = set() # server state
self.server_locations = set() # all locations the server knows of, missing_location | checked_locations
self.locations_info = {} self.locations_info = {}
self.input_queue = asyncio.Queue() self.input_queue = asyncio.Queue()
@@ -202,6 +227,16 @@ class CommonContext:
# execution # execution
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy") self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@property
def suggested_address(self) -> str:
if self.server_address:
return self.server_address
return Utils.persistent_load().get("client", {}).get("last_server_address", "")
@functools.cached_property
def raw_text_parser(self) -> RawJSONtoTextParser:
return RawJSONtoTextParser(self)
@property @property
def total_locations(self) -> typing.Optional[int]: def total_locations(self) -> typing.Optional[int]:
"""Will return None until connected.""" """Will return None until connected."""
@@ -209,9 +244,9 @@ class CommonContext:
return len(self.checked_locations | self.missing_locations) return len(self.checked_locations | self.missing_locations)
async def connection_closed(self): async def connection_closed(self):
self.reset_server_state()
if self.server and self.server.socket is not None: if self.server and self.server.socket is not None:
await self.server.socket.close() await self.server.socket.close()
self.reset_server_state()
def reset_server_state(self): def reset_server_state(self):
self.auth = None self.auth = None
@@ -229,13 +264,18 @@ class CommonContext:
"remaining": "disabled", "remaining": "disabled",
} }
async def disconnect(self): async def disconnect(self, allow_autoreconnect: bool = False):
if not allow_autoreconnect:
self.disconnected_intentionally = True
if self.cancel_autoreconnect():
logger.info("Cancelled auto-reconnect.")
if self.server and not self.server.socket.closed: if self.server and not self.server.socket.closed:
await self.server.socket.close() await self.server.socket.close()
if self.server_task is not None: if self.server_task is not None:
await self.server_task await self.server_task
async def send_msgs(self, msgs): async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
""" `msgs` JSON serializable """
if not self.server or not self.server.socket.open or self.server.socket.closed: if not self.server or not self.server.socket.open or self.server.socket.closed:
return return
await self.server.socket.send(encode(msgs)) await self.server.socket.send(encode(msgs))
@@ -263,7 +303,8 @@ class CommonContext:
logger.info('Enter slot name:') logger.info('Enter slot name:')
self.auth = await self.console_input() self.auth = await self.console_input()
async def send_connect(self, **kwargs): async def send_connect(self, **kwargs: typing.Any) -> None:
""" send `Connect` packet to log in to server """
payload = { payload = {
'cmd': 'Connect', 'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
@@ -274,14 +315,24 @@ class CommonContext:
payload.update(kwargs) payload.update(kwargs)
await self.send_msgs([payload]) await self.send_msgs([payload])
async def console_input(self): async def console_input(self) -> str:
if self.ui:
self.ui.focus_textinput()
self.input_requests += 1 self.input_requests += 1
return await self.input_queue.get() return await self.input_queue.get()
async def connect(self, address=None): async def connect(self, address: typing.Optional[str] = None) -> None:
""" disconnect any previous connection, and open new connection to the server """
await self.disconnect() await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop") self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
def cancel_autoreconnect(self) -> bool:
if self.autoreconnect_task:
self.autoreconnect_task.cancel()
self.autoreconnect_task = None
return True
return False
def slot_concerns_self(self, slot) -> bool: def slot_concerns_self(self, slot) -> bool:
if slot == self.slot: if slot == self.slot:
return True return True
@@ -289,6 +340,12 @@ class CommonContext:
return self.slot in self.slot_info[slot].group_members return self.slot in self.slot_info[slot].group_members
return False return False
def is_uninteresting_item_send(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out ItemSend prints that do not concern the local player."""
return print_json_packet.get("type", "") == "ItemSend" \
and not self.slot_concerns_self(print_json_packet["receiving"]) \
and not self.slot_concerns_self(print_json_packet["item"].player)
def on_print(self, args: dict): def on_print(self, args: dict):
logger.info(args["text"]) logger.info(args["text"])
@@ -320,6 +377,7 @@ class CommonContext:
async def shutdown(self): async def shutdown(self):
self.server_address = "" self.server_address = ""
self.username = None self.username = None
self.cancel_autoreconnect()
if self.server and not self.server.socket.closed: if self.server and not self.server.socket.closed:
await self.server.socket.close() await self.server.socket.close()
if self.server_task: if self.server_task:
@@ -345,6 +403,8 @@ class CommonContext:
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {}) cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
needed_updates: typing.Set[str] = set() needed_updates: typing.Set[str] = set()
for game in relevant_games: for game in relevant_games:
if game not in remote_datepackage_versions:
continue
remote_version: int = remote_datepackage_versions[game] remote_version: int = remote_datepackage_versions[game]
if remote_version == 0: # custom datapackage for this game if remote_version == 0: # custom datapackage for this game
@@ -380,7 +440,7 @@ class CommonContext:
# DeathLink hooks # DeathLink hooks
def on_deathlink(self, data: dict): def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
"""Gets dispatched when a new DeathLink is triggered by another linked player.""" """Gets dispatched when a new DeathLink is triggered by another linked player."""
self.last_death_link = max(data["time"], self.last_death_link) self.last_death_link = max(data["time"], self.last_death_link)
text = data.get("cause", "") text = data.get("cause", "")
@@ -411,10 +471,10 @@ class CommonContext:
if old_tags != self.tags and self.server and not self.server.socket.closed: if old_tags != self.tags and self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]) await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
def gui_error(self, title: str, text: typing.Union[Exception, str]): def gui_error(self, title: str, text: typing.Union[Exception, str]) -> typing.Optional["kvui.MessageBox"]:
"""Displays an error messagebox""" """Displays an error messagebox"""
if not self.ui: if not self.ui:
return return None
title = title or "Error" title = title or "Error"
from kvui import MessageBox from kvui import MessageBox
if self._messagebox: if self._messagebox:
@@ -431,6 +491,13 @@ class CommonContext:
# display error # display error
self._messagebox = MessageBox(title, text, error=True) self._messagebox = MessageBox(title, text, error=True)
self._messagebox.open() self._messagebox.open()
return self._messagebox
def _handle_connection_loss(self, msg: str) -> None:
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
exc_info = sys.exc_info()
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
def run_gui(self): def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task.""" """Import kivy UI system and start running it as self.ui_task."""
@@ -467,7 +534,7 @@ async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
seconds_elapsed = 0 seconds_elapsed = 0
async def server_loop(ctx: CommonContext, address=None): async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) -> None:
if ctx.server and ctx.server.socket: if ctx.server and ctx.server.socket:
logger.error('Already connected') logger.error('Already connected')
return return
@@ -480,6 +547,11 @@ async def server_loop(ctx: CommonContext, address=None):
logger.info('Please connect to an Archipelago server.') logger.info('Please connect to an Archipelago server.')
return return
ctx.cancel_autoreconnect()
if ctx._messagebox_connection_loss:
ctx._messagebox_connection_loss.dismiss()
ctx._messagebox_connection_loss = None
address = f"ws://{address}" if "://" not in address \ address = f"ws://{address}" if "://" not in address \
else address.replace("archipelago://", "ws://") else address.replace("archipelago://", "ws://")
@@ -490,39 +562,37 @@ async def server_loop(ctx: CommonContext, address=None):
ctx.password = server_url.password ctx.password = server_url.password
port = server_url.port or 38281 port = server_url.port or 38281
def reconnect_hint() -> str:
return ", type /connect to reconnect" if ctx.server_address else ""
logger.info(f'Connecting to Archipelago server at {address}') logger.info(f'Connecting to Archipelago server at {address}')
try: try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None) socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
ctx.ui.update_address_bar(server_url.netloc) if ctx.ui is not None:
ctx.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket) ctx.server = Endpoint(socket)
logger.info('Connected') logger.info('Connected')
ctx.server_address = address ctx.server_address = address
ctx.current_reconnect_delay = ctx.starting_reconnect_delay ctx.current_reconnect_delay = ctx.starting_reconnect_delay
ctx.disconnected_intentionally = False
async for data in ctx.server.socket: async for data in ctx.server.socket:
for msg in decode(data): for msg in decode(data):
await process_server_cmd(ctx, msg) await process_server_cmd(ctx, msg)
logger.warning('Disconnected from multiworld server, type /connect to reconnect') logger.warning(f"Disconnected from multiworld server{reconnect_hint()}")
except ConnectionRefusedError as e: except ConnectionRefusedError:
msg = 'Connection refused by the server. May not be running Archipelago on that address or port.' ctx._handle_connection_loss("Connection refused by the server. May not be running Archipelago on that address or port.")
logger.exception(msg, extra={'compact_gui': True}) except websockets.InvalidURI:
ctx.gui_error(msg, e) ctx._handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
except websockets.InvalidURI as e: except OSError:
msg = 'Failed to connect to the multiworld server (invalid URI)' ctx._handle_connection_loss("Failed to connect to the multiworld server")
logger.exception(msg, extra={'compact_gui': True}) except Exception:
ctx.gui_error(msg, e) ctx._handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
except OSError as e:
msg = 'Failed to connect to the multiworld server'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
except Exception as e:
msg = 'Lost connection to the multiworld server, type /connect to reconnect'
logger.exception(msg, extra={'compact_gui': True})
ctx.gui_error(msg, e)
finally: finally:
await ctx.connection_closed() await ctx.connection_closed()
if ctx.server_address: if ctx.server_address and ctx.username and not ctx.disconnected_intentionally:
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s") logger.info(f"... automatically reconnecting in {ctx.current_reconnect_delay} seconds")
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect") assert ctx.autoreconnect_task is None
ctx.autoreconnect_task = asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
ctx.current_reconnect_delay *= 2 ctx.current_reconnect_delay *= 2
@@ -562,18 +632,21 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
f" for each location checked. Use !hint for more information.") f" for each location checked. Use !hint for more information.")
ctx.hint_cost = int(args['hint_cost']) ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points']) ctx.check_points = int(args['location_check_points'])
players = args.get("players", [])
if len(players) < 1: if "players" in args: # TODO remove when servers sending this are outdated
logger.info('No player connected') players = args.get("players", [])
else: if len(players) < 1:
players.sort() logger.info('No player connected')
current_team = -1 else:
logger.info('Connected Players:') players.sort()
for network_player in players: current_team = -1
if network_player.team != current_team: logger.info('Connected Players:')
logger.info(f' Team #{network_player.team + 1}') for network_player in players:
current_team = network_player.team if network_player.team != current_team:
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot)) logger.info(f' Team #{network_player.team + 1}')
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
# update datapackage # update datapackage
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"]) await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
@@ -628,6 +701,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
# when /missing is used for the client side view of what is missing. # when /missing is used for the client side view of what is missing.
ctx.missing_locations = set(args["missing_locations"]) ctx.missing_locations = set(args["missing_locations"])
ctx.checked_locations = set(args["checked_locations"]) ctx.checked_locations = set(args["checked_locations"])
ctx.server_locations = ctx.missing_locations | ctx. checked_locations
server_url = urllib.parse.urlparse(ctx.server_address)
Utils.persistent_store("client", "last_server_address", server_url.netloc)
elif cmd == 'ReceivedItems': elif cmd == 'ReceivedItems':
start_index = args["index"] start_index = args["index"]
@@ -707,7 +784,7 @@ async def console_loop(ctx: CommonContext):
logger.exception(e) logger.exception(e)
def get_base_parser(description=None): def get_base_parser(description: typing.Optional[str] = None):
import argparse import argparse
parser = argparse.ArgumentParser(description=description) parser = argparse.ArgumentParser(description=description)
parser.add_argument('--connect', default=None, help='Address of the multiworld host.') parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
@@ -723,7 +800,7 @@ if __name__ == '__main__':
class TextContext(CommonContext): class TextContext(CommonContext):
tags = {"AP", "IgnoreGame", "TextOnly"} tags = {"AP", "IgnoreGame", "TextOnly"}
game = "" # empty matches any game since 0.3.2 game = "" # empty matches any game since 0.3.2
items_handling = 0 # don't receive any NetworkItems items_handling = 0b111 # receive all items for /received
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -739,7 +816,6 @@ if __name__ == '__main__':
async def main(args): async def main(args):
ctx = TextContext(args.connect, args.password) ctx = TextContext(args.connect, args.password)
ctx.auth = args.name ctx.auth = args.name
ctx.server_address = args.connect
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled: if gui_enabled:

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import copy
import json import json
import time import time
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
@@ -6,7 +7,8 @@ from typing import List
import Utils import Utils
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \ from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser get_base_parser
SYSTEM_MESSAGE_ID = 0 SYSTEM_MESSAGE_ID = 0
@@ -64,41 +66,37 @@ class FF1Context(CommonContext):
def _set_message(self, msg: str, msg_id: int): def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS: if DISPLAY_MSGS:
self.messages[(time.time(), msg_id)] = msg self.messages[time.time(), msg_id] = msg
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd == 'Connected': if cmd == 'Connected':
asyncio.create_task(parse_locations(self.locations_array, self, True)) async_start(parse_locations(self.locations_array, self, True))
elif cmd == 'Print': elif cmd == 'Print':
msg = args['text'] msg = args['text']
if ': !' not in msg: if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID) self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}" def on_print_json(self, args: dict):
self._set_message(msg, SYSTEM_MESSAGE_ID) if self.ui:
elif cmd == 'PrintJSON': self.ui.print_json(copy.deepcopy(args["data"]))
print_type = args['type'] else:
item = args['item'] text = self.jsontotextparser(copy.deepcopy(args["data"]))
receiving_player_id = args['receiving'] logger.info(text)
receiving_player_name = self.player_names[receiving_player_id] relevant = args.get("type", None) in {"Hint", "ItemSend"}
sending_player_id = item.player if relevant:
sending_player_name = self.player_names[item.player] item = args["item"]
if print_type == 'Hint': # goes to this world
msg = f"Hint: Your {self.item_names[item.item]} is at" \ if self.slot_concerns_self(args["receiving"]):
f" {self.player_names[item.player]}'s {self.location_names[item.location]}" relevant = True
self._set_message(msg, item.item) # found in this world
elif print_type == 'ItemSend' and receiving_player_id != self.slot: elif self.slot_concerns_self(item.player):
if sending_player_id == self.slot: relevant = True
if receiving_player_id == self.slot: # not related
msg = f"You found your own {self.item_names[item.item]}" else:
else: relevant = False
msg = f"You sent {self.item_names[item.item]} to {receiving_player_name}" if relevant:
else: item = args["item"]
if receiving_player_id == sending_player_id: msg = self.raw_text_parser(copy.deepcopy(args["data"]))
msg = f"{sending_player_name} found their {self.item_names[item.item]}"
else:
msg = f"{sending_player_name} sent {self.item_names[item.item]} to " \
f"{receiving_player_name}"
self._set_message(msg, item.item) self._set_message(msg, item.item)
def run_gui(self): def run_gui(self):
@@ -183,7 +181,7 @@ async def nes_sync_task(ctx: FF1Context):
# print(data_decoded) # print(data_decoded)
if ctx.game is not None and 'locations' in data_decoded: if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse # Not just a keep alive ping, parse
asyncio.create_task(parse_locations(data_decoded['locations'], ctx, False)) async_start(parse_locations(data_decoded['locations'], ctx, False))
if not ctx.auth: if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0]) ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '': if ctx.auth == '':

View File

@@ -4,9 +4,12 @@ import logging
import json import json
import string import string
import copy import copy
import re
import subprocess import subprocess
import sys
import time import time
import random import random
import typing
import ModuleUpdate import ModuleUpdate
ModuleUpdate.update() ModuleUpdate.update()
@@ -17,13 +20,18 @@ import asyncio
from queue import Queue from queue import Queue
import Utils import Utils
def check_stdin() -> None:
if Utils.is_windows and sys.stdin:
print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.")
if __name__ == "__main__": if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client") Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \ from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
get_base_parser
from MultiServer import mark_raw from MultiServer import mark_raw
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
from Utils import async_start
from worlds.factorio import Factorio from worlds.factorio import Factorio
@@ -31,6 +39,10 @@ from worlds.factorio import Factorio
class FactorioCommandProcessor(ClientCommandProcessor): class FactorioCommandProcessor(ClientCommandProcessor):
ctx: FactorioContext ctx: FactorioContext
def _cmd_energy_link(self):
"""Print the status of the energy link."""
self.output(f"Energy Link: {self.ctx.energy_link_status}")
@mark_raw @mark_raw
def _cmd_factorio(self, text: str) -> bool: def _cmd_factorio(self, text: str) -> bool:
"""Send the following command to the bound Factorio Server.""" """Send the following command to the bound Factorio Server."""
@@ -47,6 +59,13 @@ class FactorioCommandProcessor(ClientCommandProcessor):
"""Manually trigger a resync.""" """Manually trigger a resync."""
self.ctx.awaiting_bridge = True self.ctx.awaiting_bridge = True
def _cmd_toggle_send_filter(self):
"""Toggle filtering of item sends that get displayed in-game to only those that involve you."""
self.ctx.toggle_filter_item_sends()
def _cmd_toggle_chat(self):
"""Toggle sending of chat messages from players on the Factorio server to Archipelago."""
self.ctx.toggle_bridge_chat_out()
class FactorioContext(CommonContext): class FactorioContext(CommonContext):
command_processor = FactorioCommandProcessor command_processor = FactorioCommandProcessor
@@ -66,6 +85,9 @@ class FactorioContext(CommonContext):
self.factorio_json_text_parser = FactorioJSONtoTextParser(self) self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
self.energy_link_increment = 0 self.energy_link_increment = 0
self.last_deplete = 0 self.last_deplete = 0
self.filter_item_sends: bool = False
self.multiplayer: bool = False # whether multiple different players have connected
self.bridge_chat_out: bool = True
async def server_auth(self, password_requested: bool = False): async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password: if password_requested and not self.password:
@@ -82,12 +104,15 @@ class FactorioContext(CommonContext):
def on_print(self, args: dict): def on_print(self, args: dict):
super(FactorioContext, self).on_print(args) super(FactorioContext, self).on_print(args)
if self.rcon_client: if self.rcon_client:
self.print_to_game(args['text']) if not args['text'].startswith(self.player_names[self.slot] + ":"):
self.print_to_game(args['text'])
def on_print_json(self, args: dict): def on_print_json(self, args: dict):
if self.rcon_client: if self.rcon_client:
text = self.factorio_json_text_parser(copy.deepcopy(args["data"])) if not self.filter_item_sends or not self.is_uninteresting_item_send(args):
self.print_to_game(text) text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
if not text.startswith(self.player_names[self.slot] + ":"):
self.print_to_game(text)
super(FactorioContext, self).on_print_json(args) super(FactorioContext, self).on_print_json(args)
@property @property
@@ -98,6 +123,15 @@ class FactorioContext(CommonContext):
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] " self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
f"{text}") f"{text}")
@property
def energy_link_status(self) -> str:
if not self.energy_link_increment:
return "Disabled"
elif self.current_energy_link_value is None:
return "Standby"
else:
return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J"
def on_deathlink(self, data: dict): def on_deathlink(self, data: dict):
if self.rcon_client: if self.rcon_client:
self.rcon_client.send_command(f"/ap-deathlink {data['source']}") self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
@@ -110,7 +144,7 @@ class FactorioContext(CommonContext):
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
item_name in args["checked_locations"]}) item_name in args["checked_locations"]})
if cmd == "Connected" and self.energy_link_increment: if cmd == "Connected" and self.energy_link_increment:
asyncio.create_task(self.send_msgs([{ async_start(self.send_msgs([{
"cmd": "SetNotify", "keys": ["EnergyLink"] "cmd": "SetNotify", "keys": ["EnergyLink"]
}])) }]))
elif cmd == "SetReply": elif cmd == "SetReply":
@@ -124,6 +158,45 @@ class FactorioContext(CommonContext):
f"{Utils.format_SI_prefix(args['value'])}J remaining.") f"{Utils.format_SI_prefix(args['value'])}J remaining.")
self.rcon_client.send_command(f"/ap-energylink {gained}") self.rcon_client.send_command(f"/ap-energylink {gained}")
def on_user_say(self, text: str) -> typing.Optional[str]:
# Mirror chat sent from the UI to the Factorio server.
self.print_to_game(f"{self.player_names[self.slot]}: {text}")
return text
async def chat_from_factorio(self, user: str, message: str) -> None:
if not self.bridge_chat_out:
return
# Pass through commands
if message.startswith("!"):
await self.send_msgs([{"cmd": "Say", "text": message}])
return
# Omit messages that contain local coordinates
if "[gps=" in message:
return
prefix = f"({user}) " if self.multiplayer else ""
await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}])
def toggle_filter_item_sends(self) -> None:
self.filter_item_sends = not self.filter_item_sends
if self.filter_item_sends:
announcement = "Item sends are now filtered."
else:
announcement = "Item sends are no longer filtered."
logger.info(announcement)
self.print_to_game(announcement)
def toggle_bridge_chat_out(self) -> None:
self.bridge_chat_out = not self.bridge_chat_out
if self.bridge_chat_out:
announcement = "Chat is now bridged to Archipelago."
else:
announcement = "Chat is no longer bridged to Archipelago."
logger.info(announcement)
self.print_to_game(announcement)
def run_gui(self): def run_gui(self):
from kvui import GameManager from kvui import GameManager
@@ -141,7 +214,6 @@ class FactorioContext(CommonContext):
async def game_watcher(ctx: FactorioContext): async def game_watcher(ctx: FactorioContext):
bridge_logger = logging.getLogger("FactorioWatcher") bridge_logger = logging.getLogger("FactorioWatcher")
from worlds.factorio.Technologies import lookup_id_to_name
next_bridge = time.perf_counter() + 1 next_bridge = time.perf_counter() + 1
try: try:
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
@@ -163,6 +235,7 @@ async def game_watcher(ctx: FactorioContext):
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data} research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"] victory = data["victory"]
await ctx.update_death_link(data["death_link"]) await ctx.update_death_link(data["death_link"])
ctx.multiplayer = data.get("multiplayer", False)
if not ctx.finished_game and victory: if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
@@ -171,14 +244,14 @@ async def game_watcher(ctx: FactorioContext):
if ctx.locations_checked != research_data: if ctx.locations_checked != research_data:
bridge_logger.debug( bridge_logger.debug(
f"New researches done: " f"New researches done: "
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}") f"{[ctx.location_names[rid] for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}]) await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
death_link_tick = data.get("death_link_tick", 0) death_link_tick = data.get("death_link_tick", 0)
if death_link_tick != ctx.death_link_tick: if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick ctx.death_link_tick = death_link_tick
if "DeathLink" in ctx.tags: if "DeathLink" in ctx.tags:
asyncio.create_task(ctx.send_death()) async_start(ctx.send_death())
if ctx.energy_link_increment: if ctx.energy_link_increment:
in_world_bridges = data["energy_bridges"] in_world_bridges = data["energy_bridges"]
if in_world_bridges: if in_world_bridges:
@@ -186,7 +259,7 @@ async def game_watcher(ctx: FactorioContext):
if in_world_energy < (ctx.energy_link_increment * in_world_bridges): if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
# attempt to refill # attempt to refill
ctx.last_deplete = time.time() ctx.last_deplete = time.time()
asyncio.create_task(ctx.send_msgs([{ async_start(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations": "cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges}, [{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
{"operation": "max", "value": 0}], {"operation": "max", "value": 0}],
@@ -196,7 +269,7 @@ async def game_watcher(ctx: FactorioContext):
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \ elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
ctx.energy_link_increment*in_world_bridges: ctx.energy_link_increment*in_world_bridges:
value = ctx.energy_link_increment * in_world_bridges value = ctx.energy_link_increment * in_world_bridges
asyncio.create_task(ctx.send_msgs([{ async_start(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations": "cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": value}] [{"operation": "add", "value": value}]
}])) }]))
@@ -212,6 +285,8 @@ async def game_watcher(ctx: FactorioContext):
def stream_factorio_output(pipe, queue, process): def stream_factorio_output(pipe, queue, process):
pipe.reconfigure(errors="replace")
def queuer(): def queuer():
while process.poll() is None: while process.poll() is None:
text = pipe.readline().strip() text = pipe.readline().strip()
@@ -244,7 +319,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process) stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
try: try:
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
if factorio_process.poll(): if factorio_process.poll() is not None:
factorio_server_logger.info("Factorio server has exited.") factorio_server_logger.info("Factorio server has exited.")
ctx.exit_event.set() ctx.exit_event.set()
@@ -257,12 +332,25 @@ async def factorio_server_watcher(ctx: FactorioContext):
if not ctx.server: if not ctx.server:
logger.info("Established bridge to Factorio Server. " logger.info("Established bridge to Factorio Server. "
"Ready to connect to Archipelago via /connect") "Ready to connect to Archipelago via /connect")
check_stdin()
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg: if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
ctx.awaiting_bridge = True ctx.awaiting_bridge = True
factorio_server_logger.debug(msg) factorio_server_logger.debug(msg)
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command energy-link$", msg):
factorio_server_logger.debug(msg)
ctx.print_to_game(f"Energy Link: {ctx.energy_link_status}")
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter$", msg):
factorio_server_logger.debug(msg)
ctx.toggle_filter_item_sends()
elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg):
factorio_server_logger.debug(msg)
ctx.toggle_bridge_chat_out()
else: else:
factorio_server_logger.info(msg) factorio_server_logger.info(msg)
match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg)
if match:
await ctx.chat_from_factorio(match.group(1), match.group(2))
if ctx.rcon_client: if ctx.rcon_client:
commands = {} commands = {}
while ctx.send_index < len(ctx.items_received): while ctx.send_index < len(ctx.items_received):
@@ -283,12 +371,34 @@ async def factorio_server_watcher(ctx: FactorioContext):
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
logging.error("Aborted Factorio Server Bridge") logging.error("Aborted Factorio Server Bridge")
ctx.rcon_client = None
ctx.exit_event.set() ctx.exit_event.set()
finally: finally:
factorio_process.terminate() if factorio_process.poll() is not None:
factorio_process.wait(5) if ctx.rcon_client:
ctx.rcon_client.close()
ctx.rcon_client = None
return
sent_quit = False
if ctx.rcon_client:
# Attempt clean quit through RCON.
try:
ctx.rcon_client.send_command("/quit")
except factorio_rcon.RCONNetworkError:
pass
else:
sent_quit = True
ctx.rcon_client.close()
ctx.rcon_client = None
if not sent_quit:
# Attempt clean quit using SIGTERM. (Note that on Windows this kills the process instead.)
factorio_process.terminate()
try:
factorio_process.wait(10)
except subprocess.TimeoutExpired:
factorio_process.kill()
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient): async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
@@ -362,6 +472,8 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
async def main(args): async def main(args):
ctx = FactorioContext(args.connect, args.password) ctx = FactorioContext(args.connect, args.password)
ctx.filter_item_sends = initial_filter_item_sends
ctx.bridge_chat_out = initial_bridge_chat_out
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled: if gui_enabled:
@@ -414,6 +526,12 @@ if __name__ == '__main__':
server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None) server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None)
if server_settings: if server_settings:
server_settings = os.path.abspath(server_settings) server_settings = os.path.abspath(server_settings)
if not isinstance(options["factorio_options"]["filter_item_sends"], bool):
logging.warning(f"Warning: Option filter_item_sends should be a bool.")
initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"])
if not isinstance(options["factorio_options"]["bridge_chat_out"], bool):
logging.warning(f"Warning: Option bridge_chat_out should be a bool.")
initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"])
if not os.path.exists(os.path.dirname(executable)): if not os.path.exists(os.path.dirname(executable)):
raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.") raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.")

466
Fill.py
View File

@@ -4,9 +4,10 @@ import collections
import itertools import itertools
from collections import Counter, deque from collections import Counter, deque
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item, ItemClassification
from worlds.AutoWorld import call_all from worlds.AutoWorld import call_all
from worlds.generic.Rules import add_item_rule
class FillError(RuntimeError): class FillError(RuntimeError):
@@ -22,7 +23,8 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location], def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None: itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None) -> None:
unplaced_items: typing.List[Item] = [] unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = [] placements: typing.List[Location] = []
@@ -69,60 +71,66 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
else: else:
# we filled all reachable spots. # we filled all reachable spots.
# try swapping this item with previously placed items if swap:
for (i, location) in enumerate(placements): # try swapping this item with previously placed items
placed_item = location.item for (i, location) in enumerate(placements):
# Unplaceable items can sometimes be swapped infinitely. Limit the placed_item = location.item
# number of times we will swap an individual item to prevent this # Unplaceable items can sometimes be swapped infinitely. Limit the
swap_count = swapped_items[placed_item.player, # number of times we will swap an individual item to prevent this
placed_item.name] swap_count = swapped_items[placed_item.player,
if swap_count > 1: placed_item.name]
if swap_count > 1:
continue
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state, [placed_item])
# swap_state assumes we can collect placed item before item_to_place
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Verify that placing this item won't reduce available locations, which could happen with rules
# that want to not have both items. Left in until removal is proven useful.
prev_state = swap_state.copy()
prev_loc_count = len(
world.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True)
new_loc_count = len(
world.get_reachable_locations(swap_state))
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1
swapped_items[placed_item.player,
placed_item.name] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill is None:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place)
continue continue
else:
location.item = None
placed_item.location = None
swap_state = sweep_from_pool(base_state)
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Verify that placing this item won't reduce available locations
prev_state = swap_state.copy()
prev_state.collect(placed_item)
prev_loc_count = len(
world.get_reachable_locations(prev_state))
swap_state.collect(item_to_place, True)
new_loc_count = len(
world.get_reachable_locations(swap_state))
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swap_count += 1
swapped_items[placed_item.player,
placed_item.name] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill is None:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place) unplaced_items.append(item_to_place)
continue continue
world.push_item(spot_to_fill, item_to_place, False) world.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked = lock spot_to_fill.locked = lock
placements.append(spot_to_fill) placements.append(spot_to_fill)
spot_to_fill.event = item_to_place.advancement spot_to_fill.event = item_to_place.advancement
if on_place:
on_place(spot_to_fill)
if len(unplaced_items) > 0 and len(locations) > 0: if len(unplaced_items) > 0 and len(locations) > 0:
# There are leftover unplaceable items and locations that won't accept them # There are leftover unplaceable items and locations that won't accept them
@@ -136,33 +144,207 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
itempool.extend(unplaced_items) itempool.extend(unplaced_items)
def remaining_fill(world: MultiWorld,
locations: typing.List[Location],
itempool: typing.List[Item]) -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
while locations and itempool:
item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None
for i, location in enumerate(locations):
if location.item_rule(item_to_place):
# popping by index is faster than removing by content,
spot_to_fill = locations.pop(i)
# skipping a scan for the element
break
else:
# we filled all reachable spots.
# try swapping this item with previously placed items
for (i, location) in enumerate(placements):
placed_item = location.item
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
if swapped_items[placed_item.player,
placed_item.name] > 1:
continue
location.item = None
placed_item.location = None
if location.item_rule(item_to_place):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
swapped_items[placed_item.player,
placed_item.name] += 1
itempool.append(placed_item)
break
# Item can't be placed here, restore original item
location.item = placed_item
placed_item.location = location
if spot_to_fill is None:
# Can't place this item, move on to the next
unplaced_items.append(item_to_place)
continue
world.push_item(spot_to_fill, item_to_place, False)
placements.append(spot_to_fill)
if unplaced_items and locations:
# There are leftover unplaceable items and locations that won't accept them
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
itempool.extend(unplaced_items)
def fast_fill(world: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
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"}
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:
if (location.item is not None and location.item.advancement and location.address is not None and not
location.locked and location.item.player not in minimal_players):
pool.append(location.item)
state.remove(location.item)
location.item = None
location.event = False
if location in state.events:
state.events.remove(location)
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
fill_restrictive(world, state, locations, pool)
def inaccessible_location_rules(world: MultiWorld, state: CollectionState, locations):
maximum_exploration_state = sweep_from_pool(state)
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')
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
def distribute_early_items(world: MultiWorld,
fill_locations: typing.List[Location],
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
""" returns new fill_locations and itempool """
early_items_count: typing.Dict[typing.Tuple[str, int], int] = {}
for player in world.player_ids:
items = itertools.chain(world.early_items[player], world.local_early_items[player])
for item in items:
early_items_count[(item, player)] = [world.early_items[player].get(item, 0), world.local_early_items[player].get(item, 0)]
if early_items_count:
early_locations: typing.List[Location] = []
early_priority_locations: typing.List[Location] = []
loc_indexes_to_remove: typing.Set[int] = set()
base_state = world.state.copy()
base_state.sweep_for_events(locations=(loc for loc in world.get_filled_locations() if loc.address is None))
for i, loc in enumerate(fill_locations):
if loc.can_reach(base_state):
if loc.progress_type == LocationProgressType.PRIORITY:
early_priority_locations.append(loc)
else:
early_locations.append(loc)
loc_indexes_to_remove.add(i)
fill_locations = [loc for i, loc in enumerate(fill_locations) if i not in loc_indexes_to_remove]
early_prog_items: typing.List[Item] = []
early_rest_items: typing.List[Item] = []
early_local_prog_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
early_local_rest_items: typing.Dict[int, typing.List[Item]] = {player: [] for player in world.player_ids}
item_indexes_to_remove: typing.Set[int] = set()
for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count:
if item.advancement:
if early_items_count[(item.name, item.player)][1]:
early_local_prog_items[item.player].append(item)
early_items_count[(item.name, item.player)][1] -= 1
else:
early_prog_items.append(item)
early_items_count[(item.name, item.player)][0] -= 1
else:
if early_items_count[(item.name, item.player)][1]:
early_local_rest_items[item.player].append(item)
early_items_count[(item.name, item.player)][1] -= 1
else:
early_rest_items.append(item)
early_items_count[(item.name, item.player)][0] -= 1
item_indexes_to_remove.add(i)
if early_items_count[(item.name, item.player)] == [0, 0]:
del early_items_count[(item.name, item.player)]
if len(early_items_count) == 0:
break
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
for player in world.player_ids:
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
early_local_rest_items[player], lock=True)
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True)
early_locations += early_priority_locations
for player in world.player_ids:
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
early_local_prog_items[player], lock=True)
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True)
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 "
f"{len(unplaced_early_items)} items early.")
itempool += unplaced_early_items
fill_locations.extend(early_locations)
world.random.shuffle(fill_locations)
return fill_locations, itempool
def distribute_items_restrictive(world: MultiWorld) -> None: def distribute_items_restrictive(world: MultiWorld) -> None:
fill_locations = sorted(world.get_unfilled_locations()) fill_locations = sorted(world.get_unfilled_locations())
world.random.shuffle(fill_locations) world.random.shuffle(fill_locations)
# get items to distribute # get items to distribute
itempool = sorted(world.itempool) itempool = sorted(world.itempool)
world.random.shuffle(itempool) world.random.shuffle(itempool)
fill_locations, itempool = distribute_early_items(world, fill_locations, itempool)
progitempool: typing.List[Item] = [] progitempool: typing.List[Item] = []
nonexcludeditempool: typing.List[Item] = [] usefulitempool: typing.List[Item] = []
localrestitempool: typing.Dict[int, typing.List[Item]] = {player: [] for player in range(1, world.players + 1)} filleritempool: typing.List[Item] = []
nonlocalrestitempool: typing.List[Item] = []
restitempool: typing.List[Item] = []
for item in itempool: for item in itempool:
if item.advancement: if item.advancement:
progitempool.append(item) progitempool.append(item)
elif item.useful: # this only gets nonprogression items which should not appear in excluded locations elif item.useful:
nonexcludeditempool.append(item) usefulitempool.append(item)
elif item.name in world.local_items[item.player].value:
localrestitempool[item.player].append(item)
elif item.name in world.non_local_items[item.player].value:
nonlocalrestitempool.append(item)
else: else:
restitempool.append(item) filleritempool.append(item)
call_all(world, "fill_hook", progitempool, nonexcludeditempool, call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations)
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = { locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
loc_type: [] for loc_type in LocationProgressType} loc_type: [] for loc_type in LocationProgressType}
@@ -174,60 +356,44 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
defaultlocations = locations[LocationProgressType.DEFAULT] defaultlocations = locations[LocationProgressType.DEFAULT]
excludedlocations = locations[LocationProgressType.EXCLUDED] excludedlocations = locations[LocationProgressType.EXCLUDED]
fill_restrictive(world, world.state, prioritylocations, progitempool, lock=True) # can't lock due to accessibility corrections touching things, so we remember which ones got placed and lock later
lock_later = []
def mark_for_locking(location: Location):
nonlocal lock_later
lock_later.append(location)
if prioritylocations: if prioritylocations:
# "priority fill"
fill_restrictive(world, world.state, prioritylocations, progitempool, swap=False, on_place=mark_for_locking)
accessibility_corrections(world, world.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations defaultlocations = prioritylocations + defaultlocations
if progitempool: if progitempool:
# "progression fill"
fill_restrictive(world, world.state, defaultlocations, progitempool) fill_restrictive(world, world.state, defaultlocations, progitempool)
if progitempool: if progitempool:
raise FillError( raise FillError(
f'Not enough locations for progress items. There are {len(progitempool)} more items than locations') f'Not enough locations for progress items. There are {len(progitempool)} more items than locations')
accessibility_corrections(world, world.state, defaultlocations)
if nonexcludeditempool: for location in lock_later:
world.random.shuffle(defaultlocations) if location.item:
# needs logical fill to not conflict with local items location.locked = True
fill_restrictive( del mark_for_locking, lock_later
world, world.state, defaultlocations, nonexcludeditempool)
if nonexcludeditempool:
raise FillError(
f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations')
defaultlocations = defaultlocations + excludedlocations inaccessible_location_rules(world, world.state, defaultlocations)
world.random.shuffle(defaultlocations)
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds remaining_fill(world, excludedlocations, filleritempool)
local_locations: typing.Dict[int, typing.List[Location]] = {player: [] for player in world.player_ids} if excludedlocations:
for location in defaultlocations: raise FillError(
local_locations[location.player].append(location) f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items")
for player_locations in local_locations.values():
world.random.shuffle(player_locations)
for player, items in localrestitempool.items(): # items already shuffled restitempool = usefulitempool + filleritempool
player_local_locations = local_locations[player]
for item_to_place in items:
if not player_local_locations:
logging.warning(f"Ran out of local locations for player {player}, "
f"cannot place {item_to_place}.")
break
spot_to_fill = player_local_locations.pop()
world.push_item(spot_to_fill, item_to_place, False)
defaultlocations.remove(spot_to_fill)
for item_to_place in nonlocalrestitempool: remaining_fill(world, defaultlocations, restitempool)
for i, location in enumerate(defaultlocations):
if location.player != item_to_place.player:
world.push_item(defaultlocations.pop(i), item_to_place, False)
break
else:
logging.warning(
f"Could not place non_local_item {item_to_place} among {defaultlocations}, tossing.")
world.random.shuffle(defaultlocations) unplaced = restitempool
restitempool, defaultlocations = fast_fill(
world, restitempool, defaultlocations)
unplaced = progitempool + restitempool
unfilled = defaultlocations unfilled = defaultlocations
if unplaced or unfilled: if unplaced or unfilled:
@@ -241,15 +407,6 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
logging.info(f'Per-Player counts: {print_data})') logging.info(f'Per-Player counts: {print_data})')
def fast_fill(world: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
def flood_items(world: MultiWorld) -> None: def flood_items(world: MultiWorld) -> None:
# get items to distribute # get items to distribute
world.random.shuffle(world.itempool) world.random.shuffle(world.itempool)
@@ -526,6 +683,17 @@ def distribute_planned(world: MultiWorld) -> None:
else: else:
warn(warning, force) warn(warning, force)
swept_state = world.state.copy()
swept_state.sweep_for_events()
reachable = frozenset(world.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
for loc in world.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
# TODO: remove. Preferably by implementing key drop # TODO: remove. Preferably by implementing key drop
from worlds.alttp.Regions import key_drop_data from worlds.alttp.Regions import key_drop_data
world_name_lookup = world.world_name_lookup world_name_lookup = world.world_name_lookup
@@ -541,7 +709,39 @@ def distribute_planned(world: MultiWorld) -> None:
if 'from_pool' not in block: if 'from_pool' not in block:
block['from_pool'] = True block['from_pool'] = True
if 'world' not in block: if 'world' not in block:
block['world'] = False target_world = False
else:
target_world = block['world']
if target_world is False or world.players == 1: # target own world
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(world.player_ids) - {player}
elif target_world is None: # target all worlds
worlds = set(world.player_ids)
elif type(target_world) == list: # list of target worlds
worlds = set()
for listed_world in target_world:
if listed_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block['force'])
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, world.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
block['force'])
continue
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block['force'])
continue
worlds = {world_name_lookup[target_world]}
block['world'] = worlds
items: block_value = [] items: block_value = []
if "items" in block: if "items" in block:
items = block["items"] items = block["items"]
@@ -578,6 +778,17 @@ def distribute_planned(world: MultiWorld) -> None:
for key, value in locations.items(): for key, value in locations.items():
location_list += [key] * value location_list += [key] * value
locations = location_list locations = location_list
if "early_locations" in locations:
locations.remove("early_locations")
for player in worlds:
locations += early_locations[player]
if "non_early_locations" in locations:
locations.remove("non_early_locations")
for player in worlds:
locations += non_early_locations[player]
block['locations'] = locations block['locations'] = locations
if not block['count']: if not block['count']:
@@ -613,38 +824,11 @@ def distribute_planned(world: MultiWorld) -> None:
for placement in plando_blocks: for placement in plando_blocks:
player = placement['player'] player = placement['player']
try: try:
target_world = placement['world'] worlds = placement['world']
locations = placement['locations'] locations = placement['locations']
items = placement['items'] items = placement['items']
maxcount = placement['count']['target'] maxcount = placement['count']['target']
from_pool = placement['from_pool'] from_pool = placement['from_pool']
if target_world is False or world.players == 1: # target own world
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(world.player_ids) - {player}
elif target_world is None: # target all worlds
worlds = set(world.player_ids)
elif type(target_world) == list: # list of target worlds
worlds = set()
for listed_world in target_world:
if listed_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
placement['force'])
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, world.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
placement['force'])
continue
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
placement['force'])
continue
worlds = {world_name_lookup[target_world]}
candidates = list(location for location in world.get_unfilled_locations_for_players(locations, candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
worlds)) worlds))

View File

@@ -7,7 +7,7 @@ import urllib.request
import urllib.parse import urllib.parse
from typing import Set, Dict, Tuple, Callable, Any, Union from typing import Set, Dict, Tuple, Callable, Any, Union
import os import os
from collections import Counter from collections import Counter, ChainMap
import string import string
import enum import enum
@@ -23,7 +23,6 @@ from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain from Main import main as ERmain
from BaseClasses import seeddigits, get_seed from BaseClasses import seeddigits, get_seed
import Options import Options
from worlds.alttp import Bosses
from worlds.alttp.Text import TextTable from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister from worlds.AutoWorld import AutoWorldRegister
import copy import copy
@@ -63,7 +62,7 @@ class PlandoSettings(enum.IntFlag):
def __str__(self) -> str: def __str__(self) -> str:
if self.value: if self.value:
return ", ".join((flag.name for flag in PlandoSettings if self.value & flag.value)) return ", ".join(flag.name for flag in PlandoSettings if self.value & flag.value)
return "Off" return "Off"
@@ -84,11 +83,6 @@ def mystery_argparse():
parser.add_argument('--seed', help='Define seed number to generate.', type=int) parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1)) parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"]) parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"],
help="Path to the 1.0 JP LttP Baserom.") # absolute, relative to cwd or relative to app path
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"],
help="Path to the 1.0 JP SM Baserom.")
parser.add_argument('--enemizercli', default=resolve_path(defaults["enemizer_path"], local_path))
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path), parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
parser.add_argument('--race', action='store_true', default=defaults["race"]) parser.add_argument('--race', action='store_true', default=defaults["race"])
@@ -133,12 +127,14 @@ def main(args=None, callback=ERmain):
if args.meta_file_path and os.path.exists(args.meta_file_path): if args.meta_file_path and os.path.exists(args.meta_file_path):
try: try:
weights_cache[args.meta_file_path] = read_weights_yamls(args.meta_file_path) meta_weights = read_weights_yamls(args.meta_file_path)[-1]
except Exception as e: except Exception as e:
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta_file_path][-1]
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}") print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
del(meta_weights["meta_description"]) try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
del(meta_weights["meta_description"])
except Exception as e:
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
if args.samesettings: if args.samesettings:
raise Exception("Cannot mix --samesettings with --meta") raise Exception("Cannot mix --samesettings with --meta")
else: else:
@@ -158,13 +154,14 @@ def main(args=None, callback=ERmain):
# sort dict for consistent results across platforms: # sort dict for consistent results across platforms:
weights_cache = {key: value for key, value in sorted(weights_cache.items())} weights_cache = {key: value for key, value in sorted(weights_cache.items())}
for filename, yaml_data in weights_cache.items(): for filename, yaml_data in weights_cache.items():
for yaml in yaml_data: if filename not in {args.meta_file_path, args.weights_file_path}:
print(f"P{player_id} Weights: {filename} >> " for yaml in yaml_data:
f"{get_choice('description', yaml, 'No description specified')}") print(f"P{player_id} Weights: {filename} >> "
player_files[player_id] = filename f"{get_choice('description', yaml, 'No description specified')}")
player_id += 1 player_files[player_id] = filename
player_id += 1
args.multi = max(player_id-1, args.multi) args.multi = max(player_id - 1, args.multi)
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: " print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
f"{args.plando}") f"{args.plando}")
@@ -181,31 +178,29 @@ def main(args=None, callback=ERmain):
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
erargs.lttp_rom = args.lttp_rom
erargs.sm_rom = args.sm_rom
erargs.enemizercli = args.enemizercli
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: ( tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None) {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
for fname, yamls in weights_cache.items()} for fname, yamls in weights_cache.items()}
player_path_cache = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
if meta_weights: if meta_weights:
for category_name, category_dict in meta_weights.items(): for category_name, category_dict in meta_weights.items():
for key in category_dict: for key in category_dict:
option = get_choice(key, category_dict) option = roll_meta_option(key, category_name, category_dict)
if option is not None: if option is not None:
for player, path in player_path_cache.items(): for path in weights_cache:
for yaml in weights_cache[path]: for yaml in weights_cache[path]:
if category_name is None: if category_name is None:
yaml[key] = option for category in yaml:
if category in AutoWorldRegister.world_types and key in Options.common_options:
yaml[category][key] = option
elif category_name not in yaml: elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.") logging.warning(f"Meta: Category {category_name} is not present in {path}.")
else: else:
yaml[category_name][key] = option yaml[category_name][key] = option
player_path_cache = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter() name_counter = Counter()
erargs.player_settings = {} erargs.player_settings = {}
@@ -238,8 +233,8 @@ def main(args=None, callback=ERmain):
else: else:
raise RuntimeError(f'No weights specified for player {player}') raise RuntimeError(f'No weights specified for player {player}')
if len(set(erargs.name.values())) != len(erargs.name): if len(set(name.lower() for name in erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}") raise Exception(f"Names have to be unique. Names: {Counter(name.lower() for name in erargs.name.values())}")
if args.yaml_output: if args.yaml_output:
import yaml import yaml
@@ -322,11 +317,11 @@ class SafeDict(dict):
def handle_name(name: str, player: int, name_counter: Counter): def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name] += 1 name_counter[name.lower()] += 1
number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")]) new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name], new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
NUMBER=(name_counter[name] if name_counter[ NUMBER=(number if number > 1 else ''),
name] > 1 else ''),
player=player, player=player,
PLAYER=(player if player > 1 else ''))) PLAYER=(player if player > 1 else '')))
new_name = new_name.strip()[:16] new_name = new_name.strip()[:16]
@@ -342,19 +337,6 @@ def prefer_int(input_data: str) -> Union[str, int]:
return input_data return input_data
available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
{'Agahnim', 'Agahnim2', 'Ganon'}}
available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in
Bosses.boss_location_table}
boss_shuffle_options = {None: 'none',
'none': 'none',
'basic': 'basic',
'full': 'full',
'chaos': 'chaos',
'singularity': 'singularity'
}
goals = { goals = {
'ganon': 'ganon', 'ganon': 'ganon',
'crystals': 'crystals', 'crystals': 'crystals',
@@ -387,6 +369,28 @@ def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> di
return weights return weights
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
if not game:
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)
if option_key in options:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return category_dict[option_key]
if game == "A Link to the Past": # TODO wow i hate this
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
"triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
"boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
"red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
"misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
"random_sprite_on_event"}:
return get_choice(option_key, category_dict)
raise Exception(f"Error generating meta option {option_key} for {game}.")
def roll_linked_options(weights: dict) -> dict: def roll_linked_options(weights: dict) -> dict:
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
for option_set in weights["linked_options"]: for option_set in weights["linked_options"]:
@@ -439,42 +443,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
return weights return weights
def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str: def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoSettings):
if boss_shuffle in boss_shuffle_options:
return boss_shuffle_options[boss_shuffle]
elif PlandoSettings.bosses in plando_options:
options = boss_shuffle.lower().split(";")
remainder_shuffle = "none" # vanilla
bosses = []
for boss in options:
if boss in boss_shuffle_options:
remainder_shuffle = boss_shuffle_options[boss]
elif "-" in boss:
loc, boss_name = boss.split("-")
if boss_name not in available_boss_names:
raise ValueError(f"Unknown Boss name {boss_name}")
if loc not in available_boss_locations:
raise ValueError(f"Unknown Boss Location {loc}")
level = ''
if loc.split(" ")[-1] in {"top", "middle", "bottom"}:
# split off level
loc = loc.split(" ")
level = f" {loc[-1]}"
loc = " ".join(loc[:-1])
loc = loc.title().replace("Of", "of")
if not Bosses.can_place_boss(boss_name.title(), loc, level):
raise ValueError(f"Cannot place {boss_name} at {loc}{level}")
bosses.append(boss)
elif boss not in available_boss_names:
raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.")
else:
bosses.append(boss)
return ";".join(bosses + [remainder_shuffle])
else:
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)):
if option_key in game_weights: if option_key in game_weights:
try: try:
if not option.supports_weighting: if not option.supports_weighting:
@@ -485,10 +454,9 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
except Exception as e: except Exception as e:
raise Exception(f"Error generating option {option_key} in {ret.game}") from e raise Exception(f"Error generating option {option_key} in {ret.game}") from e
else: else:
if hasattr(player_option, "verify"): player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
player_option.verify(AutoWorldRegister.world_types[ret.game])
else: else:
setattr(ret, option_key, option(option.default)) setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses): def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
@@ -531,12 +499,12 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default))) setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
if ret.game in AutoWorldRegister.world_types: if ret.game in AutoWorldRegister.world_types:
for option_key, option in world_type.options.items(): for option_key, option in world_type.option_definitions.items():
handle_option(ret, game_weights, option_key, option) handle_option(ret, game_weights, option_key, option, plando_options)
for option_key, option in Options.per_game_common_options.items(): 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 # skip setting this option if already set from common_options, defaulting to root option
if not (option_key in Options.common_options and option_key not in game_weights): if not (option_key in Options.common_options and option_key not in game_weights):
handle_option(ret, game_weights, option_key, option) handle_option(ret, game_weights, option_key, option, plando_options)
if PlandoSettings.items in plando_options: if PlandoSettings.items in plando_options:
ret.plando_items = game_weights.get("plando_items", []) ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time": if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
@@ -619,8 +587,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.item_functionality = get_choice_legacy('item_functionality', weights) ret.item_functionality = get_choice_legacy('item_functionality', weights)
boss_shuffle = get_choice_legacy('boss_shuffle', weights)
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
ret.enemy_damage = {None: 'default', ret.enemy_damage = {None: 'default',
'default': 'default', 'default': 'default',

View File

@@ -10,16 +10,21 @@ Scroll down to components= to add components to the launcher as well as setup.py
import argparse import argparse
from os.path import isfile
import sys
from typing import Iterable, Sequence, Callable, Union, Optional
import subprocess
import itertools import itertools
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox,\
is_windows, is_macos, is_linux
from shutil import which
import shlex import shlex
import subprocess
import sys
from enum import Enum, auto from enum import Enum, auto
from os.path import isfile
from shutil import which
from typing import Iterable, Sequence, Callable, Union, Optional
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
is_windows, is_macos, is_linux
def open_host_yaml(): def open_host_yaml():
@@ -65,6 +70,7 @@ def browse_files():
webbrowser.open(file) webbrowser.open(file)
# noinspection PyArgumentList
class Type(Enum): class Type(Enum):
TOOL = auto() TOOL = auto()
FUNC = auto() # not a real component FUNC = auto() # not a real component
@@ -126,7 +132,7 @@ components: Iterable[Component] = (
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'), Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
# SNI # SNI
Component('SNI Client', 'SNIClient', Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3')), file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw')),
Component('LttP Adjuster', 'LttPAdjuster'), Component('LttP Adjuster', 'LttPAdjuster'),
# Factorio # Factorio
Component('Factorio Client', 'FactorioClient'), Component('Factorio Client', 'FactorioClient'),
@@ -139,10 +145,15 @@ components: Iterable[Component] = (
Component('OoT Adjuster', 'OoTAdjuster'), Component('OoT Adjuster', 'OoTAdjuster'),
# FF1 # FF1
Component('FF1 Client', 'FF1Client'), Component('FF1 Client', 'FF1Client'),
# Pokémon
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
# ChecksFinder # ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'), Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2 # Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'), Component('Starcraft 2 Client', 'Starcraft2Client'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
# Functions # Functions
Component('Open host.yaml', func=open_host_yaml), Component('Open host.yaml', func=open_host_yaml),
Component('Open Patch', func=open_patch), Component('Open Patch', func=open_patch),

View File

@@ -26,7 +26,9 @@ ModuleUpdate.update()
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \ from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \
get_adjuster_settings, tkinter_center_window, init_logging get_adjuster_settings, tkinter_center_window, init_logging
from Patch import GAME_ALTTP
GAME_ALTTP = "A Link to the Past"
class AdjusterWorld(object): class AdjusterWorld(object):
@@ -83,9 +85,9 @@ def main():
parser.add_argument('--ow_palettes', default='default', parser.add_argument('--ow_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick']) 'sick'])
parser.add_argument('--link_palettes', default='default', # parser.add_argument('--link_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', # choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick']) # 'sick'])
parser.add_argument('--shield_palettes', default='default', parser.add_argument('--shield_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick']) 'sick'])
@@ -139,7 +141,7 @@ def adjust(args):
vanillaRom = args.baserom vanillaRom = args.baserom
if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom): if not os.path.exists(vanillaRom) and not os.path.isabs(vanillaRom):
vanillaRom = local_path(vanillaRom) vanillaRom = local_path(vanillaRom)
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}: if os.path.splitext(args.rom)[-1].lower() == '.aplttp':
import Patch import Patch
meta, args.rom = Patch.create_rom_file(args.rom) meta, args.rom = Patch.create_rom_file(args.rom)
@@ -195,7 +197,7 @@ def adjustGUI():
romEntry2 = Entry(romDialogFrame, textvariable=romVar2) romEntry2 = Entry(romDialogFrame, textvariable=romVar2)
def RomSelect2(): def RomSelect2():
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".apbp")), ("All Files", "*")]) rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc", ".aplttp")), ("All Files", "*")])
romVar2.set(rom) romVar2.set(rom)
romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2) romSelectButton2 = Button(romDialogFrame, text='Select Rom', command=RomSelect2)
@@ -725,7 +727,7 @@ def get_rom_options_frame(parent=None):
vars.auto_apply = StringVar(value=adjuster_settings.auto_apply) vars.auto_apply = StringVar(value=adjuster_settings.auto_apply)
autoApplyFrame = Frame(romOptionsFrame) autoApplyFrame = Frame(romOptionsFrame)
autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W) autoApplyFrame.grid(row=9, column=0, columnspan=2, sticky=W)
filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .apbp files") filler = Label(autoApplyFrame, text="Automatically apply last used settings on opening .aplttp files")
filler.pack(side=TOP, expand=True, fill=X) filler.pack(side=TOP, expand=True, fill=X)
askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask') askRadio = Radiobutton(autoApplyFrame, text='Ask', variable=vars.auto_apply, value='ask')
askRadio.pack(side=LEFT, padx=5, pady=5) askRadio.pack(side=LEFT, padx=5, pady=5)
@@ -752,6 +754,7 @@ class SpriteSelector():
self.window['pady'] = 5 self.window['pady'] = 5
self.spritesPerRow = 32 self.spritesPerRow = 32
self.all_sprites = [] self.all_sprites = []
self.invalid_sprites = []
self.sprite_pool = spritePool self.sprite_pool = spritePool
def open_custom_sprite_dir(_evt): def open_custom_sprite_dir(_evt):
@@ -833,6 +836,13 @@ class SpriteSelector():
self.window.focus() self.window.focus()
tkinter_center_window(self.window) tkinter_center_window(self.window)
if self.invalid_sprites:
invalid = sorted(self.invalid_sprites)
logging.warning(f"The following sprites are invalid: {', '.join(invalid)}")
msg = f"{invalid[0]} "
msg += f"and {len(invalid)-1} more are invalid" if len(invalid) > 1 else "is invalid"
messagebox.showerror("Invalid sprites detected", msg, parent=self.window)
def remove_from_sprite_pool(self, button, spritename): def remove_from_sprite_pool(self, button, spritename):
self.callback(("remove", spritename)) self.callback(("remove", spritename))
self.spritePoolButtons.buttons.remove(button) self.spritePoolButtons.buttons.remove(button)
@@ -897,7 +907,13 @@ class SpriteSelector():
sprites = [] sprites = []
for file in os.listdir(path): for file in os.listdir(path):
sprites.append((file, Sprite(os.path.join(path, file)))) if file == '.gitignore':
continue
sprite = Sprite(os.path.join(path, file))
if sprite.valid:
sprites.append((file, sprite))
else:
self.invalid_sprites.append(file)
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip()) sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())

114
Main.py
View File

@@ -8,15 +8,15 @@ import concurrent.futures
import pickle import pickle
import tempfile import tempfile
import zipfile import zipfile
from typing import Dict, Tuple, Optional, Set from typing import Dict, List, Tuple, Optional, Set
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
from worlds.alttp.Items import item_name_groups from worlds.alttp.Items import item_name_groups
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance from worlds.alttp.Regions import is_main_entrance
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from Utils import output_path, get_options, __version__, version_tuple from Utils import output_path, get_options, __version__, version_tuple
from worlds.generic.Rules import locality_rules, exclusion_rules, group_locality_rules from worlds.generic.Rules import locality_rules, exclusion_rules
from worlds import AutoWorld from worlds import AutoWorld
ordered_areas = ( ordered_areas = (
@@ -70,7 +70,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.required_medallions = args.required_medallions.copy() world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy() world.game = args.game.copy()
world.player_name = args.name.copy() world.player_name = args.name.copy()
world.enemizer = args.enemizercli
world.sprite = args.sprite.copy() world.sprite = args.sprite.copy()
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option. world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
@@ -81,15 +80,30 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info("Found World Types:") logger.info("Found World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
numlength = 8
max_item = 0
max_location = 0
for cls in AutoWorld.AutoWorldRegister.world_types.values():
if cls.item_id_to_name:
max_item = max(max_item, max(cls.item_id_to_name))
max_location = max(max_location, max(cls.location_id_to_name))
item_digits = len(str(max_item))
location_digits = len(str(max_location))
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
del max_item, max_location
for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden: if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} " logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - " f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
f"{max(cls.item_id_to_name):{numlength}}) | " f"{max(cls.item_id_to_name):{item_digits}}) | "
f"{len(cls.location_names):3} " f"{len(cls.location_names):{location_count}} "
f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - " f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
f"{max(cls.location_id_to_name):{numlength}})") f"{max(cls.location_id_to_name):{location_digits}})")
del item_digits, location_digits, item_count, location_count
AutoWorld.call_stage(world, "assert_generate") AutoWorld.call_stage(world, "assert_generate")
@@ -108,7 +122,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]: if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
world.local_items[player].value.add('Triforce Piece') world.local_items[player].value.add('Triforce Piece')
# Not possible to place pendants/crystals out side of boss prizes yet. # Not possible to place pendants/crystals outside boss prizes yet.
world.non_local_items[player].value -= item_name_groups['Pendants'] world.non_local_items[player].value -= item_name_groups['Pendants']
world.non_local_items[player].value -= item_name_groups['Crystals'] world.non_local_items[player].value -= item_name_groups['Crystals']
@@ -123,9 +137,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info('Calculating Access Rules.') logger.info('Calculating Access Rules.')
if world.players > 1: if world.players > 1:
for player in world.player_ids: locality_rules(world)
locality_rules(world, player)
group_locality_rules(world)
else: else:
world.non_local_items[1].value = set() world.non_local_items[1].value = set()
world.local_items[1].value = set() world.local_items[1].value = set()
@@ -142,8 +154,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# temporary home for item links, should be moved out of Main # temporary home for item links, should be moved out of Main
for group_id, group in world.groups.items(): for group_id, group in world.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]): def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
classifications = collections.defaultdict(int) Optional[Dict[int, Dict[str, int]]], Optional[Dict[str, int]]
]:
classifications: Dict[str, int] = collections.defaultdict(int)
counters = {player: {name: 0 for name in shared_pool} for player in players} counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in world.itempool: for item in world.itempool:
if item.player in counters and item.name in shared_pool: if item.player in counters and item.name in shared_pool:
@@ -153,7 +167,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in players.copy(): for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]): if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player) players.remove(player)
del(counters[player]) del (counters[player])
if not players: if not players:
return None, None return None, None
@@ -165,14 +179,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
counters[player][item] = count counters[player][item] = count
else: else:
for player in players: for player in players:
del(counters[player][item]) del (counters[player][item])
return counters, classifications return counters, classifications
common_item_count, classifications = find_common_pool(group["players"], group["item_pool"]) common_item_count, classifications = find_common_pool(group["players"], group["item_pool"])
if not common_item_count: if not common_item_count:
continue continue
new_itempool = [] new_itempool: List[Item] = []
for item_name, item_count in next(iter(common_item_count.values())).items(): for item_name, item_count in next(iter(common_item_count.values())).items():
for _ in range(item_count): for _ in range(item_count):
new_item = group["world"].create_item(item_name) new_item = group["world"].create_item(item_name)
@@ -217,9 +231,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info("Running Item Plando") logger.info("Running Item Plando")
for item in world.itempool:
item.world = world
distribute_planned(world) distribute_planned(world)
logger.info('Running Pre Main Fill.') logger.info('Running Pre Main Fill.')
@@ -253,24 +264,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
output_file_futures.append( output_file_futures.append(
pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir)) pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
def get_entrance_to_region(region: Region):
for entrance in region.entrances:
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
return entrance
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
return get_entrance_to_region(entrance.parent_region)
# collect ER hint info # collect ER hint info
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if er_hint_data: Dict[int, Dict[int, str]] = {}
world.shuffle[player] != "vanilla" or world.retro_caves[player]} AutoWorld.call_all(world, 'extend_hint_information', er_hint_data)
for region in world.regions:
if region.player in er_hint_data and region.locations:
main_entrance = get_entrance_to_region(region)
for location in region.locations:
if type(location.address) == int: # skips events and crystals
if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name:
er_hint_data[region.player][location.address] = main_entrance.name
checks_in_area = {player: {area: list() for area in ordered_areas} checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)} for player in range(1, world.players + 1)}
@@ -280,22 +276,23 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for location in world.get_filled_locations(): for location in world.get_filled_locations():
if type(location.address) is int: if type(location.address) is int:
main_entrance = get_entrance_to_region(location.parent_region)
if location.game != "A Link to the Past": if location.game != "A Link to the Past":
checks_in_area[location.player]["Light World"].append(location.address) checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.dungeon: else:
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower', main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance)
'Inverted Ganons Tower': 'Ganons Tower'} \ if location.parent_region.dungeon:
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name) dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
checks_in_area[location.player][dungeonname].append(location.address) 'Inverted Ganons Tower': 'Ganons Tower'} \
elif location.parent_region.type == RegionType.LightWorld: .get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
checks_in_area[location.player]["Light World"].append(location.address) checks_in_area[location.player][dungeonname].append(location.address)
elif location.parent_region.type == RegionType.DarkWorld: elif location.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Dark World"].append(location.address) checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.LightWorld: elif location.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Light World"].append(location.address) checks_in_area[location.player]["Dark World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.DarkWorld: elif main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[location.player]["Dark World"].append(location.address) checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
checks_in_area[location.player]["Total"] += 1 checks_in_area[location.player]["Total"] += 1
oldmancaves = [] oldmancaves = []
@@ -309,7 +306,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
player = region.player player = region.player
location_id = SHOP_ID_START + total_shop_slots + index location_id = SHOP_ID_START + total_shop_slots + index
main_entrance = get_entrance_to_region(region) main_entrance = region.get_connecting_entrance(is_main_entrance)
if main_entrance.parent_region.type == RegionType.LightWorld: if main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[player]["Light World"].append(location_id) checks_in_area[player]["Light World"].append(location_id)
else: else:
@@ -344,7 +341,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player, world_precollected in world.precollected_items.items()} for player, world_precollected in world.precollected_items.items()}
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))} precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
for slot in world.player_ids: for slot in world.player_ids:
slot_data[slot] = world.worlds[slot].fill_slot_data() slot_data[slot] = world.worlds[slot].fill_slot_data()
@@ -426,7 +422,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase)) world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
zipfilename = output_path(f"AP_{world.seed_name}.zip") zipfilename = output_path(f"AP_{world.seed_name}.zip")
logger.info(f'Creating final archive at {zipfilename}.') logger.info(f"Creating final archive at {zipfilename}")
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED, with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zf: compresslevel=9) as zf:
for file in os.scandir(temp_dir): for file in os.scandir(temp_dir):

View File

@@ -13,10 +13,12 @@ update_ran = getattr(sys, "frozen", False) # don't run update if environment is
if not update_ran: if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")): for entry in os.scandir(os.path.join(local_dir, "worlds")):
if entry.is_dir(): # skip .* (hidden / disabled) folders
req_file = os.path.join(entry.path, "requirements.txt") if not entry.name.startswith("."):
if os.path.exists(req_file): if entry.is_dir():
requirements_files.add(req_file) req_file = os.path.join(entry.path, "requirements.txt")
if os.path.exists(req_file):
requirements_files.add(req_file)
def update_command(): def update_command():
@@ -37,11 +39,25 @@ def update(yes=False, force=False):
path = os.path.join(os.path.dirname(__file__), req_file) path = os.path.join(os.path.dirname(__file__), req_file)
with open(path) as requirementsfile: with open(path) as requirementsfile:
for line in requirementsfile: for line in requirementsfile:
if line.startswith('https://'): if line.startswith(("https://", "git+https://")):
# extract name and version from url # extract name and version for url
wheel = line.split('/')[-1] rest = line.split('/')[-1]
name, version, _ = wheel.split('-', 2) line = ""
line = f'{name}=={version}' if "#egg=" in rest:
# from egg info
rest, egg = rest.split("#egg=", 1)
egg = egg.split(";", 1)[0]
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
line = egg
else:
egg = ""
if "@" in rest and not line:
raise ValueError("Can't deduce version from requirement")
elif not line:
# from filename
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
name, version, _ = rest.split("-", 2)
line = f'{egg or name}=={version}'
requirements = pkg_resources.parse_requirements(line) requirements = pkg_resources.parse_requirements(line)
for requirement in requirements: for requirement in requirements:
requirement = str(requirement) requirement = str(requirement)

View File

@@ -30,17 +30,13 @@ except ImportError:
OperationalError = ConnectionError OperationalError = ConnectionError
import NetUtils import NetUtils
from worlds.AutoWorld import AutoWorldRegister
proxy_worlds = {name: world(None, 0) for name, world in AutoWorldRegister.world_types.items()}
from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_location_id_to_name
import Utils import Utils
from Utils import get_item_name_from_id, get_location_name_from_id, \ from Utils import version_tuple, restricted_loads, Version, async_start
version_tuple, restricted_loads, Version
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType SlotType
min_client_version = Version(0, 1, 6) min_client_version = Version(0, 1, 6)
print_command_compatability_threshold = Version(0, 3, 5) # Remove backwards compatibility around 0.3.7
colorama.init() colorama.init()
# functions callable on storable data on the server by clients # functions callable on storable data on the server by clients
@@ -126,6 +122,12 @@ class Context:
stored_data: typing.Dict[str, object] stored_data: typing.Dict[str, object]
stored_data_notification_clients: typing.Dict[str, typing.Set[Client]] stored_data_notification_clients: typing.Dict[str, typing.Set[Client]]
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
forced_auto_forfeits: typing.Dict[str, bool]
non_hintable_names: typing.Dict[str, typing.Set[str]]
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled", hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
@@ -190,8 +192,43 @@ class Context:
self.stored_data = {} self.stored_data = {}
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet) self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
# General networking # init empty to satisfy linter, I suppose
self.gamespackage = {}
self.item_name_groups = {}
self.all_item_and_group_names = {}
self.forced_auto_forfeits = collections.defaultdict(lambda: False)
self.non_hintable_names = collections.defaultdict(frozenset)
self._load_game_data()
self._init_game_data()
# Datapackage retrieval
def _load_game_data(self):
import worlds
self.gamespackage = worlds.network_data_package["games"]
self.item_name_groups = {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
self.forced_auto_forfeits[world_name] = world.forced_auto_forfeit
self.non_hintable_names[world_name] = world.hint_blacklist
def _init_game_data(self):
for game_name, game_package in self.gamespackage.items():
for item_name, item_id in game_package["item_name_to_id"].items():
self.item_names[item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[location_id] = location_name
self.all_item_and_group_names[game_name] = \
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
def location_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
return self.gamespackage[game]["location_name_to_id"] if game in self.gamespackage else None
# General networking
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool: async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
if not endpoint.socket or not endpoint.socket.open: if not endpoint.socket or not endpoint.socket.open:
return False return False
@@ -236,16 +273,16 @@ class Context:
def broadcast_all(self, msgs: typing.List[dict]): def broadcast_all(self, msgs: typing.List[dict]):
msgs = self.dumper(msgs) msgs = self.dumper(msgs)
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth) endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs)) async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast_team(self, team: int, msgs: typing.List[dict]): def broadcast_team(self, team: int, msgs: typing.List[dict]):
msgs = self.dumper(msgs) msgs = self.dumper(msgs)
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values())) endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs)) async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]): def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
msgs = self.dumper(msgs) msgs = self.dumper(msgs)
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs)) async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
async def disconnect(self, endpoint: Client): async def disconnect(self, endpoint: Client):
if endpoint in self.endpoints: if endpoint in self.endpoints:
@@ -256,20 +293,27 @@ class Context:
# text # text
def notify_all(self, text): def notify_all(self, text: str):
logging.info("Notice (all): %s" % text) logging.info("Notice (all): %s" % text)
self.broadcast_all([{"cmd": "Print", "text": text}]) broadcast_text_all(self, text)
def notify_client(self, client: Client, text: str): def notify_client(self, client: Client, text: str):
if not client.auth: if not client.auth:
return return
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text)) logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}])) if client.version >= print_command_compatability_threshold:
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
else:
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str]): def notify_client_multiple(self, client: Client, texts: typing.List[str]):
if not client.auth: if not client.auth:
return return
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts])) if client.version >= print_command_compatability_threshold:
async_start(self.send_msgs(client,
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
else:
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
# loading # loading
@@ -544,12 +588,13 @@ class Context:
finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \ finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \
f' has completed their goal.' f' has completed their goal.'
self.notify_all(finished_msg) self.notify_all(finished_msg)
if "auto" in self.forfeit_mode:
forfeit_player(self, client.team, client.slot)
elif proxy_worlds[self.games[client.slot]].forced_auto_forfeit:
forfeit_player(self, client.team, client.slot)
if "auto" in self.collect_mode: if "auto" in self.collect_mode:
collect_player(self, client.team, client.slot) collect_player(self, client.team, client.slot)
if "auto" in self.forfeit_mode:
forfeit_player(self, client.team, client.slot)
elif self.forced_auto_forfeits[self.games[client.slot]]:
forfeit_player(self, client.team, client.slot)
self.save() # save goal completion flag
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False): def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
@@ -582,7 +627,7 @@ def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], onl
continue continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)] client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player == slot)]
for client in clients: for client in clients:
asyncio.create_task(ctx.send_msgs(client, client_hints)) async_start(ctx.send_msgs(client, client_hints))
def update_aliases(ctx: Context, team: int): def update_aliases(ctx: Context, team: int):
@@ -591,7 +636,7 @@ def update_aliases(ctx: Context, team: int):
for clients in ctx.clients[team].values(): for clients in ctx.clients[team].values():
for client in clients: for client in clients:
asyncio.create_task(ctx.send_encoded_msgs(client, cmd)) async_start(ctx.send_encoded_msgs(client, cmd))
async def server(websocket, path: str = "/", ctx: Context = None): async def server(websocket, path: str = "/", ctx: Context = None):
@@ -642,9 +687,10 @@ async def on_client_connected(ctx: Context, client: Client):
'permissions': get_permissions(ctx), 'permissions': get_permissions(ctx),
'hint_cost': ctx.hint_cost, 'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points, 'location_check_points': ctx.location_check_points,
'datapackage_version': network_data_package["version"], 'datapackage_version': sum(game_data["version"] for game_data in ctx.gamespackage.values())
if all(game_data["version"] for game_data in ctx.gamespackage.values()) else 0,
'datapackage_versions': {game: game_data["version"] for game, game_data 'datapackage_versions': {game: game_data["version"] for game, game_data
in network_data_package["games"].items()}, in ctx.gamespackage.items()},
'seed_name': ctx.seed_name, 'seed_name': ctx.seed_name,
'time': time.time(), 'time': time.time(),
}]) }])
@@ -685,20 +731,37 @@ async def on_client_left(ctx: Context, client: Client):
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
async def countdown(ctx: Context, timer): async def countdown(ctx: Context, timer: int):
ctx.notify_all(f'[Server]: Starting countdown of {timer}s') broadcast_countdown(ctx, timer, f"[Server]: Starting countdown of {timer}s")
if ctx.countdown_timer: if ctx.countdown_timer:
ctx.countdown_timer = timer # timer is already running, set it to a different time ctx.countdown_timer = timer # timer is already running, set it to a different time
else: else:
ctx.countdown_timer = timer ctx.countdown_timer = timer
while ctx.countdown_timer > 0: while ctx.countdown_timer > 0:
ctx.notify_all(f'[Server]: {ctx.countdown_timer}') broadcast_countdown(ctx, ctx.countdown_timer, f"[Server]: {ctx.countdown_timer}")
ctx.countdown_timer -= 1 ctx.countdown_timer -= 1
await asyncio.sleep(1) await asyncio.sleep(1)
ctx.notify_all(f'[Server]: GO') broadcast_countdown(ctx, 0, f"[Server]: GO")
ctx.countdown_timer = 0 ctx.countdown_timer = 0
def broadcast_text_all(ctx: Context, text: str, additional_arguments: dict = {}):
old_clients, new_clients = [], []
for teams in ctx.clients.values():
for clients in teams.values():
for client in clients:
new_clients.append(client) if client.version >= print_command_compatability_threshold \
else old_clients.append(client)
ctx.broadcast(old_clients, [{"cmd": "Print", "text": text }])
ctx.broadcast(new_clients, [{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
def broadcast_countdown(ctx: Context, timer: int, message: str):
broadcast_text_all(ctx, message, {"type": "Countdown", "countdown": timer})
def get_players_string(ctx: Context): def get_players_string(ctx: Context):
auth_clients = {(c.team, c.slot) for c in ctx.endpoints if c.auth} auth_clients = {(c.team, c.slot) for c in ctx.endpoints if c.auth}
@@ -751,7 +814,7 @@ def send_new_items(ctx: Context):
items = get_received_items(ctx, team, slot, client.remote_items) items = get_received_items(ctx, team, slot, client.remote_items)
if len(start_inventory) + len(items) > client.send_index: if len(start_inventory) + len(items) > client.send_index:
first_new_item = max(0, client.send_index - len(start_inventory)) first_new_item = max(0, client.send_index - len(start_inventory))
asyncio.create_task(ctx.send_msgs(client, [{ async_start(ctx.send_msgs(client, [{
"cmd": "ReceivedItems", "cmd": "ReceivedItems",
"index": client.send_index, "index": client.send_index,
"items": start_inventory[client.send_index:] + items[first_new_item:]}])) "items": start_inventory[client.send_index:] + items[first_new_item:]}]))
@@ -822,8 +885,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
send_items_to(ctx, team, target_player, new_item) send_items_to(ctx, team, target_player, new_item)
logging.info('(Team #%d) %s sent %s to %s (%s)' % ( logging.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id), team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
ctx.player_names[(team, target_player)], get_location_name_from_id(location))) ctx.player_names[(team, target_player)], ctx.location_names[location]))
info_text = json_format_send_event(new_item, target_player) info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text]) ctx.broadcast_team(team, [info_text])
@@ -838,13 +901,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
ctx.save() ctx.save()
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]: def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
hints = [] hints = []
slots: typing.Set[int] = {slot} slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items(): for group_id, group in ctx.groups.items():
if slot in group: if slot in group:
slots.add(group_id) slots.add(group_id)
seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item]
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
for finding_player, check_data in ctx.locations.items(): for finding_player, check_data in ctx.locations.items():
for location_id, (item_id, receiving_player, item_flags) in check_data.items(): for location_id, (item_id, receiving_player, item_flags) in check_data.items():
if receiving_player in slots and item_id == seeked_item_id: if receiving_player in slots and item_id == seeked_item_id:
@@ -857,7 +921,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]: def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
seeked_location: int = proxy_worlds[ctx.games[slot]].location_name_to_id[location] seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
return collect_hint_location_id(ctx, team, slot, seeked_location) return collect_hint_location_id(ctx, team, slot, seeked_location)
@@ -874,8 +938,8 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str: def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \ text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
f"{lookup_any_item_id_to_name[hint.item]} is " \ f"{ctx.item_names[hint.item]} is " \
f"at {get_location_name_from_id(hint.location)} " \ f"at {ctx.location_names[hint.location]} " \
f"in {ctx.player_names[team, hint.finding_player]}'s World" f"in {ctx.player_names[team, hint.finding_player]}'s World"
if hint.entrance: if hint.entrance:
@@ -934,7 +998,11 @@ class CommandMeta(type):
return super(CommandMeta, cls).__new__(cls, name, bases, attrs) return super(CommandMeta, cls).__new__(cls, name, bases, attrs)
def mark_raw(function): _Return = typing.TypeVar("_Return")
# TODO: when python 3.10 is lowest supported, typing.ParamSpec
def mark_raw(function: typing.Callable[[typing.Any], _Return]) -> typing.Callable[[typing.Any], _Return]:
function.raw_text = True function.raw_text = True
return function return function
@@ -1022,7 +1090,7 @@ class CommonCommandProcessor(CommandProcessor):
timer = int(seconds, 10) timer = int(seconds, 10)
except ValueError: except ValueError:
timer = 10 timer = 10
asyncio.create_task(countdown(self.ctx, timer)) async_start(countdown(self.ctx, timer))
return True return True
def _cmd_options(self): def _cmd_options(self):
@@ -1133,8 +1201,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
forfeit_player(self.ctx, self.client.team, self.client.slot) forfeit_player(self.ctx, self.client.team, self.client.slot)
return True return True
elif "disabled" in self.ctx.forfeit_mode: elif "disabled" in self.ctx.forfeit_mode:
self.output( self.output("Sorry, client item releasing has been disabled on this server. "
"Sorry, client item releasing has been disabled on this server. You can ask the server admin for a /release") "You can ask the server admin for a /release")
return False return False
else: # is auto or goal else: # is auto or goal
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL: if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
@@ -1170,7 +1238,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.remaining_mode == "enabled": if self.ctx.remaining_mode == "enabled":
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item") self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
for item_id in remaining_item_ids)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")
@@ -1183,7 +1251,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL: if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item") self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
for item_id in remaining_item_ids)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")
@@ -1199,7 +1267,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot) locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
if locations: if locations:
texts = [f'Missing: {get_location_name_from_id(location)}' for location in locations] texts = [f'Missing: {self.ctx.location_names[location]}' for location in locations]
texts.append(f"Found {len(locations)} missing location checks") texts.append(f"Found {len(locations)} missing location checks")
self.ctx.notify_client_multiple(self.client, texts) self.ctx.notify_client_multiple(self.client, texts)
else: else:
@@ -1212,7 +1280,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot) locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
if locations: if locations:
texts = [f'Checked: {get_location_name_from_id(location)}' for location in locations] texts = [f'Checked: {self.ctx.location_names[location]}' for location in locations]
texts.append(f"Found {len(locations)} done location checks") texts.append(f"Found {len(locations)} done location checks")
self.ctx.notify_client_multiple(self.client, texts) self.ctx.notify_client_multiple(self.client, texts)
else: else:
@@ -1241,11 +1309,13 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_getitem(self, item_name: str) -> bool: def _cmd_getitem(self, item_name: str) -> bool:
"""Cheat in an item, if it is enabled on this server""" """Cheat in an item, if it is enabled on this server"""
if self.ctx.item_cheat: if self.ctx.item_cheat:
world = proxy_worlds[self.ctx.games[self.client.slot]] names = self.ctx.item_names_for_game(self.ctx.games[self.client.slot])
item_name, usable, response = get_intended_text(item_name, item_name, usable, response = get_intended_text(
world.item_names) item_name,
names
)
if usable: if usable:
new_item = NetworkItem(world.create_item(item_name).code, -1, self.client.slot) new_item = NetworkItem(names[item_name], -1, self.client.slot)
get_received_items(self.ctx, self.client.team, self.client.slot, False).append(new_item) get_received_items(self.ctx, self.client.team, self.client.slot, False).append(new_item)
get_received_items(self.ctx, self.client.team, self.client.slot, True).append(new_item) get_received_items(self.ctx, self.client.team, self.client.slot, True).append(new_item)
self.ctx.notify_all( self.ctx.notify_all(
@@ -1262,6 +1332,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool: def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client) points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot)
if not input_text: if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]} self.ctx.hints[self.client.team, self.client.slot]}
@@ -1270,88 +1342,116 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. " self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
f"You have {points_available} points.") f"You have {points_available} points.")
return True return True
elif input_text.isnumeric():
game = self.ctx.games[self.client.slot]
hint_id = int(input_text)
hint_name = self.ctx.item_names[hint_id] \
if not for_location and hint_id in self.ctx.item_names \
else self.ctx.location_names[hint_id] \
if for_location and hint_id in self.ctx.location_names \
else None
if hint_name in self.ctx.non_hintable_names[game]:
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = []
elif not for_location:
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
else:
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
else: else:
world = proxy_worlds[self.ctx.games[self.client.slot]] game = self.ctx.games[self.client.slot]
names = world.location_names if for_location else world.all_item_and_group_names if game not in self.ctx.all_item_and_group_names:
hint_name, usable, response = get_intended_text(input_text, self.output("Can't look up item/location for unknown game. Hint for ID instead.")
names) return False
names = self.ctx.location_names_for_game(game) \
if for_location else \
self.ctx.all_item_and_group_names[game]
hint_name, usable, response = get_intended_text(input_text, names)
if usable: if usable:
if hint_name in world.hint_blacklist: if hint_name in self.ctx.non_hintable_names[game]:
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = [] hints = []
elif not for_location and hint_name in world.item_name_groups: # item group name elif not for_location and hint_name in self.ctx.item_name_groups[game]: # item group name
hints = [] hints = []
for item in world.item_name_groups[hint_name]: for item_name in self.ctx.item_name_groups[game][hint_name]:
if item in world.item_name_to_id: # ensure item has an ID if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item)) hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
elif not for_location and hint_name in world.item_names: # item name elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name) hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
else: # location name else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name) hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
cost = self.ctx.get_hint_cost(self.client.slot)
if hints:
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
old_hints = set(hints) - new_hints
if old_hints:
notify_hints(self.ctx, self.client.team, list(old_hints))
if not new_hints:
self.output("Hint was previously used, no points deducted.")
if new_hints:
found_hints = [hint for hint in new_hints if hint.found]
not_found_hints = [hint for hint in new_hints if not hint.found]
if not not_found_hints: # everything's been found, no need to pay
can_pay = 1000
elif cost:
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
else:
can_pay = 1000
self.ctx.random.shuffle(not_found_hints)
# By popular vote, make hints prefer non-local placements
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
hints = found_hints
while can_pay > 0:
if not not_found_hints:
break
hint = not_found_hints.pop()
hints.append(hint)
can_pay -= 1
self.ctx.hints_used[self.client.team, self.client.slot] += 1
points_available = get_client_points(self.ctx, self.client)
if not_found_hints:
if hints and cost and int((points_available // cost) == 0):
self.output(
f"There may be more hintables, however, you cannot afford to pay for any more. "
f" You have {points_available} and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
elif hints:
self.output(
"There may be more hintables, you can rerun the command to find more.")
else:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
notify_hints(self.ctx, self.client.team, hints)
self.ctx.save()
return True
else:
self.output("Nothing found. Item/Location may not exist.")
return False
else: else:
self.output(response) self.output(response)
return False return False
if hints:
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
old_hints = set(hints) - new_hints
if old_hints:
notify_hints(self.ctx, self.client.team, list(old_hints))
if not new_hints:
self.output("Hint was previously used, no points deducted.")
if new_hints:
found_hints = [hint for hint in new_hints if hint.found]
not_found_hints = [hint for hint in new_hints if not hint.found]
if not not_found_hints: # everything's been found, no need to pay
can_pay = 1000
elif cost:
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
else:
can_pay = 1000
self.ctx.random.shuffle(not_found_hints)
# By popular vote, make hints prefer non-local placements
not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player))
hints = found_hints
while can_pay > 0:
if not not_found_hints:
break
hint = not_found_hints.pop()
hints.append(hint)
can_pay -= 1
self.ctx.hints_used[self.client.team, self.client.slot] += 1
points_available = get_client_points(self.ctx, self.client)
if not_found_hints:
if hints and cost and int((points_available // cost) == 0):
self.output(
f"There may be more hintables, however, you cannot afford to pay for any more. "
f" You have {points_available} and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
elif hints:
self.output(
"There may be more hintables, you can rerun the command to find more.")
else:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
notify_hints(self.ctx, self.client.team, hints)
self.ctx.save()
return True
else:
if points_available >= cost:
self.output("Nothing found. Item/Location may not exist.")
else:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.get_hint_cost(self.client.slot)}.")
return False
@mark_raw @mark_raw
def _cmd_hint(self, item: str = "") -> bool: def _cmd_hint(self, item_name: str = "") -> bool:
"""Use !hint {item_name}, """Use !hint {item_name},
for example !hint Lamp to get a spoiler peek for that item. for example !hint Lamp to get a spoiler peek for that item.
If hint costs are on, this will only give you one new result, If hint costs are on, this will only give you one new result,
you can rerun the command to get more in that case.""" you can rerun the command to get more in that case."""
return self.get_hints(item) return self.get_hints(item_name)
@mark_raw @mark_raw
def _cmd_hint_location(self, location: str = "") -> bool: def _cmd_hint_location(self, location: str = "") -> bool:
@@ -1477,23 +1577,23 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
elif cmd == "GetDataPackage": elif cmd == "GetDataPackage":
exclusions = args.get("exclusions", []) exclusions = args.get("exclusions", [])
if "games" in args: if "games" in args:
games = {name: game_data for name, game_data in network_data_package["games"].items() games = {name: game_data for name, game_data in ctx.gamespackage.items()
if name in set(args.get("games", []))} if name in set(args.get("games", []))}
await ctx.send_msgs(client, [{"cmd": "DataPackage", await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": {"games": games}}]) "data": {"games": games}}])
# TODO: remove exclusions behaviour around 0.5.0 # TODO: remove exclusions behaviour around 0.5.0
elif exclusions: elif exclusions:
exclusions = set(exclusions) exclusions = set(exclusions)
games = {name: game_data for name, game_data in network_data_package["games"].items() games = {name: game_data for name, game_data in ctx.gamespackage.items()
if name not in exclusions} if name not in exclusions}
package = network_data_package.copy()
package["games"] = games package = {"games": games}
await ctx.send_msgs(client, [{"cmd": "DataPackage", await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": package}]) "data": package}])
else: else:
await ctx.send_msgs(client, [{"cmd": "DataPackage", await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": network_data_package}]) "data": {"games": ctx.gamespackage}}])
elif client.auth: elif client.auth:
if cmd == "ConnectUpdate": if cmd == "ConnectUpdate":
@@ -1549,7 +1649,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
create_as_hint: int = int(args.get("create_as_hint", 0)) create_as_hint: int = int(args.get("create_as_hint", 0))
hints = [] hints = []
for location in args["locations"]: for location in args["locations"]:
if type(location) is not int or location not in lookup_any_location_id_to_name: if type(location) is not int:
await ctx.send_msgs(client, await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts', [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
"original_cmd": cmd}]) "original_cmd": cmd}])
@@ -1671,7 +1771,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_exit(self) -> bool: def _cmd_exit(self) -> bool:
"""Shutdown the server""" """Shutdown the server"""
asyncio.create_task(self.ctx.server.ws_server._close()) async_start(self.ctx.server.ws_server._close())
if self.ctx.shutdown_task: if self.ctx.shutdown_task:
self.ctx.shutdown_task.cancel() self.ctx.shutdown_task.cancel()
self.ctx.exit_event.set() self.ctx.exit_event.set()
@@ -1702,14 +1802,33 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(response) self.output(response)
return False return False
def resolve_player(self, input_name: str) -> typing.Optional[typing.Tuple[int, int, str]]:
""" returns (team, slot, player name) """
# TODO: clean up once we disallow multidata < 0.3.6, which has CI unique names
# first match case
for (team, slot), name in self.ctx.player_names.items():
if name == input_name:
return team, slot, name
# if no case-sensitive match, then match without case only if there's only 1 match
input_lower = input_name.lower()
match: typing.Optional[typing.Tuple[int, int, str]] = None
for (team, slot), name in self.ctx.player_names.items():
lowered = name.lower()
if lowered == input_lower:
if match:
return None # ambiguous input_name
match = (team, slot, name)
return match
@mark_raw @mark_raw
def _cmd_collect(self, player_name: str) -> bool: def _cmd_collect(self, player_name: str) -> bool:
"""Send out the remaining items to player.""" """Send out the remaining items to player."""
seeked_player = player_name.lower() player = self.resolve_player(player_name)
for (team, slot), name in self.ctx.player_names.items(): if player:
if name.lower() == seeked_player: team, slot, _ = player
collect_player(self.ctx, team, slot) collect_player(self.ctx, team, slot)
return True return True
self.output(f"Could not find player {player_name} to collect") self.output(f"Could not find player {player_name} to collect")
return False return False
@@ -1722,11 +1841,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw @mark_raw
def _cmd_forfeit(self, player_name: str) -> bool: def _cmd_forfeit(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients.""" """Send out the remaining items from a player to their intended recipients."""
seeked_player = player_name.lower() player = self.resolve_player(player_name)
for (team, slot), name in self.ctx.player_names.items(): if player:
if name.lower() == seeked_player: team, slot, _ = player
forfeit_player(self.ctx, team, slot) forfeit_player(self.ctx, team, slot)
return True return True
self.output(f"Could not find player {player_name} to release") self.output(f"Could not find player {player_name} to release")
return False return False
@@ -1734,12 +1853,12 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw @mark_raw
def _cmd_allow_forfeit(self, player_name: str) -> bool: def _cmd_allow_forfeit(self, player_name: str) -> bool:
"""Allow the specified player to use the !release command.""" """Allow the specified player to use the !release command."""
seeked_player = player_name.lower() player = self.resolve_player(player_name)
for (team, slot), name in self.ctx.player_names.items(): if player:
if name.lower() == seeked_player: team, slot, name = player
self.ctx.allow_forfeits[(team, slot)] = True self.ctx.allow_forfeits[(team, slot)] = True
self.output(f"Player {player_name} is now allowed to use the !release command at any time.") self.output(f"Player {name} is now allowed to use the !release command at any time.")
return True return True
self.output(f"Could not find player {player_name} to allow the !release command for.") self.output(f"Could not find player {player_name} to allow the !release command for.")
return False return False
@@ -1747,13 +1866,12 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw @mark_raw
def _cmd_forbid_forfeit(self, player_name: str) -> bool: def _cmd_forbid_forfeit(self, player_name: str) -> bool:
""""Disallow the specified player from using the !release command.""" """"Disallow the specified player from using the !release command."""
seeked_player = player_name.lower() player = self.resolve_player(player_name)
for (team, slot), name in self.ctx.player_names.items(): if player:
if name.lower() == seeked_player: team, slot, name = player
self.ctx.allow_forfeits[(team, slot)] = False self.ctx.allow_forfeits[(team, slot)] = False
self.output( self.output(f"Player {name} has to follow the server restrictions on use of the !release command.")
f"Player {player_name} has to follow the server restrictions on use of the !release command.") return True
return True
self.output(f"Could not find player {player_name} to forbid the !release command for.") self.output(f"Could not find player {player_name} to forbid the !release command for.")
return False return False
@@ -1763,18 +1881,18 @@ class ServerCommandProcessor(CommonCommandProcessor):
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values()) seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable: if usable:
team, slot = self.ctx.player_name_lookup[seeked_player] team, slot = self.ctx.player_name_lookup[seeked_player]
item = " ".join(item_name) item_name = " ".join(item_name)
world = proxy_worlds[self.ctx.games[slot]] names = self.ctx.item_names_for_game(self.ctx.games[slot])
item, usable, response = get_intended_text(item, world.item_names) item_name, usable, response = get_intended_text(item_name, names)
if usable: if usable:
amount: int = int(amount) amount: int = int(amount)
new_items = [NetworkItem(world.item_name_to_id[item], -1, 0) for i in range(int(amount))] new_items = [NetworkItem(names[item_name], -1, 0) for _ in range(int(amount))]
send_items_to(self.ctx, team, slot, *new_items) send_items_to(self.ctx, team, slot, *new_items)
send_new_items(self.ctx) send_new_items(self.ctx)
self.ctx.notify_all( self.ctx.notify_all(
'Cheat console: sending ' + ('' if amount == 1 else f'{amount} of ') + 'Cheat console: sending ' + ('' if amount == 1 else f'{amount} of ') +
f'"{item}" to {self.ctx.get_aliased_name(team, slot)}') f'"{item_name}" to {self.ctx.get_aliased_name(team, slot)}')
return True return True
else: else:
self.output(response) self.output(response)
@@ -1787,21 +1905,29 @@ class ServerCommandProcessor(CommonCommandProcessor):
"""Sends an item to the specified player""" """Sends an item to the specified player"""
return self._cmd_send_multiple(1, player_name, *item_name) return self._cmd_send_multiple(1, player_name, *item_name)
def _cmd_hint(self, player_name: str, *item: str) -> bool: def _cmd_hint(self, player_name: str, *item_name: str) -> bool:
"""Send out a hint for a player's item to their team""" """Send out a hint for a player's item to their team"""
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values()) seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable: if usable:
team, slot = self.ctx.player_name_lookup[seeked_player] team, slot = self.ctx.player_name_lookup[seeked_player]
item = " ".join(item) game = self.ctx.games[slot]
world = proxy_worlds[self.ctx.games[slot]] full_name = " ".join(item_name)
item, usable, response = get_intended_text(item, world.all_item_and_group_names)
if full_name.isnumeric():
item, usable, response = int(full_name), True, None
elif game in self.ctx.all_item_and_group_names:
item, usable, response = get_intended_text(full_name, self.ctx.all_item_and_group_names[game])
else:
self.output("Can't look up item for unknown game. Hint for ID instead.")
return False
if usable: if usable:
if item in world.item_name_groups: if game in self.ctx.item_name_groups and item in self.ctx.item_name_groups[game]:
hints = [] hints = []
for item in world.item_name_groups[item]: for item_name_from_group in self.ctx.item_name_groups[game][item]:
if item in world.item_name_to_id: # ensure item has an ID if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item)) hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
else: # item name else: # item name or id
hints = collect_hints(self.ctx, team, slot, item) hints = collect_hints(self.ctx, team, slot, item)
if hints: if hints:
@@ -1818,16 +1944,27 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(response) self.output(response)
return False return False
def _cmd_hint_location(self, player_name: str, *location: str) -> bool: def _cmd_hint_location(self, player_name: str, *location_name: str) -> bool:
"""Send out a hint for a player's location to their team""" """Send out a hint for a player's location to their team"""
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values()) seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable: if usable:
team, slot = self.ctx.player_name_lookup[seeked_player] team, slot = self.ctx.player_name_lookup[seeked_player]
item = " ".join(location) game = self.ctx.games[slot]
world = proxy_worlds[self.ctx.games[slot]] full_name = " ".join(location_name)
item, usable, response = get_intended_text(item, world.location_names)
if full_name.isnumeric():
location, usable, response = int(full_name), True, None
elif self.ctx.location_names_for_game(game) is not None:
location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game))
else:
self.output("Can't look up location for unknown game. Hint for ID instead.")
return False
if usable: if usable:
hints = collect_hint_location_name(self.ctx, team, slot, item) if isinstance(location, int):
hints = collect_hint_location_id(self.ctx, team, slot, location)
else:
hints = collect_hint_location_name(self.ctx, team, slot, location)
if hints: if hints:
notify_hints(self.ctx, team, hints) notify_hints(self.ctx, team, hints)
else: else:
@@ -1947,7 +2084,7 @@ async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(ctx.auto_shutdown) await asyncio.sleep(ctx.auto_shutdown)
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
if not ctx.client_activity_timers.values(): if not ctx.client_activity_timers.values():
asyncio.create_task(ctx.server.ws_server._close()) async_start(ctx.server.ws_server._close())
ctx.exit_event.set() ctx.exit_event.set()
if to_cancel: if to_cancel:
for task in to_cancel: for task in to_cancel:
@@ -1958,7 +2095,7 @@ async def auto_shutdown(ctx, to_cancel=None):
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
seconds = ctx.auto_shutdown - delta.total_seconds() seconds = ctx.auto_shutdown - delta.total_seconds()
if seconds < 0: if seconds < 0:
asyncio.create_task(ctx.server.ws_server._close()) async_start(ctx.server.ws_server._close())
ctx.exit_event.set() ctx.exit_event.set()
if to_cancel: if to_cancel:
for task in to_cancel: for task in to_cancel:
@@ -1977,15 +2114,28 @@ async def main(args: argparse.Namespace):
args.auto_shutdown, args.compatibility, args.log_network) args.auto_shutdown, args.compatibility, args.log_network)
data_filename = args.multidata data_filename = args.multidata
try: if not data_filename:
if not data_filename: try:
filetypes = (("Multiworld data", (".archipelago", ".zip")),) filetypes = (("Multiworld data", (".archipelago", ".zip")),)
data_filename = Utils.open_filename("Select multiworld data", filetypes) data_filename = Utils.open_filename("Select multiworld data", filetypes)
except Exception as e:
if isinstance(e, ImportError) or (e.__class__.__name__ == "TclError" and "no display" in str(e)):
if not isinstance(e, ImportError):
logging.error(f"Failed to load tkinter ({e})")
logging.info("Pass a multidata filename on command line to run headless.")
exit(1)
raise
if not data_filename:
logging.info("No file selected. Exiting.")
exit(1)
try:
ctx.load(data_filename, args.use_embedded_options) ctx.load(data_filename, args.use_embedded_options)
except Exception as e: except Exception as e:
logging.exception('Failed to read multiworld data (%s)' % e) logging.exception(f"Failed to read multiworld data ({e})")
raise raise
ctx.init_save(not args.disable_save) ctx.init_save(not args.disable_save)

View File

@@ -100,7 +100,7 @@ _encode = JSONEncoder(
).encode ).encode
def encode(obj): def encode(obj: typing.Any) -> str:
return _encode(_scan_for_TypedTuples(obj)) return _encode(_scan_for_TypedTuples(obj))
@@ -270,7 +270,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34, color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43, 'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
'blue_bg': 44, 'purple_bg': 45, 'cyan_bg': 46, 'white_bg': 47} 'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
def color_code(*args): def color_code(*args):

View File

@@ -5,9 +5,11 @@ import multiprocessing
import subprocess import subprocess
from asyncio import StreamReader, StreamWriter from asyncio import StreamReader, StreamWriter
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \ # CommonClient import first to trigger ModuleUpdater
from CommonClient import CommonContext, server_loop, gui_enabled, \
ClientCommandProcessor, logger, get_base_parser ClientCommandProcessor, logger, get_base_parser
import Utils import Utils
from Utils import async_start
from worlds import network_data_package from worlds import network_data_package
from worlds.oot.Rom import Rom, compress_rom_file from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file from worlds.oot.N64Patch import apply_patch_file
@@ -68,7 +70,7 @@ class OoTCommandProcessor(ClientCommandProcessor):
if isinstance(self.ctx, OoTContext): if isinstance(self.ctx, OoTContext):
self.ctx.deathlink_client_override = True self.ctx.deathlink_client_override = True
self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled self.ctx.deathlink_enabled = not self.ctx.deathlink_enabled
asyncio.create_task(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink") async_start(self.ctx.update_death_link(self.ctx.deathlink_enabled), name="Update Deathlink")
class OoTContext(CommonContext): class OoTContext(CommonContext):
@@ -132,6 +134,19 @@ def get_payload(ctx: OoTContext):
async def parse_payload(payload: dict, ctx: OoTContext, force: bool): async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
# Refuse to do anything if ROM is detected as changed
if ctx.auth and payload['playerName'] != ctx.auth:
logger.warning("ROM change detected. Disconnecting and reconnecting...")
ctx.deathlink_enabled = False
ctx.deathlink_client_override = False
ctx.finished_game = False
ctx.location_table = {}
ctx.deathlink_pending = False
ctx.deathlink_sent_this_death = False
ctx.auth = payload['playerName']
await ctx.send_connect()
return
# Turn on deathlink if it is on, and if the client hasn't overriden it # Turn on deathlink if it is on, and if the client hasn't overriden it
if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override: if payload['deathlinkActive'] and not ctx.deathlink_enabled and not ctx.deathlink_client_override:
await ctx.update_death_link(True) await ctx.update_death_link(True)
@@ -189,7 +204,7 @@ async def n64_sync_task(ctx: OoTContext):
if reported_version >= script_version: if reported_version >= script_version:
if ctx.game is not None and 'locations' in data_decoded: if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse # Not just a keep alive ping, parse
asyncio.create_task(parse_payload(data_decoded, ctx, False)) async_start(parse_payload(data_decoded, ctx, False))
if not ctx.auth: if not ctx.auth:
ctx.auth = data_decoded['playerName'] ctx.auth = data_decoded['playerName']
if ctx.awaiting_rom: if ctx.awaiting_rom:
@@ -265,7 +280,7 @@ async def patch_and_run_game(apz5_file):
os.chdir(data_path("Compress")) os.chdir(data_path("Compress"))
compress_rom_file(decomp_path, comp_path) compress_rom_file(decomp_path, comp_path)
os.remove(decomp_path) os.remove(decomp_path)
asyncio.create_task(run_game(comp_path)) async_start(run_game(comp_path))
if __name__ == '__main__': if __name__ == '__main__':
@@ -281,7 +296,7 @@ if __name__ == '__main__':
if args.apz5_file: if args.apz5_file:
logger.info("APZ5 file supplied, beginning patching process...") logger.info("APZ5 file supplied, beginning patching process...")
asyncio.create_task(patch_and_run_game(args.apz5_file)) async_start(patch_and_run_game(args.apz5_file))
ctx = OoTContext(args.connect, args.password) ctx = OoTContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import abc import abc
from copy import deepcopy
import math import math
import numbers import numbers
import typing import typing
@@ -26,15 +27,31 @@ class AssembleOptions(abc.ABCMeta):
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()}) attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
options.update(new_options) options.update(new_options)
# apply aliases, without name_lookup # apply aliases, without name_lookup
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")} name.startswith("alias_")}
assert "random" not in aliases, "Choice option 'random' cannot be manually assigned." assert "random" not in aliases, "Choice option 'random' cannot be manually assigned."
# auto-alias Off and On being parsed as True and False
if "off" in options:
options["false"] = options["off"]
if "on" in options:
options["true"] = options["on"]
options.update(aliases) options.update(aliases)
if "verify" not in attrs:
# not overridden by class -> look up bases
verifiers = [f for f in (getattr(base, "verify", None) for base in bases) if f]
if len(verifiers) > 1: # verify multiple bases/mixins
def verify(self, *args, **kwargs) -> None:
for f in verifiers:
f(self, *args, **kwargs)
attrs["verify"] = verify
else:
assert verifiers, "class Option is supposed to implement def verify"
# auto-validate schema on __init__ # auto-validate schema on __init__
if "schema" in attrs.keys(): if "schema" in attrs.keys():
@@ -62,6 +79,9 @@ class AssembleOptions(abc.ABCMeta):
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs) return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
@abc.abstractclassmethod
def from_any(cls, value: typing.Any) -> "Option[typing.Any]": ...
T = typing.TypeVar('T') T = typing.TypeVar('T')
@@ -112,8 +132,44 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
def from_any(cls, data: typing.Any) -> Option[T]: def from_any(cls, data: typing.Any) -> Option[T]:
raise NotImplementedError raise NotImplementedError
if typing.TYPE_CHECKING:
from Generate import PlandoSettings
from worlds.AutoWorld import World
def verify(self, world: World, player_name: str, plando_options: PlandoSettings) -> None:
pass
else:
def verify(self, *args, **kwargs) -> None:
pass
class FreeText(Option):
"""Text option that allows users to enter strings.
Needs to be validated by the world or option definition."""
def __init__(self, value: str):
assert isinstance(value, str), "value of FreeText must be a string"
self.value = value
@property
def current_key(self) -> str:
return self.value
@classmethod
def from_text(cls, text: str) -> FreeText:
return cls(text)
@classmethod
def from_any(cls, data: typing.Any) -> FreeText:
return cls.from_text(str(data))
@classmethod
def get_option_name(cls, value: T) -> str:
return value
class NumericOption(Option[int], numbers.Integral): class NumericOption(Option[int], numbers.Integral):
default = 0
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards # note: some of the `typing.Any`` here is a result of unresolved issue in python standards
# `int` is not a `numbers.Integral` according to the official typestubs # `int` is not a `numbers.Integral` according to the official typestubs
# (even though isinstance(5, numbers.Integral) == True) # (even though isinstance(5, numbers.Integral) == True)
@@ -298,7 +354,7 @@ class Toggle(NumericOption):
if type(data) == str: if type(data) == str:
return cls.from_text(data) return cls.from_text(data)
else: else:
return cls(data) return cls(int(data))
@classmethod @classmethod
def get_option_name(cls, value): def get_option_name(cls, value):
@@ -368,6 +424,170 @@ class Choice(NumericOption):
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__ __hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
class TextChoice(Choice):
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
def __init__(self, value: typing.Union[str, int]):
assert isinstance(value, str) or isinstance(value, int), \
f"{value} is not a valid option for {self.__class__.__name__}"
self.value = value
@property
def current_key(self) -> str:
if isinstance(self.value, str):
return self.value
else:
return self.name_lookup[self.value]
@classmethod
def from_text(cls, text: str) -> TextChoice:
if text.lower() == "random": # chooses a random defined option but won't use any free text options
return cls(random.choice(list(cls.name_lookup)))
for option_name, value in cls.options.items():
if option_name.lower() == text.lower():
return cls(value)
return cls(text)
@classmethod
def get_option_name(cls, value: T) -> str:
if isinstance(value, str):
return value
return cls.name_lookup[value]
def __eq__(self, other: typing.Any):
if isinstance(other, self.__class__):
return other.value == self.value
elif isinstance(other, str):
if other in self.options:
return other == self.current_key
return other == self.value
elif isinstance(other, int):
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
return other == self.value
elif isinstance(other, bool):
return other == bool(self.value)
else:
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
class BossMeta(AssembleOptions):
def __new__(mcs, name, bases, attrs):
if name != "PlandoBosses":
assert "bosses" in attrs, f"Please define valid bosses for {name}"
attrs["bosses"] = frozenset((boss.lower() for boss in attrs["bosses"]))
assert "locations" in attrs, f"Please define valid locations for {name}"
attrs["locations"] = frozenset((location.lower() for location in attrs["locations"]))
cls = super().__new__(mcs, name, bases, attrs)
assert not cls.duplicate_bosses or "singularity" in cls.options, f"Please define option_singularity for {name}"
return cls
class PlandoBosses(TextChoice, metaclass=BossMeta):
"""Generic boss shuffle option that supports plando. Format expected is
'location1-boss1;location2-boss2;shuffle_mode'.
If shuffle_mode is not provided in the string, this will be the default shuffle mode. Must override can_place_boss,
which passes a plando boss and location. Check if the placement is valid for your game here."""
bosses: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
locations: typing.ClassVar[typing.Union[typing.Set[str], typing.FrozenSet[str]]]
duplicate_bosses: bool = False
@classmethod
def from_text(cls, text: str):
# set all of our text to lower case for name checking
text = text.lower()
if text == "random":
return cls(random.choice(list(cls.options.values())))
for option_name, value in cls.options.items():
if option_name == text:
return cls(value)
options = text.split(";")
# since plando exists in the option verify the plando values given are valid
cls.validate_plando_bosses(options)
return cls.get_shuffle_mode(options)
@classmethod
def get_shuffle_mode(cls, option_list: typing.List[str]):
# find out what mode of boss shuffle we should use for placing bosses after plando
# and add as a string to look nice in the spoiler
if "random" in option_list:
shuffle = random.choice(list(cls.options))
option_list.remove("random")
options = ";".join(option_list) + f";{shuffle}"
boss_class = cls(options)
else:
for option in option_list:
if option in cls.options:
options = ";".join(option_list)
break
else:
if cls.duplicate_bosses and len(option_list) == 1:
if cls.valid_boss_name(option_list[0]):
# this doesn't exist in this class but it's a forced option for classes where this is called
options = option_list[0] + ";singularity"
else:
options = option_list[0] + f";{cls.name_lookup[cls.default]}"
else:
options = ";".join(option_list) + f";{cls.name_lookup[cls.default]}"
boss_class = cls(options)
return boss_class
@classmethod
def validate_plando_bosses(cls, options: typing.List[str]) -> None:
used_locations = []
used_bosses = []
for option in options:
# check if a shuffle mode was provided in the incorrect location
if option == "random" or option in cls.options:
if option != options[-1]:
raise ValueError(f"{option} option must be at the end of the boss_shuffle options!")
elif "-" in option:
location, boss = option.split("-")
if location in used_locations:
raise ValueError(f"Duplicate Boss Location {location} not allowed.")
if not cls.duplicate_bosses and boss in used_bosses:
raise ValueError(f"Duplicate Boss {boss} not allowed.")
used_locations.append(location)
used_bosses.append(boss)
if not cls.valid_boss_name(boss):
raise ValueError(f"{boss.title()} is not a valid boss name.")
if not cls.valid_location_name(location):
raise ValueError(f"{location.title()} is not a valid boss location name.")
if not cls.can_place_boss(boss, location):
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
else:
if cls.duplicate_bosses:
if not cls.valid_boss_name(option):
raise ValueError(f"{option} is not a valid boss name.")
else:
raise ValueError(f"{option.title()} is not formatted correctly.")
@classmethod
def can_place_boss(cls, boss: str, location: str) -> bool:
raise NotImplementedError
@classmethod
def valid_boss_name(cls, value: str) -> bool:
return value in cls.bosses
@classmethod
def valid_location_name(cls, value: str) -> bool:
return value in cls.locations
def verify(self, world, player_name: str, plando_options) -> None:
if isinstance(self.value, int):
return
from Generate import PlandoSettings
if not(PlandoSettings.bosses & plando_options):
import logging
# plando is disabled but plando options were given so pull the option and change it to an int
option = self.value.split(";")[-1]
self.value = self.options[option]
logging.warning(f"The plando bosses module is turned off, so {self.name_lookup[self.value].title()} "
f"boss shuffle will be used for player {player_name}.")
class Range(NumericOption): class Range(NumericOption):
range_start = 0 range_start = 0
range_end = 1 range_end = 1
@@ -385,7 +605,7 @@ class Range(NumericOption):
if text.startswith("random"): if text.startswith("random"):
return cls.weighted_range(text) return cls.weighted_range(text)
elif text == "default" and hasattr(cls, "default"): elif text == "default" and hasattr(cls, "default"):
return cls(cls.default) return cls.from_any(cls.default)
elif text == "high": elif text == "high":
return cls(cls.range_end) return cls(cls.range_end)
elif text == "low": elif text == "low":
@@ -396,7 +616,7 @@ class Range(NumericOption):
and text in ("true", "false"): and text in ("true", "false"):
# these are the conditions where "true" and "false" make sense # these are the conditions where "true" and "false" make sense
if text == "true": if text == "true":
return cls(cls.default) return cls.from_any(cls.default)
else: # "false" else: # "false"
return cls(0) return cls(0)
return cls(int(text)) return cls(int(text))
@@ -507,7 +727,7 @@ class VerifyKeys:
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. " raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls.valid_keys}.") f"Allowed keys: {cls.valid_keys}.")
def verify(self, world): def verify(self, world, player_name: str, plando_options) -> None:
if self.convert_name_groups and self.verify_item_name: if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value: for item_name in self.value:
@@ -530,11 +750,11 @@ class VerifyKeys:
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys): class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
default = {} default: typing.Dict[str, typing.Any] = {}
supports_weighting = False supports_weighting = False
def __init__(self, value: typing.Dict[str, typing.Any]): def __init__(self, value: typing.Dict[str, typing.Any]):
self.value = value self.value = deepcopy(value)
@classmethod @classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict: def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
@@ -561,11 +781,11 @@ class ItemDict(OptionDict):
class OptionList(Option[typing.List[typing.Any]], VerifyKeys): class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
default = [] default: typing.List[typing.Any] = []
supports_weighting = False supports_weighting = False
def __init__(self, value: typing.List[typing.Any]): def __init__(self, value: typing.List[typing.Any]):
self.value = value or [] self.value = deepcopy(value)
super(OptionList, self).__init__() super(OptionList, self).__init__()
@classmethod @classmethod
@@ -587,11 +807,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
class OptionSet(Option[typing.Set[str]], VerifyKeys): class OptionSet(Option[typing.Set[str]], VerifyKeys):
default = frozenset() default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset()
supports_weighting = False supports_weighting = False
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]): def __init__(self, value: typing.Iterable[str]):
self.value = set(value) self.value = set(deepcopy(value))
super(OptionSet, self).__init__() super(OptionSet, self).__init__()
@classmethod @classmethod
@@ -600,10 +820,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
@classmethod @classmethod
def from_any(cls, data: typing.Any): def from_any(cls, data: typing.Any):
if type(data) == list: if isinstance(data, (list, set, frozenset)):
cls.verify_keys(data)
return cls(data)
elif type(data) == set:
cls.verify_keys(data) cls.verify_keys(data)
return cls(data) return cls(data)
return cls.from_text(str(data)) return cls.from_text(str(data))
@@ -633,7 +850,7 @@ class Accessibility(Choice):
class ProgressionBalancing(SpecialRange): class ProgressionBalancing(SpecialRange):
"""A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. """A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
[0-99, default 50] A lower setting means more getting stuck. A higher setting means less getting stuck.""" A lower setting means more getting stuck. A higher setting means less getting stuck."""
default = 50 default = 50
range_start = 0 range_start = 0
range_end = 99 range_end = 99
@@ -732,8 +949,8 @@ class ItemLinks(OptionList):
pool |= {item_name} pool |= {item_name}
return pool return pool
def verify(self, world): def verify(self, world, player_name: str, plando_options) -> None:
super(ItemLinks, self).verify(world) super(ItemLinks, self).verify(world, player_name, plando_options)
existing_links = set() existing_links = set()
for link in self.value: for link in self.value:
if link["name"] in existing_links: if link["name"] in existing_links:

427
Patch.py
View File

@@ -1,265 +1,23 @@
from __future__ import annotations from __future__ import annotations
import shutil
import json
import bsdiff4
import yaml
import os import os
import lzma
import threading
import concurrent.futures
import zipfile
import sys import sys
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO from typing import Tuple, Optional, TypedDict
import ModuleUpdate if __name__ == "__main__":
ModuleUpdate.update() import ModuleUpdate
ModuleUpdate.update()
import Utils from worlds.Files import AutoPatchRegister, APDeltaPatch
current_patch_version = 4
class AutoPatchRegister(type): class RomMeta(TypedDict):
patch_types: Dict[str, APDeltaPatch] = {} server: str
file_endings: Dict[str, APDeltaPatch] = {}
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
# construct class
new_class = super().__new__(cls, name, bases, dct)
if "game" in dct:
AutoPatchRegister.patch_types[dct["game"]] = new_class
if not dct["patch_file_ending"]:
raise Exception(f"Need an expected file ending for {name}")
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
return new_class
@staticmethod
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
for file_ending, handler in AutoPatchRegister.file_endings.items():
if file.endswith(file_ending):
return handler
class APContainer:
"""A zipfile containing at least archipelago.json"""
version: int = current_patch_version
compression_level: int = 9
compression_method: int = zipfile.ZIP_DEFLATED
game: Optional[str] = None
# instance attributes:
path: Optional[str]
player: Optional[int] player: Optional[int]
player_name: str player_name: str
server: str
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
player_name: str = "", server: str = ""):
self.path = path
self.player = player
self.player_name = player_name
self.server = server
def write(self, file: Optional[Union[str, BinaryIO]] = None):
if not self.path and not file:
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
as zf:
if file:
self.path = zf.filename
self.write_contents(zf)
def write_contents(self, opened_zipfile: zipfile.ZipFile):
manifest = self.get_manifest()
try:
manifest = json.dumps(manifest)
except Exception as e:
raise Exception(f"Manifest {manifest} did not convert to json.") from e
else:
opened_zipfile.writestr("archipelago.json", manifest)
def read(self, file: Optional[Union[str, BinaryIO]] = None):
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
if not self.path and not file:
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
with zipfile.ZipFile(file if file else self.path, "r") as zf:
if file:
self.path = zf.filename
self.read_contents(zf)
def read_contents(self, opened_zipfile: zipfile.ZipFile):
with opened_zipfile.open("archipelago.json", "r") as f:
manifest = json.load(f)
if manifest["compatible_version"] > self.version:
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
f"for this handler (version: {self.version})")
self.player = manifest["player"]
self.server = manifest["server"]
self.player_name = manifest["player_name"]
def get_manifest(self) -> dict:
return {
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
"player": self.player,
"player_name": self.player_name,
"game": self.game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 4,
"version": current_patch_version,
}
class APDeltaPatch(APContainer, metaclass=AutoPatchRegister): def create_rom_file(patch_file: str) -> Tuple[RomMeta, str]:
"""An APContainer that additionally has delta.bsdiff4
containing a delta patch to get the desired file, often a rom."""
hash = Optional[str] # base checksum of source file
patch_file_ending: str = ""
delta: Optional[bytes] = None
result_file_ending: str = ".sfc"
source_data: bytes
def __init__(self, *args, patched_path: str = "", **kwargs):
self.patched_path = patched_path
super(APDeltaPatch, self).__init__(*args, **kwargs)
def get_manifest(self) -> dict:
manifest = super(APDeltaPatch, self).get_manifest()
manifest["base_checksum"] = self.hash
manifest["result_file_ending"] = self.result_file_ending
return manifest
@classmethod
def get_source_data(cls) -> bytes:
"""Get Base data"""
raise NotImplementedError()
@classmethod
def get_source_data_with_cache(cls) -> bytes:
if not hasattr(cls, "source_data"):
cls.source_data = cls.get_source_data()
return cls.source_data
def write_contents(self, opened_zipfile: zipfile.ZipFile):
super(APDeltaPatch, self).write_contents(opened_zipfile)
# write Delta
opened_zipfile.writestr("delta.bsdiff4",
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
def read_contents(self, opened_zipfile: zipfile.ZipFile):
super(APDeltaPatch, self).read_contents(opened_zipfile)
self.delta = opened_zipfile.read("delta.bsdiff4")
def patch(self, target: str):
"""Base + Delta -> Patched"""
if not self.delta:
self.read()
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
with open(target, "wb") as f:
f.write(result)
# legacy patch handling follows:
GAME_ALTTP = "A Link to the Past"
GAME_SM = "Super Metroid"
GAME_SOE = "Secret of Evermore"
GAME_SMZ3 = "SMZ3"
GAME_DKC3 = "Donkey Kong Country 3"
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3", "Donkey Kong Country 3"}
preferred_endings = {
GAME_ALTTP: "apbp",
GAME_SM: "apm3",
GAME_SOE: "apsoe",
GAME_SMZ3: "apsmz",
GAME_DKC3: "apdkc3"
}
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if game == GAME_ALTTP:
from worlds.alttp.Rom import LTTPJPN10HASH as HASH
elif game == GAME_SM:
from worlds.sm.Rom import SMJUHASH as HASH
elif game == GAME_SOE:
from worlds.soe.Patch import USHASH as HASH
elif game == GAME_SMZ3:
from worlds.alttp.Rom import LTTPJPN10HASH as ALTTPHASH
from worlds.sm.Rom import SMJUHASH as SMHASH
HASH = ALTTPHASH + SMHASH
elif game == GAME_DKC3:
from worlds.dkc3.Rom import USHASH as HASH
else:
raise RuntimeError(f"Selected game {game} for base rom not found.")
patch = yaml.dump({"meta": metadata,
"patch": patch,
"game": game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 3,
"version": current_patch_version,
"base_checksum": HASH})
return patch.encode(encoding="utf-8-sig")
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if metadata is None:
metadata = {}
patch = bsdiff4.diff(get_base_rom_data(game), rom)
return generate_yaml(patch, metadata, game)
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
player: int = 0, player_name: str = "", game: str = GAME_ALTTP) -> str:
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
"player_id": player,
"player_name": player_name}
bytes = generate_patch(load_bytes(rom_file_to_patch),
meta,
game)
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
".apbp" if game == GAME_ALTTP
else ".apsmz" if game == GAME_SMZ3
else ".apdkc3" if game == GAME_DKC3
else ".apm3")
write_lzma(bytes, target)
return target
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
game_name = data["game"]
if not ignore_version and data["compatible_version"] > current_patch_version:
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
target = os.path.splitext(patch_file)[0] + ".sfc"
return data["meta"], target, patched_data
def get_base_rom_data(game: str):
if game == GAME_ALTTP:
from worlds.alttp.Rom import get_base_rom_bytes
elif game == "alttp": # old version for A Link to the Past
from worlds.alttp.Rom import get_base_rom_bytes
elif game == GAME_SM:
from worlds.sm.Rom import get_base_rom_bytes
elif game == GAME_SOE:
from worlds.soe.Patch import get_base_rom_path
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
elif game == GAME_SMZ3:
from worlds.smz3.Rom import get_base_rom_bytes
elif game == GAME_DKC3:
from worlds.dkc3.Rom import get_base_rom_bytes
else:
raise RuntimeError("Selected game for base rom not found.")
return get_base_rom_bytes()
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
auto_handler = AutoPatchRegister.get_handler(patch_file) auto_handler = AutoPatchRegister.get_handler(patch_file)
if auto_handler: if auto_handler:
handler: APDeltaPatch = auto_handler(patch_file) handler: APDeltaPatch = auto_handler(patch_file)
@@ -268,171 +26,10 @@ def create_rom_file(patch_file: str) -> Tuple[dict, str]:
return {"server": handler.server, return {"server": handler.server,
"player": handler.player, "player": handler.player,
"player_name": handler.player_name}, target "player_name": handler.player_name}, target
else: raise NotImplementedError(f"No Handler for {patch_file} found.")
data, target, patched_data = create_rom_bytes(patch_file)
with open(target, "wb") as f:
f.write(patched_data)
return data, target
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
data = Utils.parse_yaml(lzma.decompress(patch_data).decode("utf-8-sig"))
data["meta"]["server"] = server
bytes = generate_yaml(data["patch"], data["meta"], data["game"])
return lzma.compress(bytes)
def load_bytes(path: str) -> bytes:
with open(path, "rb") as f:
return f.read()
def write_lzma(data: bytes, path: str):
with lzma.LZMAFile(path, 'wb') as f:
f.write(data)
def read_rom(stream, strip_header=True) -> bytearray:
"""Reads rom into bytearray and optionally strips off any smc header"""
buffer = bytearray(stream.read())
if strip_header and len(buffer) % 0x400 == 0x200:
return buffer[0x200:]
return buffer
if __name__ == "__main__": if __name__ == "__main__":
host = Utils.get_public_ipv4() for file in sys.argv[1:]:
options = Utils.get_options()['server_options'] meta_data, result_file = create_rom_file(file)
if options['host']: print(f"Patch with meta-data {meta_data} was written to {result_file}")
host = options['host']
address = f"{host}:{options['port']}"
ziplock = threading.Lock()
print(f"Host for patches to be created is {address}")
with concurrent.futures.ThreadPoolExecutor() as pool:
for rom in sys.argv:
try:
if rom.endswith(".sfc"):
print(f"Creating patch for {rom}")
result = pool.submit(create_patch_file, rom, address)
result.add_done_callback(lambda task: print(f"Created patch {task.result()}"))
elif rom.endswith(".apbp"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
#romfile, adjusted = Utils.get_adjuster_settings(target)
adjuster_settings = Utils.get_adjuster_settings(GAME_ALTTP)
adjusted = False
if adjuster_settings:
import pprint
from worlds.alttp.Rom import get_base_rom_path
adjuster_settings.rom = target
adjuster_settings.baserom = get_base_rom_path()
adjuster_settings.world = None
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
"reduceflashing", "deathlink"}
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
if hasattr(adjuster_settings, "sprite_pool"):
sprite_pool = {}
for sprite in getattr(adjuster_settings, "sprite_pool"):
if sprite in sprite_pool:
sprite_pool[sprite] += 1
else:
sprite_pool[sprite] = 1
if sprite_pool:
printed_options["sprite_pool"] = sprite_pool
adjust_wanted = str('no')
if not hasattr(adjuster_settings, 'auto_apply') or 'ask' in adjuster_settings.auto_apply:
adjust_wanted = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
f"{pprint.pformat(printed_options)}\n"
f"Enter yes, no, always or never: ")
if adjuster_settings.auto_apply == 'never': # never adjust, per user request
adjust_wanted = 'no'
elif adjuster_settings.auto_apply == 'always':
adjust_wanted = 'yes'
if adjust_wanted and "never" in adjust_wanted:
adjuster_settings.auto_apply = 'never'
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
elif adjust_wanted and "always" in adjust_wanted:
adjuster_settings.auto_apply = 'always'
Utils.persistent_store("adjuster", GAME_ALTTP, adjuster_settings)
if adjust_wanted and adjust_wanted.startswith("y"):
if hasattr(adjuster_settings, "sprite_pool"):
from LttPAdjuster import AdjusterWorld
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
adjusted = True
import LttPAdjuster
_, romfile = LttPAdjuster.adjust(adjuster_settings)
if hasattr(adjuster_settings, "world"):
delattr(adjuster_settings, "world")
else:
adjusted = False
if adjusted:
try:
shutil.move(romfile, target)
romfile = target
except Exception as e:
print(e)
print(f"Created rom {romfile if adjusted else target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".apm3"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
print(f"Created rom {target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".apsmz"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
print(f"Created rom {target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".apdkc3"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
print(f"Created rom {target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".zip"):
print(f"Updating host in patch files contained in {rom}")
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
data = zfr.read(zfinfo)
if zfinfo.filename.endswith(".apbp") or \
zfinfo.filename.endswith(".apm3") or \
zfinfo.filename.endswith(".apdkc3"):
data = update_patch_data(data, server)
with ziplock:
zfw.writestr(zfinfo, data)
return zfinfo.filename
futures = []
with zipfile.ZipFile(rom, "r") as zfr:
updated_zip = os.path.splitext(rom)[0] + "_updated.zip"
with zipfile.ZipFile(updated_zip, "w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zfw:
for zfname in zfr.namelist():
futures.append(pool.submit(_handle_zip_file_entry, zfr.getinfo(zfname), address))
for future in futures:
print(f"File {future.result()} added to {os.path.split(updated_zip)[1]}")
except:
import traceback
traceback.print_exc()
input("Press enter to close.")

304
PokemonClient.py Normal file
View File

@@ -0,0 +1,304 @@
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
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}}
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}
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
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'
items_handling = 0b101
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
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 Bizhawk 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
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 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()
return json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10}
}
)
async def parse_locations(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:]}
# Check for clear problems
if len(flags['Rod']) > 1:
return
if flags["EventFlag"][1] + flags["EventFlag"][8] + flags["EventFlag"][9] + flags["EventFlag"][12] \
+ flags["EventFlag"][61] + flags["EventFlag"][62] + flags["EventFlag"][63] + flags["EventFlag"][64] \
+ flags["EventFlag"][65] + flags["EventFlag"][66] + flags["EventFlag"][67] + flags["EventFlag"][68] \
+ flags["EventFlag"][69] + flags["EventFlag"][70] != 0:
return
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)
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())
#print(data_decoded)
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 == '':
logger.info("Invalid ROM detected. No player name built into the ROM.")
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))
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'
with open(Utils.local_path(Utils.get_options()["pokemon_rb_options"][f"{game_version}_rom_file"]), "rb") as stream:
base_rom = bytes(stream.read())
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

@@ -28,6 +28,11 @@ Currently, the following games are supported:
* Starcraft 2: Wings of Liberty * Starcraft 2: Wings of Liberty
* Donkey Kong Country 3 * Donkey Kong Country 3
* Dark Souls 3 * Dark Souls 3
* Super Mario World
* Pokémon Red and Blue
* Hylics 2
* Overcooked! 2
* Zillion
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). 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 Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
@@ -61,26 +66,10 @@ This project makes use of multiple other projects. We wouldn't be here without t
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer) * [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
## Contributing ## Contributing
Contributions are welcome. We have a few asks of any new contributors. For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md)
* Ensure that all changes which affect logic are covered by unit tests.
* Do not introduce any unit test failures/regressions.
Otherwise, we tend to judge code on a case to case basis. It is a generally good idea to stick to PEP-8 guidelines to ensure consistency with existing code. (And to make the linter happy.)
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.
## FAQ ## FAQ
For frequently asked questions see the website's [FAQ Page](https://archipelago.gg/faq/en/) For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/)
## Code of Conduct ## Code of Conduct
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to: Please refer to our [code of conduct.](/docs/code_of_conduct.md)
* Be welcoming and inclusive in tone and language.
* Be respectful of others and their abilities.
* Show empathy when speaking with others.
* Be gracious and accept feedback and constructive criticism.
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private, such as private messaging or emails.
Any incidents of abuse may be reported directly to Ijwu at hmfarran@gmail.com.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

251
Utils.py
View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
import shutil import asyncio
import typing import typing
import builtins import builtins
import os import os
@@ -12,12 +12,20 @@ import io
import collections import collections
import importlib import importlib
import logging import logging
import decimal from typing import BinaryIO, ClassVar, Coroutine, Optional, Set
from yaml import load, load_all, dump, SafeLoader
try:
from yaml import CLoader as UnsafeLoader
from yaml import CDumper as Dumper
except ImportError:
from yaml import Loader as UnsafeLoader
from yaml import Dumper
if typing.TYPE_CHECKING: if typing.TYPE_CHECKING:
from tkinter import Tk import tkinter
else: import pathlib
Tk = typing.Any
def tuplize_version(version: str) -> Version: def tuplize_version(version: str) -> Version:
@@ -30,21 +38,13 @@ class Version(typing.NamedTuple):
build: int build: int
__version__ = "0.3.4" __version__ = "0.3.6"
version_tuple = tuplize_version(__version__) version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith('linux') is_linux = sys.platform.startswith("linux")
is_macos = sys.platform == 'darwin' is_macos = sys.platform == "darwin"
is_windows = sys.platform in ("win32", "cygwin", "msys") is_windows = sys.platform in ("win32", "cygwin", "msys")
import jellyfish
from yaml import load, load_all, dump, SafeLoader
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader
def int16_as_bytes(value: int) -> typing.List[int]: def int16_as_bytes(value: int) -> typing.List[int]:
value = value & 0xFFFF value = value & 0xFFFF
@@ -125,23 +125,24 @@ def home_path(*path: str) -> str:
def user_path(*path: str) -> str: def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions.""" """Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, 'cached_path'): if hasattr(user_path, "cached_path"):
pass pass
elif os.access(local_path(), os.W_OK): elif os.access(local_path(), os.W_OK):
user_path.cached_path = local_path() user_path.cached_path = local_path()
else: else:
user_path.cached_path = home_path() user_path.cached_path = home_path()
# populate home from local - TODO: upgrade feature # populate home from local - TODO: upgrade feature
if user_path.cached_path != local_path() and not os.path.exists(user_path('host.yaml')): if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")):
for dn in ('Players', 'data/sprites'): import shutil
for dn in ("Players", "data/sprites"):
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
for fn in ('manifest.json', 'host.yaml'): for fn in ("manifest.json", "host.yaml"):
shutil.copy2(local_path(fn), user_path(fn)) shutil.copy2(local_path(fn), user_path(fn))
return os.path.join(user_path.cached_path, *path) return os.path.join(user_path.cached_path, *path)
def output_path(*path: str): def output_path(*path: str) -> str:
if hasattr(output_path, 'cached_path'): if hasattr(output_path, 'cached_path'):
return os.path.join(output_path.cached_path, *path) return os.path.join(output_path.cached_path, *path)
output_path.cached_path = user_path(get_options()["general_options"]["output_path"]) output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
@@ -150,11 +151,12 @@ def output_path(*path: str):
return path return path
def open_file(filename): def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
if sys.platform == 'win32': if is_windows:
os.startfile(filename) os.startfile(filename)
else: else:
open_command = 'open' if sys.platform == 'darwin' else 'xdg-open' from shutil import which
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
subprocess.call([open_command, filename]) subprocess.call([open_command, filename])
@@ -173,7 +175,9 @@ class UniqueKeyLoader(SafeLoader):
parse_yaml = functools.partial(load, Loader=UniqueKeyLoader) parse_yaml = functools.partial(load, Loader=UniqueKeyLoader)
parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader) parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader)
unsafe_parse_yaml = functools.partial(load, Loader=Loader) unsafe_parse_yaml = functools.partial(load, Loader=UnsafeLoader)
del load, load_all # should not be used. don't leak their names
def get_cert_none_ssl_context(): def get_cert_none_ssl_context():
@@ -191,11 +195,12 @@ def get_public_ipv4() -> str:
ip = socket.gethostbyname(socket.gethostname()) ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context() ctx = get_cert_none_ssl_context()
try: try:
ip = urllib.request.urlopen('https://checkip.amazonaws.com/', context=ctx).read().decode('utf8').strip() ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx).read().decode("utf8").strip()
except Exception as e: except Exception as e:
# noinspection PyBroadException
try: try:
ip = urllib.request.urlopen('https://v4.ident.me', context=ctx).read().decode('utf8').strip() ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip()
except: except Exception:
logging.exception(e) logging.exception(e)
pass # we could be offline, in a local game, so no point in erroring out pass # we could be offline, in a local game, so no point in erroring out
return ip return ip
@@ -208,15 +213,18 @@ def get_public_ipv6() -> str:
ip = socket.gethostbyname(socket.gethostname()) ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context() ctx = get_cert_none_ssl_context()
try: try:
ip = urllib.request.urlopen('https://v6.ident.me', context=ctx).read().decode('utf8').strip() ip = urllib.request.urlopen("https://v6.ident.me", context=ctx).read().decode("utf8").strip()
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
pass # we could be offline, in a local game, or ipv6 may not be available pass # we could be offline, in a local game, or ipv6 may not be available
return ip return ip
OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]]
@cache_argsless @cache_argsless
def get_default_options() -> dict: def get_default_options() -> OptionsType:
# Refer to host.yaml for comments as to what all these options mean. # Refer to host.yaml for comments as to what all these options mean.
options = { options = {
"general_options": { "general_options": {
@@ -224,20 +232,21 @@ def get_default_options() -> dict:
}, },
"factorio_options": { "factorio_options": {
"executable": os.path.join("factorio", "bin", "x64", "factorio"), "executable": os.path.join("factorio", "bin", "x64", "factorio"),
"filter_item_sends": False,
"bridge_chat_out": True,
},
"sni_options": {
"sni": "SNI",
"snes_rom_start": True,
}, },
"sm_options": { "sm_options": {
"rom_file": "Super Metroid (JU).sfc", "rom_file": "Super Metroid (JU).sfc",
"sni": "SNI",
"rom_start": True,
}, },
"soe_options": { "soe_options": {
"rom_file": "Secret of Evermore (USA).sfc", "rom_file": "Secret of Evermore (USA).sfc",
}, },
"lttp_options": { "lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"sni": "SNI",
"rom_start": True,
}, },
"server_options": { "server_options": {
"host": None, "host": None,
@@ -280,15 +289,27 @@ def get_default_options() -> dict:
}, },
"dkc3_options": { "dkc3_options": {
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc", "rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
"sni": "SNI",
"rom_start": True,
}, },
"smw_options": {
"rom_file": "Super Mario World (USA).sfc",
},
"zillion_options": {
"rom_file": "Zillion (UE) [!].sms",
# RetroArch doesn't make it easy to launch a game from the command line.
# You have to know the path to the emulator core library on the user's computer.
"rom_start": "retroarch",
},
"pokemon_rb_options": {
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",
"rom_start": True
}
} }
return options return options
def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict: def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType:
for key, value in src.items(): for key, value in src.items():
new_keys = keys.copy() new_keys = keys.copy()
new_keys.append(key) new_keys.append(key)
@@ -308,34 +329,20 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
@cache_argsless @cache_argsless
def get_options() -> dict: def get_options() -> OptionsType:
if not hasattr(get_options, "options"): filenames = ("options.yaml", "host.yaml")
filenames = ("options.yaml", "host.yaml") locations: typing.List[str] = []
locations = [] if os.path.join(os.getcwd()) != local_path():
if os.path.join(os.getcwd()) != local_path(): locations += filenames # use files from cwd only if it's not the local_path
locations += filenames # use files from cwd only if it's not the local_path locations += [user_path(filename) for filename in filenames]
locations += [user_path(filename) for filename in filenames]
for location in locations: for location in locations:
if os.path.exists(location): if os.path.exists(location):
with open(location) as f: with open(location) as f:
options = parse_yaml(f.read()) options = parse_yaml(f.read())
return update_options(get_default_options(), options, location, list())
get_options.options = update_options(get_default_options(), options, location, list()) raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
break
else:
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
return get_options.options
def get_item_name_from_id(code: int) -> str:
from worlds import lookup_any_item_id_to_name
return lookup_any_item_id_to_name.get(code, f'Unknown item (ID:{code})')
def get_location_name_from_id(code: int) -> str:
from worlds import lookup_any_location_id_to_name
return lookup_any_location_id_to_name.get(code, f'Unknown location (ID:{code})')
def persistent_store(category: str, key: typing.Any, value: typing.Any): def persistent_store(category: str, key: typing.Any, value: typing.Any):
@@ -344,10 +351,10 @@ def persistent_store(category: str, key: typing.Any, value: typing.Any):
category = storage.setdefault(category, {}) category = storage.setdefault(category, {})
category[key] = value category[key] = value
with open(path, "wt") as f: with open(path, "wt") as f:
f.write(dump(storage)) f.write(dump(storage, Dumper=Dumper))
def persistent_load() -> typing.Dict[dict]: def persistent_load() -> typing.Dict[str, dict]:
storage = getattr(persistent_load, "storage", None) storage = getattr(persistent_load, "storage", None)
if storage: if storage:
return storage return storage
@@ -365,8 +372,8 @@ def persistent_load() -> typing.Dict[dict]:
return storage return storage
def get_adjuster_settings(gameName: str): def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]:
adjuster_settings = persistent_load().get("adjuster", {}).get(gameName, {}) adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
return adjuster_settings return adjuster_settings
@@ -382,10 +389,10 @@ def get_unique_identifier():
return uuid return uuid
safe_builtins = { safe_builtins = frozenset((
'set', 'set',
'frozenset', 'frozenset',
} ))
class RestrictedUnpickler(pickle.Unpickler): class RestrictedUnpickler(pickle.Unpickler):
@@ -404,7 +411,8 @@ class RestrictedUnpickler(pickle.Unpickler):
# Options and Plando are unpickled by WebHost -> Generate # Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}: if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
return getattr(self.generic_properties_module, name) return getattr(self.generic_properties_module, name)
if module.endswith("Options"): # pep 8 specifies that modules should have "all-lowercase names" (options, not Options)
if module.lower().endswith("options"):
if module == "Options": if module == "Options":
mod = self.options_module mod = self.options_module
else: else:
@@ -413,8 +421,7 @@ class RestrictedUnpickler(pickle.Unpickler):
if issubclass(obj, self.options_module.Option): if issubclass(obj, self.options_module.Option):
return obj return obj
# Forbid everything else. # Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" % raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
(module, name))
def restricted_loads(s): def restricted_loads(s):
@@ -423,6 +430,9 @@ def restricted_loads(s):
class KeyedDefaultDict(collections.defaultdict): class KeyedDefaultDict(collections.defaultdict):
"""defaultdict variant that uses the missing key as argument to default_factory"""
default_factory: typing.Callable[[typing.Any], typing.Any]
def __missing__(self, key): def __missing__(self, key):
self[key] = value = self.default_factory(key) self[key] = value = self.default_factory(key)
return value return value
@@ -432,6 +442,10 @@ def get_text_between(text: str, start: str, end: str) -> str:
return text[text.index(start) + len(start): text.rindex(end)] return text[text.index(start) + len(start): text.rindex(end)]
def get_text_after(text: str, start: str) -> str:
return text[text.index(start) + len(start):]
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG} loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
@@ -493,11 +507,11 @@ def stream_input(stream, queue):
return thread return thread
def tkinter_center_window(window: Tk): def tkinter_center_window(window: "tkinter.Tk") -> None:
window.update() window.update()
xPos = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2) x = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2)
yPos = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2) y = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2)
window.geometry("+{}+{}".format(xPos, yPos)) window.geometry(f"+{x}+{y}")
class VersionException(Exception): class VersionException(Exception):
@@ -514,24 +528,27 @@ def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
# noinspection PyPep8Naming # noinspection PyPep8Naming
def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")) -> str: def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P", "E", "Z", "Y")) -> str:
"""Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix""" """Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix"""
import decimal
n = 0 n = 0
value = decimal.Decimal(value) value = decimal.Decimal(value)
while value >= power: limit = power - decimal.Decimal("0.005")
while value >= limit:
value /= power value /= power
n += 1 n += 1
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}" return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
def get_fuzzy_ratio(word1: str, word2: str) -> float:
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
-> typing.List[typing.Tuple[str, int]]: -> typing.List[typing.Tuple[str, int]]:
import jellyfish
def get_fuzzy_ratio(word1: str, word2: str) -> float:
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
limit: int = limit if limit else len(wordlist) limit: int = limit if limit else len(wordlist)
return list( return list(
map( map(
@@ -549,18 +566,19 @@ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: ty
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \
-> typing.Optional[str]: -> typing.Optional[str]:
def run(*args: str): def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux: if is_linux:
# prefer native dialog # prefer native dialog
kdialog = shutil.which('kdialog') from shutil import which
kdialog = which("kdialog")
if kdialog: if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return run(kdialog, f'--title={title}', '--getopenfilename', '.', k_filters) return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters)
zenity = shutil.which('zenity') zenity = which("zenity")
if zenity: if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
return run(zenity, f'--title={title}', '--file-selection', *z_filters) return run(zenity, f"--title={title}", "--file-selection", *z_filters)
# fall back to tk # fall back to tk
try: try:
@@ -578,10 +596,10 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
def messagebox(title: str, text: str, error: bool = False) -> None: def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str): def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
def is_kivy_running(): def is_kivy_running():
if 'kivy' in sys.modules: if "kivy" in sys.modules:
from kivy.app import App from kivy.app import App
return App.get_running_app() is not None return App.get_running_app() is not None
return False return False
@@ -591,14 +609,15 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
MessageBox(title, text, error).open() MessageBox(title, text, error).open()
return return
if is_linux and not 'tkinter' in sys.modules: if is_linux and "tkinter" not in sys.modules:
# prefer native dialog # prefer native dialog
kdialog = shutil.which('kdialog') from shutil import which
kdialog = which("kdialog")
if kdialog: if kdialog:
return run(kdialog, f'--title={title}', '--error' if error else '--msgbox', text) return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
zenity = shutil.which('zenity') zenity = which("zenity")
if zenity: if zenity:
return run(zenity, f'--title={title}', f'--text={text}', '--error' if error else '--info') return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
# fall back to tk # fall back to tk
try: try:
@@ -613,3 +632,43 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
root.withdraw() root.withdraw()
showerror(title, text) if error else showinfo(title, text) showerror(title, text) if error else showinfo(title, text)
root.update() root.update()
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: str) -> str:
parts = element.split(maxsplit=1)
if parts[0].lower() in ignore:
return parts[1].lower()
else:
return element.lower()
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))
def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
"""Reads rom into bytearray and optionally strips off any smc header"""
buffer = bytearray(stream.read())
if strip_header and len(buffer) % 0x400 == 0x200:
return buffer[0x200:]
return buffer
_faf_tasks: "Set[asyncio.Task[None]]" = set()
def async_start(co: Coroutine[None, None, None], name: Optional[str] = None) -> None:
"""
Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget"
"""
# https://docs.python.org/3.10/library/asyncio-task.html#asyncio.create_task
# Python docs:
# ```
# Important: Save a reference to the result of [asyncio.create_task],
# to avoid a task disappearing mid-execution.
# ```
# This implementation follows the pattern given in that documentation.
task = asyncio.create_task(co, name=name)
_faf_tasks.add(task)
task.add_done_callback(_faf_tasks.discard)

View File

@@ -1,5 +1,4 @@
import os import os
import sys
import multiprocessing import multiprocessing
import logging import logging
import typing import typing
@@ -12,9 +11,9 @@ ModuleUpdate.update()
# in case app gets imported by something like gunicorn # in case app gets imported by something like gunicorn
import Utils import Utils
Utils.local_path.cached_path = os.path.dirname(__file__) Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
from WebHostLib import app as raw_app from WebHostLib import register, app as raw_app
from waitress import serve from waitress import serve
from WebHostLib.models import db from WebHostLib.models import db
@@ -22,14 +21,13 @@ from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.lttpsprites import update_sprites_lttp from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files from WebHostLib.options import create as create_options_files
from worlds.AutoWorld import AutoWorldRegister
configpath = os.path.abspath("config.yaml") configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml')) configpath = os.path.abspath(Utils.user_path('config.yaml'))
def get_app(): def get_app():
register()
app = raw_app app = raw_app
if os.path.exists(configpath): if os.path.exists(configpath):
import yaml import yaml
@@ -43,19 +41,39 @@ def get_app():
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]: def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
import json import json
import shutil import shutil
import zipfile
zfile: zipfile.ZipInfo
from worlds.AutoWorld import AutoWorldRegister
worlds = {} worlds = {}
data = [] data = []
for game, world in AutoWorldRegister.world_types.items(): for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'): if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
worlds[game] = world worlds[game] = world
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
for game, world in worlds.items(): for game, world in worlds.items():
# copy files from world's docs folder to the generated folder # copy files from world's docs folder to the generated folder
source_path = Utils.local_path(os.path.dirname(sys.modules[world.__module__].__file__), 'docs') target_path = os.path.join(base_target_path, game)
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game) os.makedirs(target_path, exist_ok=True)
files = os.listdir(source_path)
for file in files: if world.zip_path:
os.makedirs(os.path.dirname(Utils.local_path(target_path, file)), exist_ok=True) zipfile_path = world.zip_path
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
assert os.path.isfile(zipfile_path), f"{zipfile_path} is not a valid file(path)."
assert zipfile.is_zipfile(zipfile_path), f"{zipfile_path} is not a valid zipfile."
with zipfile.ZipFile(zipfile_path) as zf:
for zfile in zf.infolist():
if not zfile.is_dir() and "/docs/" in zfile.filename:
zf.extract(zfile, target_path)
else:
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
files = os.listdir(source_path)
for file in files:
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
# build a json tutorial dict per game # build a json tutorial dict per game
game_data = {'gameTitle': game, 'tutorials': []} game_data = {'gameTitle': game, 'tutorials': []}
for tutorial in world.web.tutorials: for tutorial in world.web.tutorials:
@@ -85,7 +103,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
for games in data: for games in data:
if 'Archipelago' in games['gameTitle']: if 'Archipelago' in games['gameTitle']:
generic_data = data.pop(data.index(games)) generic_data = data.pop(data.index(games))
sorted_data = [generic_data] + sorted(data, key=lambda entry: entry["gameTitle"].lower()) sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False) json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
return sorted_data return sorted_data

46
WebHostLib/README.md Normal file
View File

@@ -0,0 +1,46 @@
# WebHost
## Contribution Guidelines
**Thank you for your interest in contributing to the Archipelago website!**
Much of the content on the website is generated automatically, but there are some things
that need a personal touch. For those things, we rely on contributions from both the core
team and the community. The current primary maintainer of the website is Farrak Kilhn.
He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`.
### Small Changes
Little changes like adding a button or a couple new select elements are perfectly fine.
Tweaks to style specific to a PR's content are also probably not a problem. For example, if
you build a new page which needs two side by side tables, and you need to write a CSS file
specific to your page, that is perfectly reasonable.
### Content Additions
Once you develop a new feature or add new content the website, make a pull request. It will
be reviewed by the community and there will probably be some discussion around it. Depending
on the size of the feature, and if new styles are required, there may be an additional step
before the PR is accepted wherein Farrak works with the designer to implement styles.
### Restrictions on Style Changes
A professional designer is paid to develop the styles and assets for the Archipelago website.
In an effort to maintain a consistent look and feel, pull requests which *exclusively*
change site styles are rejected. Please note this applies to code which changes the overall
look and feel of the site, not to small tweaks to CSS for your custom page. The intention
behind these restrictions is to maintain a curated feel for the design of the site. If
any PR affects the overall feel of the site but includes additive changes, there will
likely be a conversation about how to implement those changes without compromising the
curated site style. It is therefore worth noting there are a couple files which, if
changed in your pull request, will cause it to draw additional scrutiny.
These closely guarded files are:
- `globalStyles.css`
- `islandFooter.css`
- `landing.css`
- `markdown.css`
- `tooltip.css`
### Site Themes
There are several themes available for game pages. It is possible to request a new theme in
the `#art-and-design` channel on Discord. Because themes are created by the designer, they
are not free, and take some time to create. Farrak works closely with the designer to implement
these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year
are added. If a proposed theme seems like a cool idea and the community likes it, there is a
good chance it will become a reality.

View File

@@ -1,16 +1,15 @@
import os
import uuid
import base64 import base64
import os
import socket import socket
import uuid
import jinja2.exceptions from flask import Flask
from pony.flask import Pony
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from flask_caching import Cache from flask_caching import Cache
from flask_compress import Compress from flask_compress import Compress
from worlds.AutoWorld import AutoWorldRegister from pony.flask import Pony
from werkzeug.routing import BaseConverter
from .models import * from Utils import title_sorted
UPLOAD_FOLDER = os.path.relpath('uploads') UPLOAD_FOLDER = os.path.relpath('uploads')
LOGS_FOLDER = os.path.relpath('logs') LOGS_FOLDER = os.path.relpath('logs')
@@ -32,8 +31,10 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key # if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8") app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
# at what amount of worlds should scheduling be used, instead of rolling in the webthread # at what amount of worlds should scheduling be used, instead of rolling in the web-thread
app.config["JOB_THRESHOLD"] = 2 app.config["JOB_THRESHOLD"] = 2
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
app.config['SESSION_PERMANENT'] = True app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent # waitress uses one thread for I/O, these are for processing of views that then get sent
@@ -53,8 +54,6 @@ app.config["PATCH_TARGET"] = "archipelago.gg"
cache = Cache(app) cache = Cache(app)
Compress(app) Compress(app)
from werkzeug.routing import BaseConverter
class B64UUIDConverter(BaseConverter): class B64UUIDConverter(BaseConverter):
@@ -68,174 +67,20 @@ class B64UUIDConverter(BaseConverter):
# short UUID # short UUID
app.url_map.converters["suuid"] = B64UUIDConverter app.url_map.converters["suuid"] = B64UUIDConverter
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
app.jinja_env.filters["title_sorted"] = title_sorted
# has automatic patch integration
import Patch
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
def get_world_theme(game_name: str): def register():
if game_name in AutoWorldRegister.world_types: """Import submodules, triggering their registering on flask routing.
return AutoWorldRegister.world_types[game_name].web.theme Note: initializes worlds subsystem."""
return 'grass' # has automatic patch integration
import worlds.AutoWorld
import worlds.Files
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
game_name in worlds.Files.AutoPatchRegister.patch_types
from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
@app.before_request app.register_blueprint(api.api_endpoints)
def register_session():
session.permanent = True # technically 31 days after the last visit
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):
return render_template('404.html'), 404
# Start Playing Page
@app.route('/start-playing')
def start_playing():
return render_template(f"startPlaying.html")
@app.route('/weighted-settings')
def weighted_settings():
return render_template(f"weighted-settings.html")
# Player settings pages
@app.route('/games/<string:game>/player-settings')
def player_settings(game):
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>')
def game_info(game, lang):
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
# List of supported games
@app.route('/games')
def games():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("supportedGames.html", worlds=worlds)
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
def tutorial(game, file, lang):
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
@app.route('/tutorial/')
def tutorial_landing():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("tutorialLanding.html")
@app.route('/faq/<string:lang>/')
def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/')
def terms(lang):
return render_template("glossary.html", lang=lang)
@app.route('/seed/<suuid:seed>')
def view_seed(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
@app.route('/new_room/<suuid:seed>')
def new_room(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
commit()
return redirect(url_for("host_room", room=room.id))
def _read_log(path: str):
if os.path.exists(path):
with open(path, encoding="utf-8-sig") as log:
yield from log
else:
yield f"Logfile {path} does not exist. " \
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<suuid:room>')
def display_log(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
return "Access Denied", 403
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def host_room(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if request.method == "POST":
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
with db_session:
room.last_activity = datetime.utcnow() # will trigger a spinup, if it's not already running
return render_template("hostRoom.html", room=room)
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static/static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
@app.route('/discord')
def discord():
return redirect("https://discord.gg/archipelago")
@app.route('/datapackage')
@cache.cached()
def get_datapackge():
"""A pretty print version of /api/datapackage"""
from worlds import network_data_package
import json
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
@app.route('/index')
@app.route('/sitemap')
def get_sitemap():
available_games = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
available_games.append(game)
return render_template("siteMap.html", games=available_games)
from WebHostLib.customserver import run_server_process
from . import tracker, upload, landing, check, generate, downloads, api, stats # to trigger app routing picking up on it
app.register_blueprint(api.api_endpoints)

View File

@@ -1,11 +1,11 @@
"""API endpoints package.""" """API endpoints package."""
from uuid import UUID
from typing import List, Tuple from typing import List, Tuple
from uuid import UUID
from flask import Blueprint, abort from flask import Blueprint, abort
from ..models import Room, Seed
from .. import cache from .. import cache
from ..models import Room, Seed
api_endpoints = Blueprint('api', __name__, url_prefix="/api") api_endpoints = Blueprint('api', __name__, url_prefix="/api")
@@ -32,14 +32,14 @@ def room_info(room: UUID):
@api_endpoints.route('/datapackage') @api_endpoints.route('/datapackage')
@cache.cached() @cache.cached()
def get_datapackge(): def get_datapackage():
from worlds import network_data_package from worlds import network_data_package
return network_data_package return network_data_package
@api_endpoints.route('/datapackage_version') @api_endpoints.route('/datapackage_version')
@cache.cached() @cache.cached()
def get_datapackge_versions(): def get_datapackage_versions():
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()} version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
version_package["version"] = network_data_package["version"] version_package["version"] = network_data_package["version"]

View File

@@ -1,15 +1,15 @@
import json import json
import pickle import pickle
from uuid import UUID from uuid import UUID
from . import api_endpoints
from flask import request, session, url_for from flask import request, session, url_for
from pony.orm import commit from pony.orm import commit
from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR from WebHostLib import app
from WebHostLib.check import get_yaml_data, roll_options from WebHostLib.check import get_yaml_data, roll_options
from WebHostLib.generate import get_meta from WebHostLib.generate import get_meta
from WebHostLib.models import Generation, STATE_QUEUED, Seed, STATE_ERROR
from . import api_endpoints
@api_endpoints.route('/generate', methods=['POST']) @api_endpoints.route('/generate', methods=['POST'])

View File

@@ -1,6 +1,7 @@
from flask import session, jsonify from flask import session, jsonify
from pony.orm import select
from WebHostLib.models import * from WebHostLib.models import Room, Seed
from . import api_endpoints, get_players from . import api_endpoints, get_players

View File

@@ -1,14 +1,14 @@
from __future__ import annotations from __future__ import annotations
import logging
import json
import multiprocessing
import threading
from datetime import timedelta, datetime
import sys import json
import typing import logging
import time import multiprocessing
import os import os
import sys
import threading
import time
import typing
from datetime import timedelta, datetime
from pony.orm import db_session, select, commit from pony.orm import db_session, select, commit
@@ -184,7 +184,7 @@ class MultiworldInstance():
logging.info(f"Spinning up {self.room_id}") logging.info(f"Spinning up {self.room_id}")
process = multiprocessing.Process(group=None, target=run_server_process, process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.room_id, self.ponyconfig), args=(self.room_id, self.ponyconfig, get_static_server_data()),
name="MultiHost") name="MultiHost")
process.start() process.start()
# bind after start to prevent thread sync issues with guardian. # bind after start to prevent thread sync issues with guardian.
@@ -238,5 +238,5 @@ def run_guardian():
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
from .customserver import run_server_process from .customserver import run_server_process, get_static_server_data
from .generate import gen_game from .generate import gen_game

View File

@@ -1,7 +1,7 @@
import zipfile import zipfile
from typing import * from typing import *
from flask import request, flash, redirect, url_for, session, render_template from flask import request, flash, redirect, url_for, render_template
from WebHostLib import app from WebHostLib import app

View File

@@ -1,20 +1,23 @@
from __future__ import annotations from __future__ import annotations
import functools
import websockets
import asyncio import asyncio
import collections
import datetime
import functools
import logging
import pickle
import random
import socket import socket
import threading import threading
import time import time
import random
import pickle import websockets
import logging from pony.orm import db_session, commit, select
import Utils import Utils
from .models import *
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
from .models import Room, Command, db
class CustomClientMessageProcessor(ClientMessageProcessor): class CustomClientMessageProcessor(ClientMessageProcessor):
@@ -39,7 +42,7 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
import MultiServer import MultiServer
MultiServer.client_message_processor = CustomClientMessageProcessor MultiServer.client_message_processor = CustomClientMessageProcessor
del (MultiServer) del MultiServer
class DBCommandProcessor(ServerCommandProcessor): class DBCommandProcessor(ServerCommandProcessor):
@@ -48,12 +51,24 @@ class DBCommandProcessor(ServerCommandProcessor):
class WebHostContext(Context): class WebHostContext(Context):
def __init__(self): room_id: int
def __init__(self, static_server_data: dict):
# static server data is used during _load_game_data to load required data,
# without needing to import worlds system, which takes quite a bit of memory
self.static_server_data = static_server_data
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2) super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
del self.static_server_data
self.main_loop = asyncio.get_running_loop() self.main_loop = asyncio.get_running_loop()
self.video = {} self.video = {}
self.tags = ["AP", "WebHost"] self.tags = ["AP", "WebHost"]
def _load_game_data(self):
for key, value in self.static_server_data.items():
setattr(self, key, value)
self.forced_auto_forfeits = collections.defaultdict(lambda: False, self.forced_auto_forfeits)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
def listen_to_db_commands(self): def listen_to_db_commands(self):
cmdprocessor = DBCommandProcessor(self) cmdprocessor = DBCommandProcessor(self)
@@ -94,7 +109,7 @@ class WebHostContext(Context):
room.multisave = pickle.dumps(self.get_save()) room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity # saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
room.last_activity = datetime.utcnow() room.last_activity = datetime.datetime.utcnow()
return True return True
def get_save(self) -> dict: def get_save(self) -> dict:
@@ -107,14 +122,32 @@ def get_random_port():
return random.randint(49152, 65535) return random.randint(49152, 65535)
def run_server_process(room_id, ponyconfig: dict): @cache_argsless
def get_static_server_data() -> dict:
import worlds
data = {
"forced_auto_forfeits": {},
"non_hintable_names": {},
"gamespackage": worlds.network_data_package["games"],
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
data["forced_auto_forfeits"][world_name] = world.forced_auto_forfeit
data["non_hintable_names"][world_name] = world.hint_blacklist
return data
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
# establish DB connection for multidata and multisave # establish DB connection for multidata and multisave
db.bind(**ponyconfig) db.bind(**ponyconfig)
db.generate_mapping(check_tables=False) db.generate_mapping(check_tables=False)
async def main(): async def main():
Utils.init_logging(str(room_id), write_mode="a") Utils.init_logging(str(room_id), write_mode="a")
ctx = WebHostContext() ctx = WebHostContext(static_server_data)
ctx.load(room_id) ctx.load(room_id)
ctx.init_save() ctx.init_save()
@@ -151,4 +184,12 @@ def run_server_process(room_id, ponyconfig: dict):
from .autolauncher import Locker from .autolauncher import Locker
with Locker(room_id): with Locker(room_id):
asyncio.run(main()) try:
asyncio.run(main())
except:
with db_session:
room = Room.get(id=room_id)
room.last_port = -1
# ensure the Room does not spin up again on its own, minute of safety buffer
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
raise

View File

@@ -1,12 +1,13 @@
import zipfile
import json import json
import zipfile
from io import BytesIO from io import BytesIO
from flask import send_file, Response, render_template from flask import send_file, Response, render_template
from pony.orm import select from pony.orm import select
from Patch import update_patch_data, preferred_endings, AutoPatchRegister from worlds.Files import AutoPatchRegister
from WebHostLib import app, Slot, Room, Seed, cache from . import app, cache
from .models import Slot, Room, Seed
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>") @app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
@@ -32,18 +33,16 @@ def download_patch(room_id, patch_id):
new_zip.writestr("archipelago.json", json.dumps(manifest)) new_zip.writestr("archipelago.json", json.dumps(manifest))
else: else:
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9) new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
if "patch_file_ending" in manifest:
patch_file_ending = manifest["patch_file_ending"]
else:
patch_file_ending = AutoPatchRegister.patch_types[patch.game].patch_file_ending
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \ fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \
f"{AutoPatchRegister.patch_types[patch.game].patch_file_ending}" f"{patch_file_ending}"
new_file.seek(0) new_file.seek(0)
return send_file(new_file, as_attachment=True, attachment_filename=fname) return send_file(new_file, as_attachment=True, download_name=fname)
else: else:
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}") return "Old Patch file, no longer compatible."
patch_data = BytesIO(patch_data)
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
f"{preferred_endings[patch.game]}"
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
@app.route("/dl_spoiler/<suuid:seed_id>") @app.route("/dl_spoiler/<suuid:seed_id>")
@@ -66,7 +65,7 @@ def download_slot_file(room_id, player_id: int):
from worlds.minecraft import mc_update_output from worlds.minecraft import mc_update_output
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port) data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
return send_file(io.BytesIO(data), as_attachment=True, attachment_filename=fname) return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
elif slot_data.game == "Factorio": elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf: with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist(): for name in zf.namelist():
@@ -76,13 +75,15 @@ def download_slot_file(room_id, player_id: int):
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
elif slot_data.game == "VVVVVV": elif slot_data.game == "VVVVVV":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
elif slot_data.game == "Zillion":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apzl"
elif slot_data.game == "Super Mario 64": elif slot_data.game == "Super Mario 64":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
elif slot_data.game == "Dark Souls III": elif slot_data.game == "Dark Souls III":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json" fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
else: else:
return "Game download not supported." return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname) return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
@app.route("/templates") @app.route("/templates")

View File

@@ -1,23 +1,24 @@
import os
import tempfile
import random
import json import json
import os
import pickle
import random
import tempfile
import zipfile import zipfile
import concurrent.futures
from collections import Counter from collections import Counter
from typing import Dict, Optional, Any from typing import Dict, Optional, Any
from Utils import __version__
from flask import request, flash, redirect, url_for, session, render_template from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import commit, db_session
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed from BaseClasses import seeddigits, get_seed
from Generate import handle_name, PlandoSettings from Generate import handle_name, PlandoSettings
import pickle from Main import main as ERmain
from Utils import __version__
from .models import Generation, STATE_ERROR, STATE_QUEUED, commit, db_session, Seed, UUID
from WebHostLib import app from WebHostLib import app
from worlds.alttp.EntranceRandomizer import parse_arguments
from .check import get_yaml_data, roll_options from .check import get_yaml_data, roll_options
from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db from .upload import upload_zip_to_db
@@ -98,7 +99,7 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
meta.setdefault("server_options", {}).setdefault("hint_cost", 10) meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("race", False) race = meta.setdefault("race", False)
try: def task():
target = tempfile.TemporaryDirectory() target = tempfile.TemporaryDirectory()
playercount = len(gen_options) playercount = len(gen_options)
seed = get_seed() seed = get_seed()
@@ -138,6 +139,23 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
ERmain(erargs, seed, baked_server_options=meta["server_options"]) ERmain(erargs, seed, baked_server_options=meta["server_options"])
return upload_to_db(target.name, sid, owner, race) return upload_to_db(target.name, sid, owner, race)
thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
thread = thread_pool.submit(task)
try:
return thread.result(app.config["JOB_TIME"])
except concurrent.futures.TimeoutError as e:
if sid:
with db_session:
gen = Generation.get(id=sid)
if gen is not None:
gen.state = STATE_ERROR
meta = json.loads(gen.meta)
meta["error"] = (
"Allowed time for Generation exceeded, please consider generating locally instead. " +
e.__class__.__name__ + ": " + str(e))
gen.meta = json.dumps(meta)
commit()
except BaseException as e: except BaseException as e:
if sid: if sid:
with db_session: with db_session:

View File

@@ -1,7 +1,11 @@
from datetime import timedelta, datetime
from flask import render_template from flask import render_template
from pony.orm import count
from WebHostLib import app, cache from WebHostLib import app, cache
from .models import * from .models import Room, Seed
from datetime import timedelta
@app.route('/', methods=['GET', 'POST']) @app.route('/', methods=['GET', 'POST'])
@cache.cached(timeout=300) # cache has to appear under app route for caching to work @cache.cached(timeout=300) # cache has to appear under app route for caching to work

View File

@@ -32,7 +32,7 @@ def update_sprites_lttp():
spriteData = [] spriteData = []
for file in os.listdir(input_dir): for file in (file for file in os.listdir(input_dir) if not file.startswith(".")):
sprite = Sprite(os.path.join(input_dir, file)) sprite = Sprite(os.path.join(input_dir, file))
if not sprite.name: if not sprite.name:

174
WebHostLib/misc.py Normal file
View File

@@ -0,0 +1,174 @@
import datetime
import os
import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from pony.orm import count, commit, db_session
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
from .models import Seed, Room, Command, UUID, uuid4
def get_world_theme(game_name: str):
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
@app.before_request
def register_session():
session.permanent = True # technically 31 days after the last visit
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):
return render_template('404.html'), 404
# Start Playing Page
@app.route('/start-playing')
def start_playing():
return render_template(f"startPlaying.html")
@app.route('/weighted-settings')
def weighted_settings():
return render_template(f"weighted-settings.html")
# Player settings pages
@app.route('/games/<string:game>/player-settings')
def player_settings(game):
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>')
def game_info(game, lang):
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
# List of supported games
@app.route('/games')
def games():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("supportedGames.html", worlds=worlds)
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
def tutorial(game, file, lang):
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
@app.route('/tutorial/')
def tutorial_landing():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("tutorialLanding.html")
@app.route('/faq/<string:lang>/')
def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/')
def terms(lang):
return render_template("glossary.html", lang=lang)
@app.route('/seed/<suuid:seed>')
def view_seed(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
@app.route('/new_room/<suuid:seed>')
def new_room(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
commit()
return redirect(url_for("host_room", room=room.id))
def _read_log(path: str):
if os.path.exists(path):
with open(path, encoding="utf-8-sig") as log:
yield from log
else:
yield f"Logfile {path} does not exist. " \
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<suuid:room>')
def display_log(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
return "Access Denied", 403
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def host_room(room: UUID):
room: Room = Room.get(id=room)
if room is None:
return abort(404)
if request.method == "POST":
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
now = datetime.datetime.utcnow()
# indicate that the page should reload to get the assigned port
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static/static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
@app.route('/discord')
def discord():
return redirect("https://discord.gg/8Z65BR2")
@app.route('/datapackage')
@cache.cached()
def get_datapackage():
"""A pretty print version of /api/datapackage"""
from worlds import network_data_package
import json
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
@app.route('/index')
@app.route('/sitemap')
def get_sitemap():
available_games = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
available_games.append(game)
return render_template("siteMap.html", games=available_games)

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from pony.orm import * from pony.orm import Database, PrimaryKey, Required, Set, Optional, buffer, LongStr
db = Database() db = Database()
@@ -29,6 +29,7 @@ class Room(db.Entity):
show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always show_spoiler = Required(int, default=0) # 0 -> never, 1 -> after completion, -> 2 always
timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown timeout = Required(int, default=lambda: 2 * 60 * 60) # seconds since last activity to shutdown
tracker = Optional(UUID, index=True) tracker = Optional(UUID, index=True)
# Port special value -1 means the server errored out. Another attempt can be made with a page refresh
last_port = Optional(int, default=lambda: 0) last_port = Optional(int, default=lambda: 0)

View File

@@ -1,41 +1,38 @@
import json
import logging import logging
import os import os
from Utils import __version__
from jinja2 import Template
import yaml
import json
import typing import typing
from worlds.AutoWorld import AutoWorldRegister import yaml
import Options from jinja2 import Template
target_folder = os.path.join("WebHostLib", "static", "generated") import Options
from Utils import __version__, local_path
from worlds.AutoWorld import AutoWorldRegister
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints", handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
"exclude_locations"} "exclude_locations"}
def create(): def create():
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True) target_folder = local_path("WebHostLib", "static", "generated")
yaml_folder = os.path.join(target_folder, "configs")
os.makedirs(yaml_folder, exist_ok=True)
for file in os.listdir(yaml_folder):
full_path: str = os.path.join(yaml_folder, file)
if os.path.isfile(full_path):
os.unlink(full_path)
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]): def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
data = {} data = {option.default: 50}
special = getattr(option, "special_range_cutoff", None) for sub_option in ["random", "random-low", "random-high"]:
if special is not None: if sub_option != option.default:
data[special] = 0 data[sub_option] = 0
data.update({
option.range_start: 0,
option.range_end: 0,
"random": 0, "random-low": 0, "random-high": 0,
option.default: 50
})
notes = {
special: "minimum value without special meaning",
option.range_start: "minimum value",
option.range_end: "maximum value"
}
notes = {}
for name, number in getattr(option, "special_range_names", {}).items(): for name, number in getattr(option, "special_range_names", {}).items():
notes[name] = f"equivalent to {number}"
if number in data: if number in data:
data[name] = data[number] data[name] = data[number]
del data[number] del data[number]
@@ -44,10 +41,10 @@ def create():
return data, notes return data, notes
def default_converter(default_value): def get_html_doc(option_type: type(Options.Option)) -> str:
if isinstance(default_value, (set, frozenset)): if not option_type.__doc__:
return list(default_value) return "Please document me!"
return default_value return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
weighted_settings = { weighted_settings = {
"baseOptions": { "baseOptions": {
@@ -60,13 +57,20 @@ def create():
for game_name, world in AutoWorldRegister.world_types.items(): for game_name, world in AutoWorldRegister.world_types.items():
all_options = {**Options.per_game_common_options, **world.options} all_options: typing.Dict[str, Options.AssembleOptions] = {
res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render( **Options.per_game_common_options,
**world.option_definitions
}
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
file_data = f.read()
res = Template(file_data).render(
options=all_options, options=all_options,
__version__=__version__, game=game_name, yaml_dump=yaml.dump, __version__=__version__, game=game_name, yaml_dump=yaml.dump,
dictify_range=dictify_range, default_converter=default_converter, dictify_range=dictify_range,
) )
del file_data
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f: with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
f.write(res) f.write(res)
@@ -88,7 +92,7 @@ def create():
game_options[option_name] = this_option = { game_options[option_name] = this_option = {
"type": "select", "type": "select",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!", "description": get_html_doc(option),
"defaultValue": None, "defaultValue": None,
"options": [] "options": []
} }
@@ -102,26 +106,21 @@ def create():
if sub_option_id == option.default: if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name this_option["defaultValue"] = sub_option_name
this_option["options"].append({
"name": "Random",
"value": "random",
})
if option.default == "random": if option.default == "random":
this_option["defaultValue"] = "random" this_option["defaultValue"] = "random"
elif hasattr(option, "range_start") and hasattr(option, "range_end"): elif issubclass(option, Options.Range):
game_options[option_name] = { game_options[option_name] = {
"type": "range", "type": "range",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!", "description": get_html_doc(option),
"defaultValue": option.default if hasattr( "defaultValue": option.default if hasattr(
option, "default") and option.default != "random" else option.range_start, option, "default") and option.default != "random" else option.range_start,
"min": option.range_start, "min": option.range_start,
"max": option.range_end, "max": option.range_end,
} }
if hasattr(option, "special_range_names"): if issubclass(option, Options.SpecialRange):
game_options[option_name]["type"] = 'special_range' game_options[option_name]["type"] = 'special_range'
game_options[option_name]["value_names"] = {} game_options[option_name]["value_names"] = {}
for key, val in option.special_range_names.items(): for key, val in option.special_range_names.items():
@@ -131,22 +130,22 @@ def create():
game_options[option_name] = { game_options[option_name] = {
"type": "items-list", "type": "items-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!", "description": get_html_doc(option),
} }
elif getattr(option, "verify_location_name", False): elif getattr(option, "verify_location_name", False):
game_options[option_name] = { game_options[option_name] = {
"type": "locations-list", "type": "locations-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!", "description": get_html_doc(option),
} }
elif hasattr(option, "valid_keys"): elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet):
if option.valid_keys: if option.valid_keys:
game_options[option_name] = { game_options[option_name] = {
"type": "custom-list", "type": "custom-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name, "displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!", "description": get_html_doc(option),
"options": list(option.valid_keys), "options": list(option.valid_keys),
} }

View File

@@ -1,7 +1,7 @@
flask>=2.1.3 flask>=2.2.2
pony>=0.7.16 pony>=0.7.16
waitress>=2.1.1 waitress>=2.1.2
Flask-Caching>=2.0.1 Flask-Caching>=2.0.1
Flask-Compress>=1.12 Flask-Compress>=1.13
Flask-Limiter>=2.5.0 Flask-Limiter>=2.7.0
bokeh>=2.4.3 bokeh>=3.0.0

View File

@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
adjustHeaderWidth(); adjustHeaderWidth();
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')); for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/); const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
for (let i=0; i < headers.length; i++){ header.setAttribute('id', headerId);
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase() header.addEventListener('click', () => {
headers[i].setAttribute('id', headerId); window.location.hash = `#${headerId}`;
headers[i].addEventListener('click', () => header.scrollIntoView();
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`); });
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
if (scrollTargetIndex > -1) { document.fonts.ready.finally(() => {
try{ if (window.location.hash) {
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1); const scrollTarget = document.getElementById(window.location.hash.substring(1));
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" }); scrollTarget?.scrollIntoView();
} catch(error) {
console.error(error);
} }
} });
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
tutorialWrapper.innerHTML = tutorialWrapper.innerHTML =

View File

@@ -46,7 +46,7 @@ the website is not required to generate them.
## How do I get started? ## 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 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/archipelago). There are always people ready to answer our discord server at the [Archipelago Discord](https://discord.gg/8Z65BR2). There are always people ready to answer
any questions you might have. any questions you might have.
## What are some common terms I should know? ## What are some common terms I should know?

View File

@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
adjustHeaderWidth(); adjustHeaderWidth();
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')); for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/); const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
for (let i=0; i < headers.length; i++){ header.setAttribute('id', headerId);
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase() header.addEventListener('click', () => {
headers[i].setAttribute('id', headerId); window.location.hash = `#${headerId}`;
headers[i].addEventListener('click', () => header.scrollIntoView();
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`); });
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
if (scrollTargetIndex > -1) { document.fonts.ready.finally(() => {
try{ if (window.location.hash) {
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1); const scrollTarget = document.getElementById(window.location.hash.substring(1));
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" }); scrollTarget?.scrollIntoView();
} catch(error) {
console.error(error);
} }
} });
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
gameInfo.innerHTML = gameInfo.innerHTML =

View File

@@ -26,24 +26,22 @@ window.addEventListener('load', () => {
adjustHeaderWidth(); adjustHeaderWidth();
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')); for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/); const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
for (let i=0; i < headers.length; i++){ header.setAttribute('id', headerId);
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase() header.addEventListener('click', () => {
headers[i].setAttribute('id', headerId); window.location.hash = `#${headerId}`;
headers[i].addEventListener('click', () => header.scrollIntoView();
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`); });
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
if (scrollTargetIndex > -1) { document.fonts.ready.finally(() => {
try{ if (window.location.hash) {
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1); const scrollTarget = document.getElementById(window.location.hash.substring(1));
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" }); scrollTarget?.scrollIntoView();
} catch(error) {
console.error(error);
} }
} });
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
tutorialWrapper.innerHTML = tutorialWrapper.innerHTML =

View File

@@ -102,9 +102,15 @@ const buildOptionsTable = (settings, romOpts = false) => {
// td Left // td Left
const tdl = document.createElement('td'); const tdl = document.createElement('td');
const label = document.createElement('label'); const label = document.createElement('label');
label.textContent = `${settings[setting].displayName}: `;
label.setAttribute('for', setting); label.setAttribute('for', setting);
label.setAttribute('data-tooltip', settings[setting].description);
label.innerText = `${settings[setting].displayName}:`; const questionSpan = document.createElement('span');
questionSpan.classList.add('interactive');
questionSpan.setAttribute('data-tooltip', settings[setting].description);
questionSpan.innerText = '(?)';
label.appendChild(questionSpan);
tdl.appendChild(label); tdl.appendChild(label);
tr.appendChild(tdl); tr.appendChild(tdl);
@@ -112,6 +118,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
const tdr = document.createElement('td'); const tdr = document.createElement('td');
let element = null; let element = null;
const randomButton = document.createElement('button');
switch(settings[setting].type){ switch(settings[setting].type){
case 'select': case 'select':
element = document.createElement('div'); element = document.createElement('div');
@@ -132,8 +140,21 @@ const buildOptionsTable = (settings, romOpts = false) => {
} }
select.appendChild(option); select.appendChild(option);
}); });
select.addEventListener('change', (event) => updateGameSetting(event)); select.addEventListener('change', (event) => updateGameSetting(event.target));
element.appendChild(select); 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; break;
case 'range': case 'range':
@@ -148,15 +169,29 @@ const buildOptionsTable = (settings, romOpts = false) => {
range.value = currentSettings[gameName][setting]; range.value = currentSettings[gameName][setting];
range.addEventListener('change', (event) => { range.addEventListener('change', (event) => {
document.getElementById(`${setting}-value`).innerText = event.target.value; document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event); updateGameSetting(event.target);
}); });
element.appendChild(range); element.appendChild(range);
let rangeVal = document.createElement('span'); let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value'); rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${setting}-value`); rangeVal.setAttribute('id', `${setting}-value`);
rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue; rangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
currentSettings[gameName][setting] : settings[setting].defaultValue;
element.appendChild(rangeVal); 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; break;
case 'special_range': case 'special_range':
@@ -195,7 +230,8 @@ const buildOptionsTable = (settings, romOpts = false) => {
let specialRangeVal = document.createElement('span'); let specialRangeVal = document.createElement('span');
specialRangeVal.classList.add('range-value'); specialRangeVal.classList.add('range-value');
specialRangeVal.setAttribute('id', `${setting}-value`); specialRangeVal.setAttribute('id', `${setting}-value`);
specialRangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue; specialRangeVal.innerText = currentSettings[gameName][setting] !== 'random' ?
currentSettings[gameName][setting] : settings[setting].defaultValue;
// Configure select event listener // Configure select event listener
specialRangeSelect.addEventListener('change', (event) => { specialRangeSelect.addEventListener('change', (event) => {
@@ -204,7 +240,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
// Update range slider // Update range slider
specialRange.value = event.target.value; specialRange.value = event.target.value;
document.getElementById(`${setting}-value`).innerText = event.target.value; document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event); updateGameSetting(event.target);
}); });
// Configure range event handler // Configure range event handler
@@ -214,13 +250,29 @@ const buildOptionsTable = (settings, romOpts = false) => {
(Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ? (Object.values(settings[setting].value_names).includes(parseInt(event.target.value))) ?
parseInt(event.target.value) : 'custom'; parseInt(event.target.value) : 'custom';
document.getElementById(`${setting}-value`).innerText = event.target.value; document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event); updateGameSetting(event.target);
}); });
element.appendChild(specialRangeSelect); element.appendChild(specialRangeSelect);
specialRangeWrapper.appendChild(specialRange); specialRangeWrapper.appendChild(specialRange);
specialRangeWrapper.appendChild(specialRangeVal); specialRangeWrapper.appendChild(specialRangeVal);
element.appendChild(specialRangeWrapper); 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; break;
default: default:
@@ -237,6 +289,25 @@ const buildOptionsTable = (settings, romOpts = false) => {
return table; return table;
}; };
const toggleRandomize = (event, inputElements) => {
const active = event.target.classList.contains('active');
const randomButton = event.target;
if (active) {
randomButton.classList.remove('active');
for (const element of inputElements) {
element.disabled = undefined;
updateGameSetting(element);
}
} else {
randomButton.classList.add('active');
for (const element of inputElements) {
element.disabled = true;
updateGameSetting(randomButton);
}
}
};
const updateBaseSetting = (event) => { const updateBaseSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName)); const options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ? options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
@@ -244,10 +315,17 @@ const updateBaseSetting = (event) => {
localStorage.setItem(gameName, JSON.stringify(options)); localStorage.setItem(gameName, JSON.stringify(options));
}; };
const updateGameSetting = (event) => { const updateGameSetting = (settingElement) => {
const options = JSON.parse(localStorage.getItem(gameName)); const options = JSON.parse(localStorage.getItem(gameName));
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value, 10); 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)); localStorage.setItem(gameName, JSON.stringify(options));
}; };

View File

@@ -27,25 +27,28 @@ window.addEventListener('load', () => {
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results); tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth(); adjustHeaderWidth();
const title = document.querySelector('h1')
if (title) {
document.title = title.textContent;
}
// Reset the id of all header divs to something nicer // Reset the id of all header divs to something nicer
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')); for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/); const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
for (let i=0; i < headers.length; i++){ header.setAttribute('id', headerId);
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase() header.addEventListener('click', () => {
headers[i].setAttribute('id', headerId); window.location.hash = `#${headerId}`;
headers[i].addEventListener('click', () => header.scrollIntoView();
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`); });
} }
// Manually scroll the user to the appropriate header if anchor navigation is used // Manually scroll the user to the appropriate header if anchor navigation is used
if (scrollTargetIndex > -1) { document.fonts.ready.finally(() => {
try{ if (window.location.hash) {
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1); const scrollTarget = document.getElementById(window.location.hash.substring(1));
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" }); scrollTarget?.scrollIntoView();
} catch(error) {
console.error(error);
} }
} });
}).catch((error) => { }).catch((error) => {
console.error(error); console.error(error);
tutorialWrapper.innerHTML = tutorialWrapper.innerHTML =

View File

@@ -78,13 +78,16 @@ const createDefaultSettings = (settingData) => {
break; break;
case 'range': case 'range':
case 'special_range': case 'special_range':
for (let i = setting.min; i <= setting.max; ++i){ newSettings[game][gameSetting][setting.min] = 0;
newSettings[game][gameSetting][i] = newSettings[game][gameSetting][setting.max] = 0;
(setting.hasOwnProperty('defaultValue') && setting.defaultValue === i) ? 25 : 0;
}
newSettings[game][gameSetting]['random'] = 0; newSettings[game][gameSetting]['random'] = 0;
newSettings[game][gameSetting]['random-low'] = 0; newSettings[game][gameSetting]['random-low'] = 0;
newSettings[game][gameSetting]['random-high'] = 0; newSettings[game][gameSetting]['random-high'] = 0;
if (setting.hasOwnProperty('defaultValue')) {
newSettings[game][gameSetting][setting.defaultValue] = 25;
} else {
newSettings[game][gameSetting][setting.min] = 25;
}
break; break;
case 'items-list': case 'items-list':
@@ -401,11 +404,17 @@ const buildWeightedSettingsDiv = (game, settings) => {
tr.appendChild(tdDelete); tr.appendChild(tdDelete);
rangeTbody.appendChild(tr); rangeTbody.appendChild(tr);
// Save new option to settings
range.dispatchEvent(new Event('change'));
}); });
Object.keys(currentSettings[game][settingName]).forEach((option) => { Object.keys(currentSettings[game][settingName]).forEach((option) => {
if (currentSettings[game][settingName][option] > 0) { // These options are statically generated below, and should always appear even if they are deleted
const tr = document.createElement('tr'); // from localStorage
if (['random-low', 'random', 'random-high'].includes(option)) { return; }
const tr = document.createElement('tr');
const tdLeft = document.createElement('td'); const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left'); tdLeft.classList.add('td-left');
tdLeft.innerText = option; tdLeft.innerText = option;
@@ -439,14 +448,15 @@ const buildWeightedSettingsDiv = (game, settings) => {
deleteButton.innerText = '❌'; deleteButton.innerText = '❌';
deleteButton.addEventListener('click', () => { deleteButton.addEventListener('click', () => {
range.value = 0; range.value = 0;
range.dispatchEvent(new Event('change')); const changeEvent = new Event('change');
changeEvent.action = 'rangeDelete';
range.dispatchEvent(changeEvent);
rangeTbody.removeChild(tr); rangeTbody.removeChild(tr);
}); });
tdDelete.appendChild(deleteButton); tdDelete.appendChild(deleteButton);
tr.appendChild(tdDelete); tr.appendChild(tdDelete);
rangeTbody.appendChild(tr); rangeTbody.appendChild(tr);
}
}); });
} }
@@ -904,8 +914,12 @@ const updateGameSetting = (evt) => {
const setting = evt.target.getAttribute('data-setting'); const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option'); const option = evt.target.getAttribute('data-option');
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value; document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
options[game][setting][option] = isNaN(evt.target.value) ? console.log(event);
evt.target.value : parseInt(evt.target.value, 10); if (evt.action && evt.action === 'rangeDelete') {
delete options[game][setting][option];
} else {
options[game][setting][option] = parseInt(evt.target.value, 10);
}
localStorage.setItem('weighted-settings', JSON.stringify(options)); localStorage.setItem('weighted-settings', JSON.stringify(options));
}; };

View File

@@ -56,7 +56,3 @@
#file-input{ #file-input{
display: none; display: none;
} }
.interactive{
color: #ffef00;
}

View File

@@ -105,3 +105,7 @@ h5, h6{
margin-bottom: 20px; margin-bottom: 20px;
background-color: #ffff00; background-color: #ffff00;
} }
.interactive{
color: #ffef00;
}

View File

@@ -55,4 +55,6 @@
border: 1px solid #2a6c2f; border: 1px solid #2a6c2f;
border-radius: 6px; border-radius: 6px;
color: #000000; color: #000000;
overflow-y: auto;
max-height: 400px;
} }

View File

@@ -116,6 +116,10 @@ html{
flex-grow: 1; flex-grow: 1;
} }
#player-settings table select:disabled{
background-color: lightgray;
}
#player-settings table .range-container{ #player-settings table .range-container{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@@ -138,12 +142,27 @@ html{
#player-settings table .special-range-wrapper{ #player-settings table .special-range-wrapper{
display: flex; display: flex;
flex-direction: row; flex-direction: row;
margin-top: 0.25rem;
} }
#player-settings table .special-range-wrapper input[type=range]{ #player-settings table .special-range-wrapper input[type=range]{
flex-grow: 1; flex-grow: 1;
} }
#player-settings table .randomize-button {
max-height: 24px;
line-height: 16px;
padding: 2px 8px;
margin: 0 0 0 0.25rem;
font-size: 12px;
border: 1px solid black;
border-radius: 3px;
}
#player-settings table .randomize-button.active {
background-color: #ffef00; /* Same as .interactive in globalStyles.css */
}
#player-settings table label{ #player-settings table label{
display: block; display: block;
min-width: 200px; min-width: 200px;

View File

@@ -1,5 +1,7 @@
html{ html{
padding-top: 110px; padding-top: 110px;
scroll-padding-top: 100px;
scroll-behavior: smooth;
} }
#base-header{ #base-header{

View File

@@ -52,6 +52,7 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,6 +52,7 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,6 +52,7 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,6 +52,7 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,6 +52,7 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -53,6 +53,7 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -52,6 +52,7 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -50,6 +50,7 @@ pre{
pre code{ pre code{
border: none; border: none;
display: block;
} }
code{ code{

View File

@@ -14,7 +14,6 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
/* Base styles for the element that has a tooltip */ /* Base styles for the element that has a tooltip */
[data-tooltip], .tooltip { [data-tooltip], .tooltip {
position: relative; position: relative;
cursor: pointer;
} }
/* Base styles for the entire tooltip */ /* Base styles for the entire tooltip */
@@ -55,14 +54,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
/** Content styles */ /** Content styles */
.tooltip:after, [data-tooltip]:after { .tooltip:after, [data-tooltip]:after {
width: 260px;
z-index: 10000; z-index: 10000;
padding: 8px; padding: 8px;
width: 160px;
border-radius: 4px; border-radius: 4px;
background-color: #000; background-color: #000;
background-color: hsla(0, 0%, 20%, 0.9); background-color: hsla(0, 0%, 20%, 0.9);
color: #fff; color: #fff;
content: attr(data-tooltip); content: attr(data-tooltip);
white-space: pre-wrap;
font-size: 14px; font-size: 14px;
line-height: 1.2; line-height: 1.2;
} }

View File

@@ -1,14 +1,14 @@
import typing
from collections import Counter, defaultdict from collections import Counter, defaultdict
from colorsys import hsv_to_rgb from colorsys import hsv_to_rgb
from datetime import datetime, timedelta, date from datetime import datetime, timedelta, date
from math import tau from math import tau
import typing
from bokeh.colors import RGB
from bokeh.embed import components from bokeh.embed import components
from bokeh.models import HoverTool from bokeh.models import HoverTool
from bokeh.plotting import figure, ColumnDataSource from bokeh.plotting import figure, ColumnDataSource
from bokeh.resources import INLINE from bokeh.resources import INLINE
from bokeh.colors import RGB
from flask import render_template from flask import render_template
from pony.orm import select from pony.orm import select
@@ -18,15 +18,17 @@ from .models import Room
PLOT_WIDTH = 600 PLOT_WIDTH = 600
def get_db_data() -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]: def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str],
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
games_played = defaultdict(Counter) games_played = defaultdict(Counter)
total_games = Counter() total_games = Counter()
cutoff = date.today()-timedelta(days=30) cutoff = date.today()-timedelta(days=30)
room: Room room: Room
for room in select(room for room in Room if room.creation_time >= cutoff): for room in select(room for room in Room if room.creation_time >= cutoff):
for slot in room.seed.slots: for slot in room.seed.slots:
total_games[slot.game] += 1 if slot.game in known_games:
games_played[room.creation_time.date()][slot.game] += 1 total_games[slot.game] += 1
games_played[room.creation_time.date()][slot.game] += 1
return total_games, games_played return total_games, games_played
@@ -73,10 +75,12 @@ def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.
@app.route('/stats') @app.route('/stats')
@cache.memoize(timeout=60 * 60) # regen once per hour should be plenty @cache.memoize(timeout=60 * 60) # regen once per hour should be plenty
def stats(): def stats():
from worlds import network_data_package
known_games = set(network_data_package["games"])
plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date", plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date",
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500) y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500)
total_games, games_played = get_db_data() total_games, games_played = get_db_data(known_games)
days = sorted(games_played) days = sorted(games_played)
color_palette = get_color_palette(len(total_games)) color_palette = get_color_palette(len(total_games))
@@ -90,7 +94,7 @@ def stats():
occurences, legend_label=game, line_width=2, color=game_to_color[game]) occurences, legend_label=game, line_width=2, color=game_to_color[game])
total = sum(total_games.values()) total = sum(total_games.values())
pie = figure(plot_height=350, title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None, pie = figure(title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")], tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2)) sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
pie.axis.visible = False pie.axis.visible = False
@@ -118,7 +122,8 @@ def stats():
start_angle="start_angles", end_angle="end_angles", fill_color="colors", start_angle="start_angles", end_angle="end_angles", fill_color="colors",
source=ColumnDataSource(data=data), legend_field="games") source=ColumnDataSource(data=data), legend_field="games")
per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in total_games per_game_charts = [create_game_played_figure(games_played, game, game_to_color[game]) for game in
sorted(total_games, key=lambda game: total_games[game])
if total_games[game] > 1] if total_games[game] > 1]
script, charts = components((plot, pie, *per_game_charts)) script, charts = components((plot, pie, *per_game_charts))

View File

@@ -1,7 +1,6 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Mystery Check Result</title> <title>Mystery Check Result</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/check.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/check.js") }}"></script>

View File

@@ -1,7 +1,6 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Generate Game</title> <title>Generate Game</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/generate.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/generate.js") }}"></script>
@@ -41,12 +40,11 @@
<tbody> <tbody>
<tr> <tr>
<td> <td>
<label for="forfeit_mode">Forfeit Permission:</label> <label for="forfeit_mode">Forfeit Permission:
<span <span class="interactive" data-tooltip="A forfeit releases all remaining items from the locations in your world.">
class="interactive" (?)
data-tooltip="A forfeit releases all remaining items from the locations </span>
in your world.">(?) </label>
</span>
</td> </td>
<td> <td>
<select name="forfeit_mode" id="forfeit_mode"> <select name="forfeit_mode" id="forfeit_mode">
@@ -63,12 +61,11 @@
<tr> <tr>
<td> <td>
<label for="collect_mode">Collect Permission:</label> <label for="collect_mode">Collect Permission:
<span <span class="interactive" data-tooltip="A collect releases all of your remaining items to you from across the multiworld.">
class="interactive" (?)
data-tooltip="A collect releases all of your remaining items to you </span>
from across the multiworld.">(?) </label>
</span>
</td> </td>
<td> <td>
<select name="collect_mode" id="collect_mode"> <select name="collect_mode" id="collect_mode">
@@ -85,12 +82,11 @@
<tr> <tr>
<td> <td>
<label for="remaining_mode">Remaining Permission:</label> <label for="remaining_mode">Remaining Permission:
<span <span class="interactive" data-tooltip="Remaining lists all items still in your world by name only.">
class="interactive" (?)
data-tooltip="Remaining lists all items still in your world by name only." </span>
>(?) </label>
</span>
</td> </td>
<td> <td>
<select name="remaining_mode" id="remaining_mode"> <select name="remaining_mode" id="remaining_mode">
@@ -106,11 +102,11 @@
</tr> </tr>
<tr> <tr>
<td> <td>
<label for="item_cheat">Item Cheat:</label> <label for="item_cheat">Item Cheat:
<span <span class="interactive" data-tooltip="Allows players to use the !getitem command.">
class="interactive" (?)
data-tooltip="Allows players to use the !getitem command.">(?) </span>
</span> </label>
</td> </td>
<td> <td>
<select name="item_cheat" id="item_cheat"> <select name="item_cheat" id="item_cheat">
@@ -131,12 +127,11 @@
<tbody> <tbody>
<tr> <tr>
<td> <td>
<label for="hint_cost"> Hint Cost:</label> <label for="hint_cost"> Hint Cost:
<span <span class="interactive" data-tooltip="After gathering this many checks, players can !hint <itemname> to get the location of that hint item.">
class="interactive" (?)
data-tooltip="After gathering this many checks, players can !hint <itemname> </span>
to get the location of that hint item.">(?) </label>
</span>
</td> </td>
<td> <td>
<select name="hint_cost" id="hint_cost"> <select name="hint_cost" id="hint_cost">
@@ -150,11 +145,11 @@
</tr> </tr>
<tr> <tr>
<td> <td>
<label for="server_password">Server Password:</label> <label for="server_password">Server Password:
<span <span class="interactive" data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command.">
class="interactive" (?)
data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command.">(?) </span>
</span> </label>
</td> </td>
<td> <td>
<input id="server_password" name="server_password"> <input id="server_password" name="server_password">
@@ -162,23 +157,22 @@
</tr> </tr>
<tr> <tr>
<td> <td>
<label for="plando_options">Plando Options:</label> Plando Options:
<span <span class="interactive" data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information.">
class="interactive" (?)
data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information.">(?)
</span> </span>
</td> </td>
<td> <td>
<input type="checkbox" name="plando_bosses" value="bosses" checked> <input type="checkbox" id="plando_bosses" name="plando_bosses" value="bosses" checked>
<label for="plando_bosses">Bosses</label><br> <label for="plando_bosses">Bosses</label><br>
<input type="checkbox" name="plando_items" value="items" checked> <input type="checkbox" id="plando_items" name="plando_items" value="items" checked>
<label for="plando_items">Items</label><br> <label for="plando_items">Items</label><br>
<input type="checkbox" name="plando_connections" value="connections" checked> <input type="checkbox" id="plando_connections" name="plando_connections" value="connections" checked>
<label for="plando_connections">Connections</label><br> <label for="plando_connections">Connections</label><br>
<input type="checkbox" name="plando_texts" value="texts" checked> <input type="checkbox" id="plando_texts" name="plando_texts" value="texts" checked>
<label for="plando_texts">Text</label> <label for="plando_texts">Text</label>
</td> </td>
</tr> </tr>

View File

@@ -1,7 +1,6 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Upload Multidata</title> <title>Upload Multidata</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostGame.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script> <script type="application/ecmascript" src="{{ url_for('static', filename="assets/hostGame.js") }}"></script>

View File

@@ -2,6 +2,7 @@
{% import "macros.html" as macros %} {% import "macros.html" as macros %}
{% block head %} {% block head %}
<title>Multiworld {{ room.id|suuid }}</title> <title>Multiworld {{ room.id|suuid }}</title>
{% if should_refresh %}<meta http-equiv="refresh" content="2">{% endif %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/>
{% endblock %} {% endblock %}
@@ -19,12 +20,16 @@
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity. The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
Should you wish to continue later, Should you wish to continue later,
anyone can simply refresh this page and the server will resume.<br> anyone can simply refresh this page and the server will resume.<br>
{% if room.last_port %} {% if room.last_port == -1 %}
There was an error hosting this Room. Another attempt will be made on refreshing this page.
The most likely failure reason is that the multiworld is too old to be loaded now.
{% elif room.last_port %}
You can connect to this room by using <span class="interactive" You can connect to this room by using <span class="interactive"
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}."> data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}' '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
</span> </span>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>{% endif %} in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
{% endif %}
{{ macros.list_patches_room(room) }} {{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %} {% if room.owner == session["_id"] %}
<form method=post> <form method=post>

View File

@@ -6,8 +6,6 @@
- -
<a href="https://github.com/ArchipelagoMW/Archipelago">Source Code</a> <a href="https://github.com/ArchipelagoMW/Archipelago">Source Code</a>
- -
<a href="https://github.com/ArchipelagoMW/Archipelago/wiki">Wiki</a>
-
<a href="https://github.com/ArchipelagoMW/Archipelago/graphs/contributors">Contributors</a> <a href="https://github.com/ArchipelagoMW/Archipelago/graphs/contributors">Contributors</a>
- -
<a href="https://github.com/ArchipelagoMW/Archipelago/issues">Bug Report</a> <a href="https://github.com/ArchipelagoMW/Archipelago/issues">Bug Report</a>

View File

@@ -43,7 +43,7 @@
{% elif patch.game | supports_apdeltapatch %} {% elif patch.game | supports_apdeltapatch %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download> <a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a> Download Patch File...</a>
{% elif patch.game == "Dark Souls III" %} {% elif patch.game == "Dark Souls III" and patch.data %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download> <a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download JSON File...</a> Download JSON File...</a>
{% else %} {% else %}

View File

@@ -43,6 +43,19 @@
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td> <td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td> <td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td> <td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
<div class="item-count">{{ shard_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
</tr> </tr>
</table> </table>
<table id="location-table"> <table id="location-table">

View File

@@ -1,56 +1,85 @@
# What is this file? # Q. What is this file?
# This file contains options which allow you to configure your multiworld experience while allowing others # A. This file contains options which allow you to configure your multiworld experience while allowing
# to play how they want as well. # others to play how they want as well.
# How do I use it?
# The options in this file are weighted. This means the higher number you assign to a value, the more
# chances you have for that option to be chosen. For example, an option like this:
# #
# map_shuffle: # Q. How do I use it?
# on: 5 # A. The options in this file are weighted. This means the higher number you assign to a value, the
# off: 15 # more chances you have for that option to be chosen. For example, an option like this:
# #
# Means you have 5 chances for map shuffle to occur, and 15 chances for map shuffle to be turned off # map_shuffle:
# on: 5
# off: 15
#
# Means you have 5 chances for map shuffle to occur, and 15 chances for map shuffle to be turned
# off.
#
# Q. I've never seen a file like this before. What characters am I allowed to use?
# A. This is a .yaml file. You are allowed to use most characters.
# To test if your yaml is valid or not, you can use this website:
# http://www.yamllint.com/
# You can also verify your Archipelago settings are valid at this site:
# https://archipelago.gg/check
# I've never seen a file like this before. What characters am I allowed to use? # Your name in-game. Spaces will be replaced with underscores and there is a 16-character limit.
# This is a .yaml file. You are allowed to use most characters. # {player} will be replaced with the player's slot number.
# To test if your yaml is valid or not, you can use this website: # {PLAYER} will be replaced with the player's slot number, if that slot number is greater than 1.
# http://www.yamllint.com/ # {number} will be replaced with the counter value of the name.
# {NUMBER} will be replaced with the counter value of the name, if the counter value is greater than 1.
name: Player{number}
description: Default {{ game }} Template # Used to describe your yaml. Useful if you have multiple files # Used to describe your yaml. Useful if you have multiple files.
# Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit description: Default {{ game }} Template
name: YourName{number}
#{player} will be replaced with the player's slot number. game: {{ game }}
#{PLAYER} will be replaced with the player's slot number if that slot number is greater than 1.
#{number} will be replaced with the counter value of the name.
#{NUMBER} will be replaced with the counter value of the name if the counter value is greater than 1.
game:
{{ game }}: 1
requires: requires:
version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected. version: {{ __version__ }} # Version of Archipelago required for this yaml to work as expected.
# Shared Options supported by all games:
{%- macro range_option(option) %} {%- macro range_option(option) %}
# you can add additional values between minimum and maximum # You can define additional values between the minimum and maximum values.
# Minimum value is {{ option.range_start }}
# Maximum value is {{ option.range_end }}
{%- set data, notes = dictify_range(option) %} {%- set data, notes = dictify_range(option) %}
{%- for entry, default in data.items() %} {%- for entry, default in data.items() %}
{{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %} {{ entry }}: {{ default }}{% if notes[entry] %} # {{ notes[entry] }}{% endif %}
{%- endfor -%} {%- endfor -%}
{% endmacro %} {% endmacro %}
{{ game }}: {{ game }}:
{%- for option_key, option in options.items() %} {%- for option_key, option in options.items() %}
{{ option_key }}:{% if option.__doc__ %} # {{ option.__doc__ | replace('\n', '\n#') | indent(4, first=False) }}{% endif %} {{ option_key }}:
{%- if option.__doc__ %}
# {{ option.__doc__
| trim
| replace('\n\n', '\n \n')
| replace('\n ', '\n# ')
| indent(4, first=False)
}}
{%- endif -%}
{%- if option.__doc__ and option.range_start is defined %}
#
{%- endif -%}
{%- if option.range_start is defined and option.range_start is number %} {%- if option.range_start is defined and option.range_start is number %}
{{- range_option(option) -}} {{- range_option(option) -}}
{%- elif option.options -%} {%- elif option.options -%}
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %} {%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %} {{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
{%- endfor -%} {%- endfor -%}
{% if option.default == "random" %}
random: 50 {%- if option.name_lookup[option.default] not in option.options %}
{{ option.default }}: 50
{%- endif -%} {%- endif -%}
{%- elif option.default is string %}
{{ option.default }}: 50
{%- elif option.default is iterable and option.default is not mapping %}
{{ option.default | list }}
{%- else %} {%- else %}
{{ yaml_dump(default_converter(option.default)) | indent(4, first=False) }} {{ yaml_dump(option.default) | trim | indent(4, first=false) }}
{%- endif -%} {%- endif -%}
{{ "\n" }}
{%- endfor %} {%- endfor %}
{% if not options %}{}{% endif %}

View File

@@ -1,7 +1,6 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
{{ super() }}
<title>Start Playing</title> <title>Start Playing</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/startPlaying.css") }}" />
{% endblock %} {% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% block head %} {% block head %}
<title>Player Settings</title> <title>Supported Games</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/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/supportedGames.css") }}" /> <link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/supportedGames.css") }}" />
{% endblock %} {% endblock %}
@@ -10,7 +10,8 @@
{% include 'header/oceanHeader.html' %} {% include 'header/oceanHeader.html' %}
<div id="games" class="markdown"> <div id="games" class="markdown">
<h1>Currently Supported Games</h1> <h1>Currently Supported Games</h1>
{% for game_name, world in worlds.items() | sort(attribute=0) %} {% for game_name in worlds | title_sorted %}
{% set world = worlds[game_name] %}
<h2>{{ game_name }}</h2> <h2>{{ game_name }}</h2>
<p> <p>
{{ world.__doc__ | default("No description provided.", true) }}<br /> {{ world.__doc__ | default("No description provided.", true) }}<br />

View File

@@ -41,7 +41,7 @@
<td></td> <td></td>
{% endif %} {% endif %}
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td> <td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
{% if 'FacebookMode' in options %} {% if 'EyeSpy' in options %}
<td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td> <td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td>
{% else %} {% else %}
<td></td> <td></td>

View File

@@ -1,18 +1,19 @@
import collections import collections
import datetime
import typing import typing
from typing import Counter, Optional, Dict, Any, Tuple from typing import Counter, Optional, Dict, Any, Tuple
from uuid import UUID
from flask import render_template from flask import render_template
from werkzeug.exceptions import abort from werkzeug.exceptions import abort
import datetime
from uuid import UUID
from worlds.alttp import Items from MultiServer import Context
from WebHostLib import app, cache, Room from NetUtils import SlotType
from Utils import restricted_loads from Utils import restricted_loads
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
from MultiServer import get_item_name_from_id, Context from worlds.alttp import Items
from NetUtils import SlotType from . import app, cache
from .models import Room
alttp_icons = { alttp_icons = {
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png", "Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
@@ -442,17 +443,23 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif", "Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png", "Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png", "Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
"Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png",
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
"Saddle": "https://i.imgur.com/2QtDyR0.png",
"Channeling Book": "https://i.imgur.com/J3WsYZw.png",
"Silk Touch Book": "https://i.imgur.com/iqERxHQ.png",
"Piercing IV Book": "https://i.imgur.com/OzJptGz.png",
} }
minecraft_location_ids = { minecraft_location_ids = {
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070, "Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077], 42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021, "Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42014], 42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014],
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046], "The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42035, 42016, 42020, "Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020,
42048, 42054, 42068, 42043, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42099, 42100], 42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105, 42099, 42103, 42110, 42100],
"Husbandry": [42065, 42067, 42078, 42022, 42007, 42079, 42013, 42028, 42036, "Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111, 42112,
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095], 42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091], "Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
} }
@@ -481,7 +488,8 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
# Multi-items # Multi-items
multi_items = { multi_items = {
"3 Ender Pearls": 45029, "3 Ender Pearls": 45029,
"8 Netherite Scrap": 45015 "8 Netherite Scrap": 45015,
"Dragon Egg Shard": 45043
} }
for item_name, item_id in multi_items.items(): for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower() base_name = item_name.split()[-1].lower()
@@ -819,27 +827,27 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str: seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
icons = { icons = {
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ETank.png", "Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ETank.png",
"Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Missile.png", "Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Missile.png",
"Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Super.png", "Super Missile": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Super.png",
"Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/PowerBomb.png", "Power Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/PowerBomb.png",
"Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Bomb.png", "Bomb": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Bomb.png",
"Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Charge.png", "Charge Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Charge.png",
"Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Ice.png", "Ice Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Ice.png",
"Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/HiJump.png", "Hi-Jump Boots": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/HiJump.png",
"Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpeedBooster.png", "Speed Booster": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpeedBooster.png",
"Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Wave.png", "Wave Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Wave.png",
"Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Spazer.png", "Spazer": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Spazer.png",
"Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpringBall.png", "Spring Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpringBall.png",
"Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Varia.png", "Varia Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Varia.png",
"Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Plasma.png", "Plasma Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Plasma.png",
"Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Grapple.png", "Grappling Beam": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Grapple.png",
"Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Morph.png", "Morph Ball": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Morph.png",
"Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Reserve.png", "Reserve Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Reserve.png",
"Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/Gravity.png", "Gravity Suit": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/Gravity.png",
"X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/XRayScope.png", "X-Ray Scope": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/XRayScope.png",
"Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/SpaceJump.png", "Space Jump": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/SpaceJump.png",
"Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/ScrewAttack.png", "Screw Attack": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ScrewAttack.png",
"Nothing": "", "Nothing": "",
"No Energy": "", "No Energy": "",
"Kraid": "", "Kraid": "",
@@ -987,10 +995,10 @@ def getTracker(tracker: UUID):
if game_state == 30: if game_state == 30:
inventory[team][player][106] = 1 # Triforce inventory[team][player][106] = 1 # Triforce
player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1) if playernumber not in groups} player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1) if playernumber not in groups} player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
for loc_data in locations.values(): for loc_data in locations.values():
for values in loc_data.values(): for values in loc_data.values():
item_id, item_player, flags = values item_id, item_player, flags = values
if item_id in ids_big_key: if item_id in ids_big_key:
@@ -1021,7 +1029,7 @@ def getTracker(tracker: UUID):
for (team, player), data in multisave.get("video", []): for (team, player), data in multisave.get("video", []):
video[(team, player)] = data video[(team, player)] = data
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=get_item_name_from_id, return render_template("tracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name,
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names, lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons, tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons,
multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas, multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas,

View File

@@ -1,19 +1,19 @@
import typing
import zipfile
import lzma
import json
import base64 import base64
import MultiServer import json
import typing
import uuid import uuid
import zipfile
from io import BytesIO from io import BytesIO
from flask import request, flash, redirect, url_for, session, render_template from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import flush, select from pony.orm import flush, select
from WebHostLib import app, Seed, Room, Slot import MultiServer
from Utils import parse_yaml, VersionException, __version__
from Patch import preferred_endings, AutoPatchRegister
from NetUtils import NetworkSlot, SlotType from NetUtils import NetworkSlot, SlotType
from Utils import VersionException, __version__
from worlds.Files import AutoPatchRegister
from . import app
from .models import Seed, Room, Slot
banned_zip_contents = (".sfc",) banned_zip_contents = (".sfc",)
@@ -22,7 +22,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
if not owner: if not owner:
owner = session["_id"] owner = session["_id"]
infolist = zfile.infolist() infolist = zfile.infolist()
slots = set() slots: typing.Set[Slot] = set()
spoiler = "" spoiler = ""
multidata = None multidata = None
for file in infolist: for file in infolist:
@@ -38,17 +38,6 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
player_name=patch.player_name, player_name=patch.player_name,
player_id=patch.player, player_id=patch.player,
game=patch.game)) game=patch.game))
elif file.filename.endswith(tuple(preferred_endings.values())):
data = zfile.open(file, "r").read()
yaml_data = parse_yaml(lzma.decompress(data).decode("utf-8-sig"))
if yaml_data["version"] < 2:
return "Old format cannot be uploaded (outdated .apbp)"
metadata = yaml_data["meta"]
slots.add(Slot(data=data,
player_name=metadata["player_name"],
player_id=metadata["player_id"],
game=yaml_data["game"]))
elif file.filename.endswith(".apmc"): elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read() data = zfile.open(file, "r").read()

495
ZillionClient.py Normal file
View File

@@ -0,0 +1,495 @@
import asyncio
import base64
import platform
from typing import Any, ClassVar, Coroutine, Dict, List, Optional, Protocol, Tuple, Type, cast
# 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
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.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_
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.auth:
# 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((
ctx.got_slot_data.wait(),
ctx.exit_event.wait(),
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
async_start(ctx.connect())
await asyncio.wait((
ctx.got_room_info.wait(),
ctx.exit_event.wait(),
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()
if __name__ == "__main__":
Utils.init_logging("ZillionClient", exception_logger="Client")
colorama.init()
asyncio.run(main())
colorama.deinit()

Binary file not shown.

BIN
data/basepatch.bsdiff4 Normal file

Binary file not shown.

View File

@@ -15,6 +15,8 @@
<UILog>: <UILog>:
viewclass: 'SelectableLabel' viewclass: 'SelectableLabel'
scroll_y: 0 scroll_y: 0
scroll_type: ["content", "bars"]
bar_width: dp(12)
effect_cls: "ScrollEffect" effect_cls: "ScrollEffect"
SelectableRecycleBoxLayout: SelectableRecycleBoxLayout:
default_size: None, dp(20) default_size: None, dp(20)

View File

@@ -97,6 +97,11 @@ local extensionConsumableLookup = {
[443] = 0x3F [443] = 0x3F
} }
local noOverworldItemsLookup = {
[499] = 0x2B,
[500] = 0x12,
}
local itemMessages = {} local itemMessages = {}
local consumableStacks = nil local consumableStacks = nil
local prevstate = "" local prevstate = ""
@@ -341,7 +346,7 @@ function processBlock(block)
-- This is a key item -- This is a key item
memoryLocation = memoryLocation - 0x0E0 memoryLocation = memoryLocation - 0x0E0
wU8(memoryLocation, 0x01) wU8(memoryLocation, 0x01)
elseif v >= 0x1E0 then elseif v >= 0x1E0 and v <= 0x1F2 then
-- This is a movement item -- This is a movement item
-- Minus Offset (0x100) - movement offset (0xE0) -- Minus Offset (0x100) - movement offset (0xE0)
memoryLocation = memoryLocation - 0x1E0 memoryLocation = memoryLocation - 0x1E0
@@ -351,7 +356,10 @@ function processBlock(block)
else else
wU8(memoryLocation, 0x01) wU8(memoryLocation, 0x01)
end end
elseif v >= 0x1F3 and v <= 0x1F4 then
-- NoOverworld special items
memoryLocation = noOverworldItemsLookup[v]
wU8(memoryLocation, 0x01)
elseif v >= 0x16C and v <= 0x1AF then elseif v >= 0x16C and v <= 0x1AF then
-- This is a gold item -- This is a gold item
amountToAdd = goldLookup[v] amountToAdd = goldLookup[v]

View File

@@ -77,12 +77,13 @@ local scrub_sanity_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0x10) return scene_check(scene_offset, bit_to_check, 0x10)
end end
-- Why is there an extra offset of 3 for temp context checks? Who knows.
local cow_check = function(scene_offset, bit_to_check) local cow_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0xC) return scene_check(scene_offset, bit_to_check, 0xC)
or check_temp_context({scene_offset, 0x00, bit_to_check}) or check_temp_context({scene_offset, 0x00, bit_to_check - 0x03})
end end
-- Haven't been able to get DMT and DMC fairy to send instantly -- DMT and DMC fairies are weird, their temp context check is special-coded for them
local great_fairy_magic_check = function(scene_offset, bit_to_check) local great_fairy_magic_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0x4) return scene_check(scene_offset, bit_to_check, 0x4)
or check_temp_context({scene_offset, 0x05, bit_to_check}) or check_temp_context({scene_offset, 0x05, bit_to_check})
@@ -100,6 +101,18 @@ local bean_sale_check = function(scene_offset, bit_to_check)
or check_temp_context({scene_offset, 0x00, 0x16}) or check_temp_context({scene_offset, 0x00, 0x16})
end end
-- Medigoron reports 0x00620028 to 0x40002C
local medigoron_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0xC)
or check_temp_context({scene_offset, 0x00, 0x28})
end
-- Bombchu salesman reports 0x005E0003 to 0x40002C
local salesman_check = function(scene_offset, bit_to_check)
return scene_check(scene_offset, bit_to_check, 0xC)
or check_temp_context({scene_offset, 0x00, 0x03})
end
--Helper method to resolve skulltula lookup location --Helper method to resolve skulltula lookup location
local function skulltula_scene_to_array_index(i) local function skulltula_scene_to_array_index(i)
return (i + 3) - 2 * (i % 4) return (i + 3) - 2 * (i % 4)
@@ -575,7 +588,7 @@ local read_death_mountain_trail_checks = function()
checks["DMT Freestanding PoH"] = on_the_ground_check(0x60, 0x1E) checks["DMT Freestanding PoH"] = on_the_ground_check(0x60, 0x1E)
checks["DMT Chest"] = chest_check(0x60, 0x01) checks["DMT Chest"] = chest_check(0x60, 0x01)
checks["DMT Storms Grotto Chest"] = chest_check(0x3E, 0x17) checks["DMT Storms Grotto Chest"] = chest_check(0x3E, 0x17)
checks["DMT Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x18) checks["DMT Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x18) or check_temp_context({0xFF, 0x05, 0x13})
checks["DMT Biggoron"] = big_goron_sword_check() checks["DMT Biggoron"] = big_goron_sword_check()
checks["DMT Cow Grotto Cow"] = cow_check(0x3E, 0x18) checks["DMT Cow Grotto Cow"] = cow_check(0x3E, 0x18)
@@ -592,7 +605,7 @@ local read_goron_city_checks = function()
checks["GC Pot Freestanding PoH"] = on_the_ground_check(0x62, 0x1F) checks["GC Pot Freestanding PoH"] = on_the_ground_check(0x62, 0x1F)
checks["GC Rolling Goron as Child"] = info_table_check(0x22, 0x6) checks["GC Rolling Goron as Child"] = info_table_check(0x22, 0x6)
checks["GC Rolling Goron as Adult"] = info_table_check(0x20, 0x1) checks["GC Rolling Goron as Adult"] = info_table_check(0x20, 0x1)
checks["GC Medigoron"] = on_the_ground_check(0x62, 0x1) checks["GC Medigoron"] = medigoron_check(0x62, 0x1)
checks["GC Maze Left Chest"] = chest_check(0x62, 0x00) checks["GC Maze Left Chest"] = chest_check(0x62, 0x00)
checks["GC Maze Right Chest"] = chest_check(0x62, 0x01) checks["GC Maze Right Chest"] = chest_check(0x62, 0x01)
checks["GC Maze Center Chest"] = chest_check(0x62, 0x02) checks["GC Maze Center Chest"] = chest_check(0x62, 0x02)
@@ -614,7 +627,7 @@ local read_death_mountain_crater_checks = function()
checks["DMC Volcano Freestanding PoH"] = on_the_ground_check(0x61, 0x08) checks["DMC Volcano Freestanding PoH"] = on_the_ground_check(0x61, 0x08)
checks["DMC Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02) checks["DMC Wall Freestanding PoH"] = on_the_ground_check(0x61, 0x02)
checks["DMC Upper Grotto Chest"] = chest_check(0x3E, 0x1A) checks["DMC Upper Grotto Chest"] = chest_check(0x3E, 0x1A)
checks["DMC Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x10) checks["DMC Great Fairy Reward"] = great_fairy_magic_check(0x3B, 0x10) or check_temp_context({0xFF, 0x05, 0x14})
checks["DMC Deku Scrub"] = scrub_sanity_check(0x61, 0x6) checks["DMC Deku Scrub"] = scrub_sanity_check(0x61, 0x6)
checks["DMC Deku Scrub Grotto Left"] = scrub_sanity_check(0x23, 0x1) checks["DMC Deku Scrub Grotto Left"] = scrub_sanity_check(0x23, 0x1)
@@ -961,7 +974,7 @@ end
local read_haunted_wasteland_checks = function() local read_haunted_wasteland_checks = function()
local checks = {} local checks = {}
checks["Wasteland Bombchu Salesman"] = on_the_ground_check(0x5E, 0x01) checks["Wasteland Bombchu Salesman"] = salesman_check(0x5E, 0x01)
checks["Wasteland Chest"] = chest_check(0x5E, 0x00) checks["Wasteland Chest"] = chest_check(0x5E, 0x00)
checks["Wasteland GS"] = skulltula_check(0x15, 0x1) checks["Wasteland GS"] = skulltula_check(0x15, 0x1)
return checks return checks

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

Binary file not shown.

389
data/lua/PKMN_RB/json.lua Normal file
View File

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

View File

@@ -0,0 +1,226 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local APIndex = 0x1A6E
local APItemAddress = 0x00FF
local EventFlagAddress = 0x1735
local MissableAddress = 0x161A
local HiddenItemsAddress = 0x16DE
local RodAddress = 0x1716
local InGame = 0x1A71
local ItemsReceived = nil
local playerName = nil
local seedName = nil
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local gbSocket = nil
local frame = 0
local u8 = nil
local wU8 = nil
local u16
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 table.empty (self)
for _, _ in pairs(self) do
return false
end
return true
end
function slice (tbl, s, e)
local pos, new = 1, {}
for i = s + 1, e do
new[pos] = tbl[i]
pos = pos + 1
end
return new
end
function processBlock(block)
if block == nil then
return
end
local itemsBlock = block["items"]
memDomain.wram()
if itemsBlock ~= nil then-- and u8(0x116B) ~= 0x00 then
-- print(itemsBlock)
ItemsReceived = itemsBlock
end
end
function difference(a, b)
local aa = {}
for k,v in pairs(a) do aa[v]=true end
for k,v in pairs(b) do aa[v]=nil end
local ret = {}
local n = 0
for k,v in pairs(a) do
if aa[v] then n=n+1 ret[n]=v end
end
return ret
end
function generateLocationsChecked()
memDomain.wram()
events = uRange(EventFlagAddress, 0x140)
missables = uRange(MissableAddress, 0x20)
hiddenitems = uRange(HiddenItemsAddress, 0x0E)
rod = u8(RodAddress)
data = {}
table.foreach(events, function(k, v) table.insert(data, v) end)
table.foreach(missables, function(k, v) table.insert(data, v) end)
table.foreach(hiddenitems, function(k, v) table.insert(data, v) end)
table.insert(data, rod)
return data
end
function generateSerialData()
memDomain.wram()
status = u8(0x1A73)
if status == 0 then
return nil
end
return uRange(0x1A76, u8(0x1A74))
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
print("timeout")
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
if l ~= nil then
processBlock(json.decode(l))
end
-- Determine Message to send back
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["playerName"] = playerName
retTable["seedName"] = seedName
memDomain.wram()
if u8(InGame) == 0xAC then
retTable["locations"] = generateLocationsChecked()
serialData = generateSerialData()
if serialData ~= nil then
retTable["serial"] = serialData
end
end
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 (is23Or24Or25 or is26To28) == false then
print("Must use a version of bizhawk 2.3.1 or higher")
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()
if u8(InGame) == 0xAC and u8(APItemAddress) == 0x00 then
ItemIndex = u16(APIndex)
if ItemsReceived[ItemIndex + 1] ~= nil then
wU8(APItemAddress, ItemsReceived[ItemIndex + 1] - 172000000)
end
end
end
elseif (curstate == STATE_UNINITIALIZED) then
if (frame % 60 == 0) then
print("Waiting for client.")
emu.frameadvance()
server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
-- print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE
gbSocket = client
gbSocket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

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

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

View File

@@ -221,7 +221,7 @@ Starting with version 4 of the APBP format, this is a ZIP file containing metada
files required by the game / patching process. For ROM-based games the ZIP will include a `delta.bsdiff4` which is the 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. bsdiff between the original and the randomized ROM.
To make using APBP easy, they can be generated by inheriting from `Patch.APDeltaPatch`. To make using APBP easy, they can be generated by inheriting from `worlds.Files.APDeltaPatch`.
### Mod files ### Mod files
Games which support modding will usually just let you drag and drop the mods files into a folder somewhere. Games which support modding will usually just let you drag and drop the mods files into a folder somewhere.
@@ -230,7 +230,7 @@ They can either be generic and modify the game using a seed or `slot_data` from
generated per seed. generated per seed.
If the mod is generated by AP and is installed from a ZIP file, it may be possible to include APBP metadata for easy 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 `Patch.APContainer`. integration into the Webhost by inheriting from `worlds.Files.APContainer`.
## Archipelago Integration ## Archipelago Integration

View File

@@ -0,0 +1,32 @@
# apworld Specification
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
Those are located in the `worlds/` folder (source) or `<insall dir>/lib/worlds/` (when installed).
See [world api.md](world api.md) for details.
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld`
file into the worlds folder.
## File Format
apworld files are zip archives with the case-sensitive file ending `.apworld`.
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
## Metadata
No metadata is specified yet.
## Extra Data
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 AP base have to use absolute imports, e.g. Options.py and worlds/AutoWorld.py.

11
docs/code_of_conduct.md Normal file
View File

@@ -0,0 +1,11 @@
# Code of Conduct
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:
* Be welcoming and inclusive in tone and language.
* Be respectful of others and their abilities.
* Show empathy when speaking with others.
* Be gracious and accept feedback and constructive criticism.
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private ones, such as private messaging or emails.
Any incidents of abuse may be reported directly to ijwu at hmfarran@gmail.com.

12
docs/contributing.md Normal file
View File

@@ -0,0 +1,12 @@
# Contributing
Contributions are welcome. We have a few requests of any 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).
Otherwise, we tend to judge code on a case to case basis.
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).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

View File

@@ -69,6 +69,12 @@ flowchart LR
end end
SNI <-- Various, depending on SNES device --> SMZ SNI <-- Various, depending on SNES device --> SMZ
%% Donkey Kong Country 3
subgraph Donkey Kong Country 3
DK3[SNES]
end
SNI <-- Various, depending on SNES device --> DK3
%% Native Clients or Games %% Native Clients or Games
%% Games or clients which compile to native or which the client is integrated in the game. %% Games or clients which compile to native or which the client is integrated in the game.
subgraph "Native" subgraph "Native"
@@ -82,10 +88,12 @@ flowchart LR
MT[Meritous] MT[Meritous]
TW[The Witness] TW[The Witness]
SA2B[Sonic Adventure 2: Battle] SA2B[Sonic Adventure 2: Battle]
DS3[Dark Souls 3]
APCLIENTPP <--> SOE APCLIENTPP <--> SOE
APCLIENTPP <--> MT APCLIENTPP <--> MT
APCLIENTPP <-- The Witness Randomizer --> TW APCLIENTPP <-- The Witness Randomizer --> TW
APCLIENTPP <--> DS3
APCPP <--> SM64 APCPP <--> SM64
APCPP <--> V6 APCPP <--> V6
APCPP <--> SA2B APCPP <--> SA2B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 92 KiB

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