Compare commits

...

495 Commits
0.1.1 ... 0.1.7

Author SHA1 Message Date
Fabian Dill
89984a0d09 Core: don't start threads for 'pass'
Core: print output progress every 10 files (OoT output may take a while, so let's give some user feedback on progress)
Subnautica: remove empty output method
2021-09-03 20:35:40 +02:00
Fabian Dill
2e2ca1665b Core: don't start threads for 'pass'
Core: print output progress every 10 files (OoT output may take a while, so let's give some user feedback on progress)
Subnautica: remove empty output method
2021-09-03 17:30:10 +02:00
Fabian Dill
1b27fc495f Ocarina of Time: reduce memory use by 64 MiB for each OoT world past the first
Ocarina of Time: limit parallel output to 2, to not waste memory that doesn't benefit speed
Ocarina of Time: remove swarm of os.chdir()
2021-09-03 12:50:26 +02:00
espeon65536
51c38fc628 Ocarina of Time (#64)
* first commit (not including OoT data files yet)

* added some basic options

* rule parser works now at least

* make sure to commit everything this time

* temporary change to BaseClasses for oot

* overworld location graph builds mostly correctly

* adding oot data files

* commenting out world options until later since they only existed to make the RuleParser work

* conversion functions between AP ids and OOT ids

* world graph outputs

* set scrub prices

* itempool generates, entrances connected, way too many options added

* fixed set_rules and set_shop_rules

* temp baseclasses changes

* Reaches the fill step now, old event-based system retained in case the new way breaks

* Song placements and misc fixes everywhere

* temporary changes to make oot work

* changed root exits for AP fill framework

* prevent infinite recursion due to OoT sharing usage of the address field

* age reachability works hopefully, songs are broken again

* working spoiler log generation on beatable-only

* Logic tricks implemented

* need this for logic tricks

* fixed map/compass being placed on Serenade location

* kill unreachable events before filling the world

* add a bunch of utility functions to prepare for rom patching

* move OptionList into generic options

* fixed some silly bugs with OptionList

* properly seed all random behavior (so far)

* ROM generation working

* fix hints trying to get alttp dungeon hint texts

* continue fixing hints

* add oot to network data package

* change item and location IDs to 66000 and 67000 range respectively

* push removed items to precollected items

* fixed various issues with cross-contamination with multiple world generation

* reenable glitched logic (hopefully)

* glitched world files age-check fix

* cleaned up some get_locations calls

* added token shuffle and scrub shuffle, modified some options slightly to make the parsing work

* reenable MQ dungeons

* fix forest mq exception

* made targeting style an option for now, will be cosmetic later

* reminder to move targeting to cosmetics

* some oot option maintenance

* enabled starting time of day

* fixed issue breaking shop slots in multiworld generation

* added "off" option for text shuffle and hints

* shopsanity functionality restored

* change patch file extension

* remove unnecessary utility functions + imports

* update MIT license

* change option to "patch_uncompressed_rom" instead of "compress_rom"

* compliance with new AutoWorld systems

* Kill only internal events, remove non-internal big poe event in code

* re-add the big poe event and handle it correctly

* remove extra method in Range option

* fix typo

* Starting items, starting with consumables option

* do not remove nonexistent item

* move set_shop_rules to after shop items are placed

* some cleanup

* add retries for song placement

* flagged Skull Mask and Mask of Truth as advancement items

* update OoT to use LogicMixin

* Fixed trying to assign starting items from the wrong players

* fixed song retry step

* improved option handling, comments, and starting item replacements

* DefaultOnToggle writes Yes or No to spoiler

* enable compression of output if Compress executable is present

* clean up compression

* check whether (de)compressor exists before running the process

* allow specification of rom path in host.yaml

* check if decompressed file already exists before decompressing again

* fix triforce hunt generation

* rename all the oot state functions with prefix

* OoT: mark triforce pieces as completion goal for triforce hunt

* added overworld and any-dungeon shuffle for dungeon items

* Hide most unshuffled locations and events from the list of locations in spoiler

* build oot option ranges with a generic function instead of defining each separately

* move oot output-type control to host.yaml instead of individual yamls

* implement dungeon song shuffle

* minor improvements to overworld dungeon item shuffle

* remove random ice trap names in shops, mostly to avoid maintaining a massive censor list

* always output patch file to folder, remove option to generate ROM in preparation for removal

* re-add the fix for infinite recursion due to not being light or dark world

* change AP-sendable to Ocarina of Time model, since the triforce piece has some extra code apparently

* oot: remove item_names and location_names

* oot: minor fixes

* oot: comment out ROM patching

* oot: only add CollectionState objects on creation if actually needed

* main entrance shuffle method and entrances-based rules

* fix entrances based rules

* disable master quest and big poe count options for client compatibility

* use get_player_name instead of get_player_names

* fix OptionList

* fix oot options for new option system

* new coop section in oot rom: expand player names to 16 bytes, write AP_PLAYER_NAME at end of PLAYER_NAMES

* fill AP player name in oot rom with 0 instead of 0xDF

* encode player name with ASCII for fixed-width

* revert oot player name array to 8 bytes per name

* remove Pierre location if fast scarecrow is on

* check player name length

* "free_scarecrow" not "fast_scarecrow"

* OoT locations now properly store the AP ID instead of the oot internal ID

* oot __version__ updates in lockstep with AP version

* pull in unmodified oot cosmetic files

* also grab JSONDump since it's needed apparently

* gather extra needed methods, modify imports

* delete cosmetics log, replace all instances of SettingsList with OOTWorld

* cosmetic options working, except for sound effects (due to ear-safe issues)

* SFX, Music, and Fanfare randomization reenabled

* move OoT data files into the worlds folder

* move Compress and Decompress into oot data folder

* Replace get_all_state with custom method to avoid the cache

* OoT ROM: increment item counter before setting incoming item/player values to 0, preventing desync issues

* set data_version to 0

* make Kokiri Sword shuffle off by default

* reenable "Random Choice" for various cosmetic options

* kill Ruto's Letter turnin if open fountain
also fix for shopsanity

* place Buy Goron/Zora Tunic first in shop shuffle

* make ice traps appear as other items instead of breaking generation

* managed to break ice traps on non-major-only

* only handle ice traps if they are on

* fix shopsanity for non-oot games, and write player name instead of player number

* light arrows hint uses player name instead of player number

* Reenable "skip child zelda" option

* fix entrances_based_rules

* fix ganondorf hint if starting with light arrows

* fix dungeonitem shuffle and shopsanity interaction

* remove has_all_of, has_any_of, count_of in BaseClasses, replace usage with has_all, has_any, has_group

* force local giveable item on ZL if skip_child_zelda and shuffle_song_items is any

* keep bosses and bombchu bowling chus out of data package

* revert workaround for infinite recursion and fix it properly

* fix shared shop id caches during patching process

* fix shop text box overflows, as much as possible

* add default oot host.yaml option

* add .apz5, .n64, .z64 to gitignore

* Properly document and name all (functioning) OOT options

* clean up some imports

* remove unnecessary files from oot's data

* fix typo in gitignore

* readd the Compress and Decompress utilities, since they are needed for generation

* cleanup of imports and some minor optimizations

* increase shop offset for item IDs to 0xCB

* remove shop item AP ids entirely

* prevent triforce pieces for other players from being received by yourself

* add "excluded" property to Location

* Hint system adapted and reenabled; hints still unseeded

* make hints deterministic with lists instead of sets

* do not allow hints to point to Light Arrows on non-vanilla bridge

* foreign locations hint as their full name in OoT rather than their region

* checkedLocations now stores hint names by player ID, so that the same location in different worlds can have hints associated

* consolidate versioning in Utils

* ice traps appear as major items rather than any progression item

* set prescription and claim check as defaults for adult trade item settings

* add oot options to playerSettings

* allow case-insensitive logic tricks in yaml

* fix oot shopsanity option formatting

* Write OoT override info even if local item, enabling local checks to show up immediately in the client

* implement CollectionState.can_live_dmg for oot glitched logic

* filter item names for invalid characters when patching shops

* make ice traps appear according to the settings of the world they are shuffled into, rather than the original world

* set hidden-spoiler items and locations with Shop items to events

* make GF carpenters, Gerudo Card, Malon, ZL, and Impa events if the relevant settings are enabled, preventing them from appearing in the client on game start

* Fix oot Glitched and No Logic generation

* fix indenting

* Greatly reduce displayed cosmetic options

* Change oot data version to 1

* add apz5 distribution to webhost

* print player name if an ALttP dungeon contains a good item for OoT world

* delete unneeded commented code

* remove OcarinaSongs import to satisfy lint
2021-09-02 14:35:05 +02:00
Fabian Dill
74c30ce09a Fill: remove/delay some LttP imports 2021-09-02 03:45:37 +02:00
Chris Wilson
859316353e Link /games to player-settings pages, add link to template file to player-settings, add markdown style formatting to /templates 2021-09-01 20:47:36 -04:00
Hussein Farran
63c9bea724 Remove total_items option. 2021-09-01 21:47:29 +00:00
espeon65536
c73b994305 use_cache argument to get_all_state 2021-09-01 19:21:03 +00:00
espeon65536
88451d4239 Skip caching get_all_state while setting rules
Since rules have not been set for later worlds, the cache believes the completion condition is freely available if it had been placed previously, which breaks beatable-only key placement.
2021-09-01 19:21:03 +00:00
CaitSith2
f74db254f6 fix typo in default value. 2021-09-01 09:18:43 -07:00
Fabian Dill
3cb0a22e17 LttP: crash on outdated dungeon_items use 2021-09-01 17:56:35 +02:00
Fabian Dill
ca3e01b15e LttPClient: prevent crash when trying to access sys.stdin 2021-09-01 17:56:19 +02:00
espeon65536
e9d1dcc46c set get_all_cache properly 2021-09-01 14:49:29 +00:00
Fabian Dill
7fd0f1a5bf Subnautica: implement create_item and therefore start_inventory 2021-09-01 16:46:44 +02:00
Fabian Dill
2d65fbf798 Merge pull request #58 from Ijwu/main
Risk of Rain 2 support
2021-09-01 11:30:41 +00:00
Fabian Dill
ac915d00fc Merge branch 'main' into main 2021-09-01 11:23:30 +00:00
espeon65536
fbb8d6b132 invalidate state cache so that reachable_regions are recalculated during TR key logic 2021-09-01 11:22:30 +00:00
espeon65536
fb0f70b3e3 make owg entrances in inverted 2021-09-01 11:22:30 +00:00
espeon65536
17929415ee actually set owg rules 2021-09-01 11:22:30 +00:00
espeon65536
631b6788c6 remove keys option for get_all_state, collect dungeon-local keys, and fix all uses of the state 2021-09-01 11:22:30 +00:00
espeon65536
7972aa6320 split building owg connections and setting the rules for those connections 2021-09-01 11:22:30 +00:00
espeon65536
138c884684 wipe reachable regions during TR key logic checks to ensure properly finding logic regions 2021-09-01 11:22:30 +00:00
Hussein Farran
f5ef98287a Add docstring to RiskOfRainWorld 2021-08-31 20:45:09 -04:00
Hussein Farran
5188b41ab0 Update RoR2 guide. 2021-08-31 20:42:16 -04:00
Hussein Farran
f83ba6e615 Add YAML options and update slot data.
Add TotalItems YAML option.
Add AllowLunarItems YAML option.
Send along TotalRevivals number with slot data.
2021-08-31 20:38:44 -04:00
Hussein Farran
cc2a72eb82 Locations/Events now None id 2021-08-31 20:21:52 -04:00
Chris Wilson
4fcce66505 Move game names and descriptions into AutoWorld, fix option value names on player-settings pages 2021-08-31 17:28:46 -04:00
Fabian Dill
66627d8a66 Options: match Toggle's get_option_name signature to Choice's 2021-08-31 22:52:14 +02:00
Fabian Dill
adfd68f83c Options: fix get_option_name 2021-08-31 22:14:18 +02:00
Fabian Dill
ddc619f2e7 WebHost: sample yamls: some formatting issues 2021-08-31 19:56:45 +02:00
Fabian Dill
ff2e57705e WebHost: sample yamls now render Range defaults correctly 2021-08-31 19:54:55 +02:00
Fabian Dill
a6a859b272 WebHost: fix sample yamls that have no options.
WebHost: hide hidden games from templates listing
2021-08-31 19:06:24 +02:00
Fabian Dill
88c5ebdd2f WebHost: add per-game yaml file downloads 2021-08-31 18:58:54 +02:00
Hussein Farran
3d578bcc98 Set force_auto_forfeit for RoR2 2021-08-31 10:08:19 -04:00
Hussein Farran
c3290af2bd Merge branch 'ArchipelagoMW:main' into main 2021-08-31 10:07:40 -04:00
Fabian Dill
01f1545b3e AutoWorld: add forced_auto_forfeit and set it for StS 2021-08-31 16:04:54 +02:00
Hussein Farran
fc8e849db5 Remove location id from Victory location. 2021-08-31 10:01:09 -04:00
Hussein Farran
9115e59f15 Add RoR2 to README 2021-08-31 08:37:01 -04:00
Hussein Farran
2f4b248a45 Add more information to the RoR2 docs. 2021-08-31 00:25:48 -04:00
Hussein Farran
2f28afb46e Add RoR2 Docs 2021-08-31 00:17:08 -04:00
Hussein Farran
e960d7b58c Merge branch 'main' of https://github.com/Ijwu/Archipelago into main 2021-08-30 21:43:18 -04:00
Fabian Dill
321569c542 Factorio: Fix random rocket-silo recipe unable to pick ingredients where recipe name != product name 2021-08-31 01:47:00 +02:00
Fabian Dill
df037c54ff LttP: fix dungeon original item rule calling
Found by Espeon
2021-08-30 23:52:40 +02:00
Fabian Dill
d859cecffb Options: use isinstance instead of type for Choice comparison 2021-08-30 23:07:19 +02:00
Fabian Dill
fd6e009c4b Fill: fix placing non_local + non advancement items 2021-08-30 22:20:44 +02:00
Fabian Dill
4520051ec9 Slay the Spire: add to playerSettings.yaml 2021-08-30 22:19:48 +02:00
Fabian Dill
b90b73859a Slay the Spire: add to playerSettings.yaml 2021-08-30 20:07:25 +02:00
Fabian Dill
6c357b61cc LttP: re-remove LttP import in BaseClasses 2021-08-30 19:11:12 +02:00
Fabian Dill
12957db90f Options: implement __eq__ assert for possible checks 2021-08-30 19:08:10 +02:00
CaitSith2
3c74f561d5 LttP: Fix smallkey_shuffle in menu display
use smallkey_shuffle.option_universal from worlds.alttp.Options rather than "universal" for compare operations on universal checking.
2021-08-30 09:59:20 -07:00
Fabian Dill
cc70a6fa26 LttP: make shuffle names consistent 2021-08-30 18:00:39 +02:00
Fabian Dill
1c42564d90 LttP: remove leftover location binding 2021-08-30 16:47:34 +02:00
Fabian Dill
e76c870c09 Unittest: fix TestInvertedBombRules 2021-08-30 16:38:21 +02:00
Fabian Dill
5daadcb2d5 LttP: implement new dungeon_items handling
LttP: move glitch_boots to new options system
WebHost: options.yaml no longer lists aliases
General: remove region.can_fill, it was only used as a hack to make dungeon-specific items to work
2021-08-30 16:31:56 +02:00
espeon65536
a124a7a82a Create event Blaze Spawner containing Blaze Rods, preventing scenarios where the only progression in a sphere is to gain access to a fortress, which crashes playthrough generation 2021-08-30 08:15:21 +00:00
espeon65536
a65bf60cea add structure compasses to itempool in a fixed order 2021-08-30 08:15:21 +00:00
Fabian Dill
3fa28a3fdb LttP: fix import mistake 2021-08-30 01:18:30 +02:00
Fabian Dill
baa7992a7a AutoWorld: add post_fill
LttP: Move ShopSlotFill to post_fill
2021-08-30 01:16:04 +02:00
Fabian Dill
7ba4bfc0d5 Generate: make sure no None items make it into multidata. 2021-08-30 00:52:57 +02:00
Fabian Dill
11fedef2f5 Generate: turn off interpret_on_off for newstyle options 2021-08-29 20:21:49 +02:00
Hussein Farran
944347a2b3 Risk of Rain 2 implementation 2021-08-29 14:02:02 -04:00
Fabian Dill
8c72b0a6c4 AutoYAML: proper multi-line comments 2021-08-29 18:13:38 +02:00
Fabian Dill
5d62d4e063 Clients: logging fixes 2021-08-29 17:38:35 +02:00
Adam Ziegler
9b05537a0e fix argument, logger name 2021-08-29 15:31:02 +00:00
Adam Ziegler
fd0a87626e list connected SNESes if more than one; allow connecting to specific one 2021-08-29 15:31:02 +00:00
KonoTyran
9402d82405 Slay the Spire (#54)
Add Slay the Spire
2021-08-29 17:30:44 +02:00
Fabian Dill
da6674760c LttP: convert MultiWorld.dungeons to dict for faster lookup 2021-08-29 16:02:28 +02:00
Fabian Dill
ee03371dd0 LttP: make heartbeep off functional again 2021-08-29 15:43:16 +02:00
Fabian Dill
a975c8fd00 LttP: Format non-native Location hints better 2021-08-28 23:18:45 +02:00
Fabian Dill
60840da740 LttP: fix dungeon local items to be local to their own dungeon 2021-08-28 22:58:23 +02:00
Fabian Dill
de567cc701 LttP: Move more functionality into ALttPItem from Item
LttP: More efficiently build !hint entrance info
LttP: More efficiently check for and build Big Bomb Shop playthrough path
2021-08-28 12:56:52 +02:00
Fabian Dill
de4775b0c8 LttP: Move difficulties and er seed sharing to generate_early 2021-08-28 00:26:02 +02:00
Fabian Dill
104cc0ea83 document World.hidden 2021-08-27 20:46:33 +02:00
CaitSith2
5bb8de500a Fix issue with syncing tech tree post-forfeit. 2021-08-27 10:41:29 -07:00
Fabian Dill
21255b3b46 LttP: Rename Shop Slot 1, 2, 3 to Shop Slot Left, Center, Right
General: Move generic IDs from LttP to new Generic World
Generate: ensure thread errors are collected before data from their completion may be referenced in playthrough/spoiler
2021-08-27 14:52:33 +02:00
espeon65536
e8da9924c6 allow collecting silver bow if noglitches or swordless, even if the limit is under 2 2021-08-27 07:44:05 +00:00
espeon65536
96b38aba04 mark TRBK as impassable during initial pass for TR key logic, so that crystaroller can be marked as front-locked 2021-08-27 07:44:05 +00:00
espeon65536
b8b51965d2 skip first sweep_for_events in playthrough computation, so keys are no longer treated as special 2021-08-27 07:44:05 +00:00
espeon65536
be46d128bc do not double-collect keys during playthrough computation, since they are progression items now 2021-08-27 07:44:05 +00:00
Fabian Dill
c05f1ed24f to be or not to be 2021-08-26 18:25:15 +02:00
Fabian Dill
99775ec1bd Generate: require that player names be unique again 2021-08-26 17:22:55 +02:00
Fabian Dill
f4f043ac87 MultiServer: categorize methods 2021-08-26 16:19:37 +02:00
Fabian Dill
acbca78e2d update Prompt Toolkit 2021-08-24 09:52:45 +02:00
Fabian Dill
30ac7baa2c FactorioClient: Batch-Send RCON commands when receiving catch-up locations and multiple items. 2021-08-24 09:52:12 +02:00
espeon65536
21a5170337 remove double negative in apmc file check 2021-08-24 04:02:28 +00:00
espeon65536
3a5a6a096b add .apmc and Forge server to gitignore 2021-08-24 04:02:28 +00:00
espeon65536
578ae70150 update playerSettings.yaml 2021-08-24 04:02:28 +00:00
espeon65536
57282e76a4 add send_defeated_mobs as option 2021-08-24 04:02:28 +00:00
espeon65536
7aaa652ef5 Give docstrings and display names to Minecraft options 2021-08-24 04:02:28 +00:00
espeon65536
81da0d2ba4 Minecraft client: skip deleting and recopying an apmc file that is already in APData 2021-08-24 04:02:28 +00:00
espeon65536
ce6cdcaf92 Minecraft client: prevent options.yaml/host.yaml contamination from non-install directories 2021-08-24 04:02:28 +00:00
espeon65536
4730a928b5 Minecraft client: fix NoneType-related error if run without apmc file 2021-08-24 04:02:28 +00:00
Chris Wilson
4c0f0a16c9 Updates to WebHost
- Support displayname option for Options module
- Improvements to landing page
- Added multi-language capable FAQ page
- Removed weighted-settings page
- Removed references to weighted-settings page
2021-08-22 20:01:58 -04:00
Fabian Dill
b07fc80f3f AutoWorld: if any world data_version is set to 0, set it for the main datapackage 2021-08-22 04:22:34 +02:00
Fabian Dill
6a3d1fcaf4 LttP & Factorio: fix item state removal for progressive items. 2021-08-21 06:55:08 +02:00
Fabian Dill
4aeb3cd3dc WebHost: allow /tutorial and /tutorial/ 2021-08-20 22:41:23 +02:00
Fabian Dill
6dc2000638 CommonClient.py: move in gui_enabled 2021-08-20 22:31:17 +02:00
Fabian Dill
72610d8c2f Core: log world ID ranges 2021-08-16 18:40:26 +02:00
Fabian Dill
0f55fa4f45 FactorioClient: allow setting a folder and find the executable in it, instead of trying to run a folder. 2021-08-15 13:46:58 +02:00
Fabian Dill
aec39c919c Minecraft: add missing minecraft defaults 2021-08-15 02:32:36 +02:00
Kono Tyran
a0849f9416 fixed error if destination folder did not exist already. 2021-08-15 00:05:54 +00:00
Kono Tyran
0668f94461 - change minecraft clients icon. 2021-08-14 23:05:15 +00:00
Chris Wilson
953ccc55d9 Update factorio icons to make progression items more distinct 2021-08-14 17:47:32 -04:00
espeon65536
fbaa8226c4 Minecraft tracker: only lookup recognized item ids 2021-08-14 19:58:23 +00:00
Fabian Dill
8abfd14569 LttP: fix missing music 2021-08-14 01:00:36 +02:00
Fabian Dill
f2f4d6a133 remove leftover debug log 2021-08-14 00:51:35 +02:00
Fabian Dill
3ed7092af5 LttP: make sure Hyrule Castle Small Key in Standard + keyshuffle is reachable in first sphere of any such players 2021-08-14 00:51:35 +02:00
Fabian Dill
9d6fa855d8 Multidata: fix accidental format change 2021-08-12 04:23:07 +02:00
Fabian Dill
8c7404edf9 Spoiler: fix built-in variable name shadowing 2021-08-11 12:45:03 +02:00
espeon65536
3f6a9e5dc7 MC client: only log removal of .apmc files 2021-08-10 18:42:48 +00:00
espeon65536
9e1748bf67 check_eula function 2021-08-10 18:42:48 +00:00
espeon65536
527a9b49e2 change to executable's working directory to find forge directory 2021-08-10 18:42:48 +00:00
espeon65536
b187223162 streamline function calls 2021-08-10 18:42:48 +00:00
espeon65536
2c5e99efed make apmc_file argument optional 2021-08-10 18:42:48 +00:00
espeon65536
fa8531022d reorganize Minecraft client internal structure, add missing error handling in update_mod 2021-08-10 18:42:48 +00:00
espeon65536
8d4be10fd7 Minecraft client first pass 2021-08-10 18:42:48 +00:00
Kono Tyran
285b9e12eb - Add Minecraft to inno_setup_38.iss, this will download java and forge and install them. 2021-08-10 18:42:25 +00:00
Fabian Dill
53fcb86174 Spoiler: remove Progressive from old system to prevent crashes when no LttP is present 2021-08-10 20:40:44 +02:00
Fabian Dill
a532ceeb0a AutoWorld: Should no longer need to overwrite collect, collect_item should be used instead
AutoWorld: Now correctly automatically applies State.remove if collect_item is also correct
LttP: Make keys advancement items

This feels like it improved generation chance. Might not be the case.
2021-08-10 09:47:28 +02:00
Fabian Dill
9ec0680ce5 LttP: move game specific fill to new AutoWorld fill_hook 2021-08-10 09:03:44 +02:00
Fabian Dill
299036ecca LttP: move some LttP specific things more towards locations where they belong. 2021-08-10 08:00:53 +02:00
Fabian Dill
4bfeb77a3a CommonClient: fix /missing
found by lordlou
2021-08-10 04:38:29 +02:00
Fabian Dill
ab7a5b07eb YAML: Make player pick a game, error out if step is skipped. 2021-08-09 23:00:28 +02:00
Fabian Dill
50ad661796 Put in support for old Progressive item key
I will probably regret this.
2021-08-09 10:07:25 +02:00
Fabian Dill
d3e71ecb9b Install all modules for unittests.yml 2021-08-09 07:29:21 +00:00
Fabian Dill
ba3bb201cd Multiple: Followed a rabbit hole of moving LttP Rom generation to AutoWorld
Generator: Re-allow names with spaces (and see what breaks)
Generator: Removed teams (Note that teams are intended to move from a generation step feature to a server runtime feature, allowing dynamic creation of an already generated MW)
LttP: All Rom Options are now on the new system
LttP: palette option "random" is now called "good"
LttP: Roms are now created as part of the general output file creation step
LttP: disable Music is now Music, removing potential double negatives
LttP & Factorio: Progressive option random is now grouped_random
LttP: Enemy damage option random is now Enemy damage: chaos
2021-08-09 09:15:41 +02:00
Fabian Dill
01d88c362a AutoWorld: Add "stage" methods and implement LttP Dungeon fill as an example. 2021-08-09 06:50:11 +02:00
Fabian Dill
95350a1fa9 Fill: Cache get_all_State 2021-08-09 06:33:26 +02:00
Fabian Dill
cc458ca5b1 LttP: Remove no longer reachable code 2021-08-09 06:19:01 +02:00
Fabian Dill
f19878fcb8 LttP: Remove calling the player Idiot 2021-08-09 03:51:33 +02:00
black-sliver
eb8e8691e9 Factorio: avoid ores when spawning silo
and minor code clean-up
2021-08-08 00:40:09 +00:00
Fabian Dill
0423c22d7f DataPackage: bring back compatibility layer for datapackage - for now. Mark removal version. 2021-08-07 09:18:42 +02:00
Fabian Dill
3441c390bd Setup: Fix crash if ROM was present. 2021-08-07 08:05:01 +02:00
Fabian Dill
a0fb9bc4ab Setup: Skip LttP Rom Selection if the Rom is not needed. 2021-08-07 06:57:33 +02:00
Fabian Dill
a7bb6f6a95 CommonClient: make entrances blue in console 2021-08-07 05:40:18 +02:00
Fabian Dill
f1bef73757 Setup: Fix subprogram paths 2021-08-07 03:16:30 +02:00
Fabian Dill
4598dd1a0f Factorio: syntax~ 2021-08-07 02:57:47 +02:00
espeon65536
0241d6f443 fix minecraft tests for egg shards 2021-08-07 00:44:57 +00:00
espeon65536
72acb5509a Minecraft: dragon egg shards 2021-08-07 00:44:57 +00:00
espeon65536
b43e99fa20 better check for completion in MC webtracker 2021-08-07 00:44:57 +00:00
espeon65536
b5083ddb1b update playerSettings: new minecraft bee_traps format 2021-08-07 00:44:57 +00:00
espeon65536
f62e8b7be9 Minecraft: write server and port to apmc on download 2021-08-07 00:44:57 +00:00
espeon65536
f655dc0dbc Minecraft tracker: formatting fix 2021-08-07 00:44:57 +00:00
espeon65536
95e0fa2672 Minecraft tracker: add progressive resource crafting 2021-08-07 00:44:57 +00:00
espeon65536
4b7c8f757e Minecraft: increment data version and client version 2021-08-07 00:44:57 +00:00
espeon65536
381e9c744a fix tests for progressive resource crafting 2021-08-07 00:44:57 +00:00
espeon65536
9aa4bb3f4b fix tests for bee traps 2021-08-07 00:44:57 +00:00
espeon65536
63617edfef Minecraft: merge ingot crafting and resource blocks into Progressive Resource Crafting 2021-08-07 00:44:57 +00:00
espeon65536
72de0450e0 Minecraft: refactored bee trap to percentage of junk item pool 2021-08-07 00:44:57 +00:00
espeon65536
306bdd322f Minecraft tracker: fix incorrect bold css 2021-08-07 00:44:57 +00:00
espeon65536
231613cb3b Minecraft tracker: automated location tracking and dropdown tabs 2021-08-07 00:44:57 +00:00
espeon65536
2af5739592 Minecraft tracker v2
group advancements by category
update font to Minecraft font
always display pearl/scrap counter
2021-08-07 00:44:57 +00:00
espeon65536
b38f7c8f2a Minecraft web tracker, built as a mix of the LttP tracker and the generic tracker 2021-08-07 00:44:57 +00:00
espeon65536
e3a81c1bed Minecraft: randomly determine junk items filling the itempool 2021-08-07 00:44:57 +00:00
Fabian Dill
cd8452d839 Factorio: sync already cleared locations to local world 2021-08-07 01:01:56 +02:00
Fabian Dill
4b38cb4c2e Setup: various small adjustments and fixes 2021-08-06 19:33:17 +02:00
Fabian Dill
eda8c6f263 add the forgotten progressive persoanl roboport equipment 2021-08-06 08:14:16 +02:00
Hussein Farran
a8cf67c94d Fix type annotation for a key under GameData 2021-08-04 22:04:53 +00:00
Hussein Farran
928b341fb3 Make data package contents more descriptive 2021-08-04 22:04:53 +00:00
Hussein Farran
6e51b1d50c Change BouncePacket and BouncedPacket docs to add key for extra data. 2021-08-04 19:54:06 +00:00
Fabian Dill
78aaa65b45 explain !hint a bit better 2021-08-04 18:38:49 +02:00
Fabian Dill
3627d8f1ae DataPackage: remove legacy format 2021-08-04 16:01:08 +02:00
Fabian Dill
1e64b817f6 CommonClient: implement new DataPackage format 2021-08-04 15:54:42 +02:00
CaitSith2
37e999652d Return of the warning for the backwards compatibility layer.
Mainly, make sure the backwards compatible /sc game.print works 100% of the time, instead of being silent, since commands that disable achievemets need to be executed twice at least once, within a certain period of time.
2021-08-03 23:24:32 -07:00
Fabian Dill
9408557f03 Factorio: add Traps 2021-08-04 05:40:51 +02:00
Fabian Dill
16701249b4 Minecraft: fix combat difficulty rules 2021-08-03 19:21:59 +02:00
Fabian Dill
3c1ac134f2 Options: add a way to get all option names (for selection menus or such) 2021-08-03 19:09:37 +02:00
Fabian Dill
230d9d993e clean up some spoiler display names 2021-08-03 19:03:41 +02:00
CaitSith2
d1c83ffc09 Make /factorio {factorio_command} no longer silent, even if /sc is used. 2021-08-03 09:48:07 -07:00
CaitSith2
a52f991543 Fix backwards compatibility check for cases where AP mod is NOT loaded last. 2021-08-03 08:51:12 -07:00
CaitSith2
dfc56a3272 Implement random progressive techs. 2021-08-02 19:33:14 -07:00
Fabian Dill
41037ce599 remove debug prints from a3924ed40a 2021-08-03 03:55:02 +02:00
CaitSith2
a3924ed40a Fix progressive items toggle 2021-08-02 18:50:56 -07:00
Fabian Dill
361bd4e5f6 Factorio: fix progressive flamethrower ordering 2021-08-03 01:14:20 +02:00
Fabian Dill
8cc245ac11 Technologies.py: add some missing types 2021-08-02 19:27:43 +02:00
Fabian Dill
2d8a6e84c1 Factorio: generalize merging of progressive technologies
use it for:
train network + braking force
flamethrower + refined flammables
inserters + inserter capacity
2021-08-02 19:12:42 +02:00
Fabian Dill
d2add54cd6 Factorio: implement decent option display names for Spoiler 2021-08-02 04:57:57 +02:00
Fabian Dill
40044ac5a6 Generate: wait for user close 2021-08-02 01:36:04 +02:00
Fabian Dill
bb15d0636e Network: implement Bounce and Bounced 2021-08-02 01:35:24 +02:00
Fabian Dill
2cc7d8395b MultiServer: fix loading old savegames 2021-08-01 22:47:56 +02:00
Fabian Dill
2f2e039356 MultiServer: Limit !hint to a single new result if costs are on. 2021-08-01 17:09:10 +02:00
Fabian Dill
0cd388ca66 MultiServer: seeded !hint selected 2021-08-01 17:02:38 +02:00
Fabian Dill
7ef1fe81f6 MultiServer: move !hint point counting to end of message 2021-08-01 16:48:25 +02:00
Fabian Dill
774610de7b Factorio: add progressive turret 2021-08-01 06:15:50 +02:00
Fabian Dill
f6c85e17d5 roll braking force into progressive train network 2021-08-01 02:51:20 +02:00
Fabian Dill
8142306562 Factorio: move adjust_energy over to "flop_random", giving half and half in each random direction, but no particular average. 2021-07-31 20:20:59 +02:00
Fabian Dill
2d84245103 Factorio: fix adjust_energy to hit special cases with implied energy cost 2021-07-31 20:19:05 +02:00
Fabian Dill
1d954b192c Factorio: display required rocket-silo ingredients ahead of time. 2021-07-31 19:45:17 +02:00
black-sliver
db0604f585 Factorio: add silo 'spawn' option 2021-07-31 16:27:53 +00:00
black-sliver
08beb5fbe6 Factorio: option to randomize silo recipe 2021-07-31 16:27:53 +00:00
alwaysintreble
7df06b87a5 reclarified some text (#38)
* reclarified some setup text
2021-07-31 16:02:38 +02:00
Fabian Dill
abf4e82737 Move Factorio data from /data/factorio to /worlds/factorio/data, to contain it in its world folder 2021-07-31 15:13:55 +02:00
Fabian Dill
7f8617d639 move ctx.ui to CommonClient.py 2021-07-31 01:53:06 +02:00
Fabian Dill
f5c62a82ac some post unification cleanup. 2021-07-31 01:40:27 +02:00
Fabian Dill
66514ec607 unify clients and setup 2021-07-31 00:03:48 +02:00
Fabian Dill
096e682b18 FactorioClient: implement JSONPrint in the client 2021-07-30 20:18:03 +02:00
Fabian Dill
e098b3c504 AutoWorld: automate item_names and location_names 2021-07-29 20:27:41 +02:00
Fabian Dill
4dde466364 MultiServer: print which game is being played. 2021-07-29 16:21:11 +02:00
Fabian Dill
6d6fc52481 Factorio: implement backwards compatible printing 2021-07-29 15:26:13 +02:00
Fabian Dill
eaae4af832 Factorio: fix reconnect 2021-07-29 15:25:45 +02:00
Fabian Dill
7f5afddb38 Docs: update network graph 2021-07-29 15:23:44 +02:00
Fabian Dill
36a981aaa2 Freeze: don't discard docstrings as Archipelago makes use of them during runtime. 2021-07-28 13:31:27 +02:00
Hussein Farran
fdcf093be0 Update README.md 2021-07-28 11:29:51 +00:00
black-sliver
1bd55b4572 ModuleUpdater: add -f and -y switches
-f: force update
-y: skip question to run pip
2021-07-27 16:02:09 +00:00
black-sliver
eb0e5b7438 MultiServer: don't extract .zip 2021-07-27 16:01:55 +00:00
Fabian Dill
884dece54c Factorio: move prints from /sc (silent command) to /ap-print, to prevent two warnings getting printed by Factorio 2021-07-27 14:59:24 +02:00
Fabian Dill
3759f4c644 FactorioClient: add /resync to trigger resending/reauth 2021-07-27 14:59:24 +02:00
Fabian Dill
f232f74246 Version: 0.1.6 start 2021-07-27 14:59:24 +02:00
Chris Wilson
a9ecab35d8 Add Subnautica to games list on WebHost 2021-07-26 17:21:00 -04:00
Chris Wilson
e1e25d0eae Add range options to player-settings pages 2021-07-25 19:04:08 -04:00
Chris Wilson
f45c042351 Include range options in generated JSON files for player-settings 2021-07-25 18:18:15 -04:00
Chris Wilson
f15bb9dbd7 Fix player-settings not defaulting the select options to their proper values. Also fix tab title. 2021-07-25 18:07:03 -04:00
Chris Wilson
610871c61b Template gameName into player-settings as a data attribute to avoid potential security risks. 2021-07-25 15:49:51 -04:00
Daivuk
35b9e4768a Adjust radiation rules to match code 2021-07-25 17:58:53 +00:00
Fabian Dill
1b4762715c print final output name 2021-07-25 16:15:51 +02:00
Fabian Dill
5c21538553 redirect old /hosted to new /room 2021-07-25 16:07:51 +02:00
David St-Louis
85481d7321 Added water filtration and added location positions (#32) 2021-07-25 15:33:47 +02:00
Chris Wilson
153fa16bcf Redirect user to 404 page for non-existing player-settings pages 2021-07-24 23:20:46 -04:00
Chris Wilson
71642f494f Automatically generate and save player settings for every game 2021-07-24 23:09:34 -04:00
Chris Wilson
8ba408385b Update options.py to generate JSON files to be used with player-settings pages 2021-07-24 21:27:56 -04:00
Fabian Dill
d2c420a1fd fix MultiServer file dailog ending targeting 2021-07-25 03:17:22 +02:00
Fabian Dill
855ff480a5 Require Factorio Client with World Gen capability 2021-07-25 03:13:13 +02:00
Fabian Dill
eb586aab55 add empty Subnautica section to playerSettings.yaml 2021-07-24 15:47:52 +02:00
Fabian Dill
b097f30f4d correctly ignore base weights file in generate 2021-07-24 14:42:34 +02:00
Fabian Dill
78f565c706 renamed /hosted/ to /room/
remove no longer used options
allow loading of json data files from webhost when it gets run by gunicorn and similar
2021-07-24 14:08:45 +02:00
Fabian Dill
af30d8b7cd ensure Hyrule Castle Small Key locality in standard + small key shuffle 2021-07-24 01:42:00 +02:00
espeon65536
e79a918c03 Minecraft updates (#29)
* Implement excluded locations

* Update Minecraft to use exclusion_rules for its exclusion pools

* Flag the enchanted books as advancement so they don't go on excluded locations (particularly the Infinity book)

* update playerSettings for exclusion

* new items: 32 Arrows, Saddle, structure compasses for overworld structures

* move structure linking to create_regions instead of generate_basic

* Update Minecraft to use LogicMixin

* add separate can_exclude property, so non-progression items can be marked non-excluded

* separate fill step for nonadvancement nonexcluded items

* made Saddle not a progression item, but also nonexcluded

* fix missing player arg

* remove higher xp amounts from pool, leaving only 50 XP

* fix new Minecraft item IDs

* added shulker box item for starting inventory

* increment client and data version

* change client_version to int instead of tuple

* make saddle a progression item

* added structure compass option and appropriate logic for all compasses

* Update playerSettings.yaml with MC options

* update minecraft tests

* update exclusion procedure for clarity
2021-07-24 01:28:16 +02:00
Daivuk
83dc92c6a5 Added missing PDA location and tweaked item count 2021-07-23 23:25:14 +00:00
espeon65536
64c80c32f0 update exclusion procedure for clarity 2021-07-23 18:18:32 +00:00
espeon65536
12eba33dbf separate fill step for nonadvancement nonexcluded items 2021-07-23 18:18:32 +00:00
espeon65536
0eee1f2d01 add separate can_exclude property, so non-progression items can be marked non-excluded 2021-07-23 18:18:32 +00:00
Fabian Dill
39a5921522 round of post-test fixes 2021-07-23 20:04:51 +02:00
Fabian Dill
c99a689504 Merge remote-tracking branch 'Daivuk/subnautica_clean' into subnautica_test 2021-07-23 15:49:23 +02:00
black-sliver
997a3e18a3 Factorio: add remaining world_gen options, reformat 2021-07-23 10:21:03 +00:00
Fabian Dill
15747f48e9 fix LttP create_regions 2021-07-23 12:03:19 +02:00
Fabian Dill
d62b46f6cd remove outdated comment 2021-07-23 11:31:09 +02:00
Daivuk
d406e4c3d9 Added Subnautica Support 2021-07-22 20:30:33 -04:00
Fabian Dill
fc7d37def4 MultiServer.py: when loading a .zip, create the .archipelago next to it to consistently load the same savegame. 2021-07-23 02:27:45 +02:00
Fabian Dill
f6b3dfe5ba MultiServer: allow loading a .zip containing a .archipelago directly. 2021-07-23 02:19:41 +02:00
Fabian Dill
fe9094dedc re-order Generate.py imports to ensure ModuleUpdate.py works 2021-07-23 01:57:45 +02:00
Fabian Dill
34ff5d9662 create options files on WebHost startup 2021-07-22 18:21:31 +02:00
Fabian Dill
df9bad75ea fix unittests 2021-07-22 15:59:37 +02:00
Fabian Dill
21af3bf563 move LttP create_regions and set_rules to AutoWorld 2021-07-22 15:51:50 +02:00
Fabian Dill
b2f5f095fc turns out windows' built in zip hates LZMA
also fix APMC output path
2021-07-22 01:08:44 +02:00
black-sliver
8a1ac566c8 FactorioClient: wait for instance to stop 2021-07-21 21:33:51 +00:00
Fabian Dill
75bf595f86 add (cached) /api/datapackage_version endpoint 2021-07-21 23:04:22 +02:00
Fabian Dill
312f13e254 add (cached) /api/datapackge endpoint 2021-07-21 22:55:44 +02:00
Fabian Dill
2fc4006dfa RIP: MultiMystery and Mystery, now there's just Generate
Other changes:
host.yaml Multi Mystery options were moved and changed
generate_output now has an output_directory argument
MultiWorld.get_game_players(<game>) now replaces <game>_player_ids
Python venv should now work properly
2021-07-21 18:08:15 +02:00
Fabian Dill
47f7ec16c0 FactorioClient.py: correctly pass keyword arguments to Factorio 2021-07-21 14:42:33 +02:00
Fabian Dill
e105616b96 use dynamic item name groups in State 2021-07-21 09:45:15 +02:00
Fabian Dill
a503134533 remove Gui.py, GuiUtils.py, ER_hint_reference.txt and icon.ico from root directory 2021-07-21 09:18:28 +02:00
Fabian Dill
bceb8540a1 assorted fixes 2021-07-20 21:19:53 +02:00
black-sliver
10c6a70696 Auto-validate Option.schema, Factorio: allow setting pollution values 2021-07-20 18:39:01 +00:00
Hussein Farran
b809d76b79 Move setting of MultiWorld.is_race to MultiWorld.secure. 2021-07-20 18:38:38 +00:00
Hussein Farran
bfad85223b Add race flag to APMC if AP is run with the race arg. 2021-07-20 18:38:38 +00:00
Fabian Dill
b53c5593a8 Automate dumpSprites.py - and then remove it 2021-07-20 18:23:06 +02:00
Fabian Dill
3bfb98a1c6 remove old Factorio tech tree layouts 2021-07-20 13:16:12 +02:00
Fabian Dill
573fde4bbc Merge together FactorioClient.py and FactorioClientGUI.py
Add cmd arguments
Add kivy style file, allowing users to modify it
2021-07-19 21:52:08 +02:00
Fabian Dill
5c8a076790 Add Ori and the Blind Forest
TODO: Mapstone counting, Open, OpenWorld, connection rules, goals
2021-07-16 12:41:37 +02:00
Fabian Dill
20b173453d (for now) only collect ER hint info for LttP
Optimize Entrance
2021-07-16 12:23:05 +02:00
Fabian Dill
3460c9f714 HK: use new logic mixin names 2021-07-15 13:33:24 +02:00
Fabian Dill
69a5bf0159 Add LogicMixin 2021-07-15 13:31:33 +02:00
Fabian Dill
01f0f309d1 add AutoWorld.generate_early, optimize Location 2021-07-15 08:50:08 +02:00
espeon65536
3d67e1dbdb move structure linking to create_regions instead of generate_basic 2021-07-15 06:04:09 +00:00
espeon65536
719e21ac8c update playerSettings for exclusion 2021-07-15 06:04:09 +00:00
espeon65536
14ed3b82a0 Flag the enchanted books as advancement so they don't go on excluded locations (particularly the Infinity book) 2021-07-15 06:04:09 +00:00
espeon65536
9e5e43fcd5 Update Minecraft to use exclusion_rules for its exclusion pools 2021-07-15 06:04:09 +00:00
espeon65536
7493b7f35e Implement excluded locations 2021-07-15 06:04:09 +00:00
Fabian Dill
54b3a57f46 fix GetDataPackage exclusions 2021-07-14 10:35:00 +02:00
Fabian Dill
4f998a6880 Documentation: now in repository. Programming documentation should be in /docs, player/user documentation should be in /WebHostLib/static/assets/tutorial.
Network: implement InvalidPacket, remove InvalidArguments and InvalidCmd
Datapackage: implement per-game versions and per-game package retrieval
2021-07-14 10:02:39 +02:00
Fabian Dill
62a6cdc9f7 allow remote_items to be set via AutoWorld 2021-07-13 19:14:57 +02:00
Fabian Dill
bc83dfa9e2 Factorio: allow artillery-shell stack to 10 2021-07-13 05:13:38 +02:00
Fabian Dill
5adbab1d2b fix FactorioClient not applying world gen preset 2021-07-13 03:44:41 +02:00
Fabian Dill
b0c1a7acce Remove remaining ALTTP import from CommonClient.py and fix /missing in FactorioClient.py 2021-07-12 20:07:02 +02:00
Fabian Dill
14cadbf80d Filter events out of datapackage 2021-07-12 18:47:58 +02:00
Fabian Dill
741ab3e45c cleanup some MC 2021-07-12 18:16:03 +02:00
Fabian Dill
f456dba993 newstyle DataPackage. Both versions in merged format for compatibility for now. 2021-07-12 18:05:46 +02:00
Fabian Dill
50a21fbd74 MultiServer: remove message that could never trigger in current protocol 2021-07-12 15:40:31 +02:00
Fabian Dill
768ae584d3 AutoWorld: add hint_blacklist, automatically generated all_names
MultiServer: revamp hint commands with AutoWorld
2021-07-12 15:33:20 +02:00
Fabian Dill
ae32315bf7 add World.location_names 2021-07-12 15:11:48 +02:00
Fabian Dill
9821e05386 Fix: When sending items via send or getitem, only consider items that belong to that world
Fix: Allow cheat-sending items into unconnected slots
2021-07-12 14:35:44 +02:00
Fabian Dill
38bc3d47ad Fix: No longer allow setting starting items from other games 2021-07-12 14:32:39 +02:00
Fabian Dill
4feb3bf411 Fix: No longer allow setting items from other games to local/non-local 2021-07-12 14:32:22 +02:00
Fabian Dill
b53d6c370b AutoWorld: remove Games Enum (AutoWorldRegister.world_types replaces it) 2021-07-12 14:10:49 +02:00
Fabian Dill
31c550d410 AutoWorld: basic Item handling 2021-07-12 13:54:47 +02:00
Fabian Dill
babd809fa6 Factorio: fix certain recipes (like steel-plate) not getting their crafting time adjusted correctly. 2021-07-10 08:01:39 +02:00
Fabian Dill
54177c7064 bump required LttP Client Version 2021-07-10 07:37:56 +02:00
Fabian Dill
4884184e4a fix autolauncher import 2021-07-09 22:47:35 +02:00
Fabian Dill
4c7ef593be Some optimizations 2021-07-09 17:44:24 +02:00
Fabian Dill
2600e9a805 Factorio: add coal liquefaction and kovarex process to progressive processing 2021-07-09 04:49:19 +02:00
Fabian Dill
6ac74f5686 Mystery: mention failing option name 2021-07-09 03:06:16 +02:00
Fabian Dill
172c1789a8 introduce World.topology_present, to indicate if any meaningful path information is available in the world 2021-07-08 11:07:41 +02:00
Fabian Dill
ffc00b7800 Factorio: fix progressive science pack order 2021-07-08 05:09:34 +02:00
Fabian Dill
f44f015cb9 typo in playerSettings.yaml 2021-07-08 00:02:17 +02:00
Fabian Dill
a4dcda16c1 LttP: update SNI handling to v34 2021-07-07 22:53:01 +02:00
Fabian Dill
9db506ef42 Factorio: recipe randomization (rocket-part and science-packs only for now) 2021-07-07 10:14:58 +02:00
Fabian Dill
007f2caecf LttP: SNI keeps its log outside its folder 2021-07-07 03:49:29 +02:00
Fabian Dill
80a5845695 LttP: Move over to SNI 2021-07-07 03:45:27 +02:00
Fabian Dill
1b5525a8c5 Factorio: fix uranium-ore recipe writing to mod 2021-07-06 23:55:30 +02:00
Fabian Dill
22d45b9571 Factorio: remove loaders from recipe list 2021-07-06 13:15:03 +02:00
Fabian Dill
773602169d Factorio: fix some form mistakes that didn't break anything (yet) 2021-07-06 13:06:45 +02:00
Fabian Dill
b650d3d9e6 Factorio: include recipe amounts in Recipe data 2021-07-06 12:35:27 +02:00
Fabian Dill
9b2171088e Factorio: mark all potential rocket recipe ingredients as advancements 2021-07-06 12:33:33 +02:00
Fabian Dill
e58ae58e24 Factorio: add Progressive Option 2021-07-04 22:21:53 +02:00
Fabian Dill
a11e840d36 Cache some MultiWorld properties 2021-07-04 16:44:27 +02:00
Fabian Dill
7d5b20ccfc Remove temporary solution "OptionSets" in favor of AutoWorld's Options 2021-07-04 16:18:21 +02:00
Fabian Dill
2530d28c9d Move Progressive Items to AutoWorld 2021-07-04 15:47:11 +02:00
Fabian Dill
c669bc3e7f Factorio: correctly display player names with spaces and detect desyncs 2021-07-04 15:25:56 +02:00
espeon65536
5943c8975a fixing the tests for bees again 2021-07-03 01:55:47 +00:00
espeon65536
d9f97f6aad Improve option retrieval to fix test crashing 2021-07-03 01:55:47 +00:00
espeon65536
576521229c Added option for MC bee traps 2021-07-03 01:55:47 +00:00
Fabian Dill
ac919f72a8 Factorio: update setup 2021-07-03 00:30:00 +02:00
Fabian Dill
85ce2aff47 Factorio: RIP Bridge File 2021-07-02 20:52:06 +02:00
Fabian Dill
8030db03ad Merge remote-tracking branch 'Espeon/minecraft' into Archipelago_Main 2021-07-02 20:14:34 +02:00
espeon65536
1e90470862 increment MC client version and network_data_package version 2021-07-02 10:12:06 -05:00
espeon65536
e37ca97bde add bee traps 2021-07-02 10:10:35 -05:00
Fabian Dill
97f45f5d96 FactorioClient:
fix reconnect
add auto-world-gen

todo:
move remaining script output bridge to rcon
2021-07-02 01:58:03 +02:00
Fabian Dill
0a64caf4c5 add Factorio world gen settings 2021-07-02 01:29:49 +02:00
Fabian Dill
eee6fc0f10 increment version 2021-07-01 21:18:08 +02:00
Fabian Dill
60972e026b send packed NetworkItem in PrintJSON 2021-06-30 20:57:00 +02:00
Fabian Dill
fd9123610b mimic ItemSend fields of PrintJSON for hints 2021-06-30 20:45:06 +02:00
alwaysintreble
6458653812 Update Text.py 2021-06-29 22:00:06 +00:00
Fabian Dill
328d448ab2 Auto import worlds to trigger registration 2021-06-29 03:49:29 +02:00
Fabian Dill
10aca70879 update Flask-Compress 2021-06-29 03:27:31 +02:00
Fabian Dill
92edc68890 update prompt_toolkit 2021-06-29 03:26:16 +02:00
Fabian Dill
4d4af9d74e WebHost: Guard each Room via file-lock 2021-06-29 03:11:48 +02:00
espeon65536
92c21de61d Merge branch 'main' of https://github.com/ArchipelagoMW/Archipelago into minecraft 2021-06-28 14:45:20 -05:00
espeon65536
f918d34098 un-disabled villages spawning in nether 2021-06-28 14:41:33 -05:00
Fabian Dill
95e0f551e8 LttP Client: restore auto-snes 2021-06-28 01:27:52 +02:00
espeon65536
43e17f82b0 Updated HK test to use autoworld 2021-06-27 23:26:24 +00:00
espeon65536
c7417623e6 Converted Hollow Knight to AutoWorld system 2021-06-27 23:26:24 +00:00
black-sliver
50ed657b0e Allow running MultiMystery.py from source on linux 2021-06-27 18:00:36 +00:00
Fabian Dill
8b5d7028f7 decrement Factorio Client version
(for now, as nobody has that client yet)
2021-06-27 05:18:44 +02:00
Chris Wilson
aa28b3887f Apply Dewin's suggested filter to the Z3 Player Tracker 2021-06-26 22:32:29 -04:00
Fabian Dill
739b563bc2 Move required Client Version to AutoWorld 2021-06-27 00:23:42 +02:00
Fabian Dill
a3a68de341 Factorio: only create events for required technologies 2021-06-26 06:05:38 +02:00
espeon65536
57c761aa7d Made AdvancementGoal a Range again
also fixed the awful rules formatting
2021-06-25 20:15:07 -05:00
espeon65536
75891b2d38 fix tests again 2021-06-25 19:59:44 -05:00
espeon65536
44943f6bf8 Merge branch 'main' of https://github.com/ArchipelagoMW/Archipelago into minecraft 2021-06-25 19:44:15 -05:00
Fabian Dill
5fdcd2d7c7 Factorio: locale formatting fixes 2021-06-26 00:54:27 +02:00
Fabian Dill
43e3c84635 fix the Hollow Knight Unittest. Yes, the one test. 2021-06-25 23:39:22 +02:00
Fabian Dill
7f8bb10fc5 Move Factorio, Hollow Knight and Minecraft Options into AutoWorld 2021-06-25 23:32:13 +02:00
Chris Wilson
cc85edafc4 Add "Host Game" button back to the website landing page 2021-06-25 16:59:59 -04:00
Fabian Dill
878ab33039 Factorio: fix incomplete crafting category copy 2021-06-25 22:09:09 +02:00
Fabian Dill
4b495557cd Tracker: sort numbers and fractions numerically 2021-06-25 21:15:54 +02:00
Fabian Dill
d1fd1cd788 Tracker: sort Last Activity numerically, instead of text. 2021-06-25 21:05:44 +02:00
Fabian Dill
f870bb3fad MultiServer:
implement a hint recheck that triggers on get_save()
Still torn if I want a single hint list per team and filter on demand, or have filtered lists and re_check on demand.
2021-06-25 21:04:37 +02:00
espeon65536
719f9d7d48 Monsters Hunted made a hard-postgame advancement, so both flags must be set for it to be not junkfilled 2021-06-25 13:57:09 -05:00
espeon65536
fd811bfd1b fix minecraft tests 2021-06-25 13:02:45 -05:00
espeon65536
6837cd2917 Require the ability to respawn the dragon for all dragon-related advancements 2021-06-25 12:43:59 -05:00
espeon65536
f778a263a7 Forbid villages from spawning in the Nether 2021-06-25 12:37:06 -05:00
Fabian Dill
007f66d86e CommonClient.py: fix generic error 2021-06-25 07:25:03 +02:00
Fabian Dill
0e32393acb FactorioClient: only await awaitable tasks 2021-06-25 07:11:06 +02:00
Fabian Dill
20729242f9 allow nested dictionaries in dict_to_lua 2021-06-25 01:55:58 +02:00
Fabian Dill
91655a855d Factorio: exclude science packs and rocket-part from free samples 2021-06-25 01:31:48 +02:00
Fabian Dill
9f2f343f76 Factorio: always display static nodes with full info 2021-06-24 23:51:42 +02:00
Fabian Dill
6c1d164330 LttP: set non-native items to Power Star 2021-06-22 06:25:19 +02:00
Fabian Dill
937fee9019 Factorio: fix locale file formatting 2021-06-22 02:00:35 +02:00
Fabian Dill
023a798ac1 Factorio: refactor visibility option into tech_tree_information
set vanilla technologies to be hidden instead of disabled
          fix advancement icon still showing when no information in tech was supposed to be given
2021-06-21 22:25:49 +02:00
Fabian Dill
07d61f6d47 fix playerSettings.yaml post-merge 2021-06-21 02:51:54 +02:00
Fabian Dill
304f63aedf Merge branch 'espeon' into Archipelago_Main
# Conflicts:
#	playerSettings.yaml
2021-06-21 02:49:06 +02:00
Fabian Dill
30190f373a send /received output to self.output 2021-06-21 02:14:25 +02:00
espeon65536
b51b094cc1 Added HMG to playerSettings 2021-06-18 23:45:03 -05:00
Fabian Dill
f4a2f344a7 format MultiServer.py 2021-06-19 03:03:06 +02:00
Fabian Dill
1e7214a86b fix required plando options triggering on empty string 2021-06-19 01:00:41 +02:00
Fabian Dill
f8fd8b3585 Factorio: add toggle to disable imported blueprints 2021-06-19 01:00:21 +02:00
CaitSith2
644d62c915 Ignore Factorio AP savegame file. 2021-06-18 14:23:55 -07:00
Fabian Dill
741ec36ee1 all requires to be modified by trigggers and linked options 2021-06-18 23:17:12 +02:00
Fabian Dill
a08d7bb1b2 Settings: add requires 2021-06-18 22:15:54 +02:00
espeon65536
16ae77ca1c Plandoing structures causes them to output in the spoiler log 2021-06-16 20:24:36 -05:00
Fabian Dill
a5bf3a8407 Factorio: remove option to turn off random_tech_ingredients 2021-06-16 23:41:43 +02:00
espeon65536
cd0306d513 additional import cleanup 2021-06-16 01:16:19 -05:00
espeon65536
b29d0b8276 Fixed some options in the Minecraft section of playerSettings 2021-06-15 22:27:51 -05:00
Chris Wilson
3ee88fd8fe Merge branch 'main' of https://github.com/ArchipelagoMW/Archipelago into main 2021-06-15 21:18:24 -04:00
Chris Wilson
bc9c93b180 Improvements to the WebHost
- Improved routing structure
- Improved style imports across site
- Added placeholder player-settings pages for Factorio and Minecraft
2021-06-15 21:18:14 -04:00
espeon65536
e49d10ab22 Clean up imports 2021-06-15 18:22:12 -05:00
espeon65536
059946d59e Shifted Minecraft to the new AutoWorld system 2021-06-15 18:15:05 -05:00
espeon65536
6211760922 Merge branch 'main' of https://github.com/ArchipelagoMW/Archipelago into minecraft 2021-06-15 16:58:28 -05:00
Fabian Dill
167958c002 fix legacy weapons trigger 2021-06-15 23:23:39 +02:00
Fabian Dill
8b16ffb629 fix LttP rom options 2021-06-15 21:15:57 +02:00
Fabian Dill
b5193162bf update playerSettings.yaml 2021-06-15 20:26:31 +02:00
Fabian Dill
bc34c237b6 move minecraft plando connections into minecraft 2021-06-15 16:34:36 +02:00
Fabian Dill
d9824d26d2 make Factorio rocket silo a static (and therefore local) node 2021-06-15 15:32:40 +02:00
Fabian Dill
8d08b55e69 move item referencing options into their game's category 2021-06-15 15:10:31 +02:00
Fabian Dill
503c844971 categorize game options 2021-06-15 14:11:46 +02:00
espeon65536
deff356910 Added HMG check to all checks for OWG and NL 2021-06-14 22:10:26 -05:00
Chris Wilson
883ebbf267 Updating WebHost structure 2021-06-14 22:27:43 -04:00
Fabian Dill
cd45116dce dynamify games listing 2021-06-15 02:35:40 +02:00
Chris Wilson
d80362c4b8 Fix 404 pages 2021-06-14 20:20:23 -04:00
Chris Wilson
384e06d6fe Subdirectory pages currently 404. I'll look into this later 2021-06-14 20:18:40 -04:00
Fabian Dill
e6f44a70d0 use flask convention for template fetching 2021-06-15 01:51:40 +02:00
Chris Wilson
0ca90ee7e8 Add subdirectory handling for zelda3, factorio, and minecraft. Add generic 404 page. 2021-06-14 19:35:02 -04:00
Fabian Dill
59a56c803a Log which player's plando has caused a placement failure 2021-06-14 23:42:13 +02:00
Fabian Dill
1e0b44bdc5 set Triforce Piece Defaults 2021-06-14 23:41:47 +02:00
Fabian Dill
2f3296bada remove _ and - from pedestal hint texts 2021-06-14 02:23:41 +02:00
Fabian Dill
434d8e0977 remove _ and - from item hint texts 2021-06-14 02:20:13 +02:00
Fabian Dill
0a89eaaf62 update trigger result key before trigger 2021-06-14 02:14:02 +02:00
Fabian Dill
cea2f81b86 remove IRH special rule now that it's a 1/1 triforce piece hunt 2021-06-13 07:57:34 +02:00
Fabian Dill
86b612f3b5 implement random-middle 2021-06-12 21:05:45 +02:00
espeon65536
d425e5eb6a disable GT junk fill in hybrid 2021-06-12 13:11:14 -05:00
Fabian Dill
183fd33f3f MultiMystery linux compatibility 2021-06-12 16:10:56 +02:00
Chris Wilson
8c82d3e747 Added a page to describe the games currently supported by AP 2021-06-12 02:49:36 -04:00
Chris Wilson
7b495f3d81 Website landing page preliminary update 2021-06-11 20:22:47 -04:00
Fabian Dill
3ea7f1cb03 Factorio Funnels: only sort current funnel, not all funnels 2021-06-11 20:18:28 +02:00
Fabian Dill
2a13fe05c6 fix import error for Hollow Knight 2021-06-11 18:05:49 +02:00
Fabian Dill
2c4c899179 move more Factorio stuff around 2021-06-11 18:02:48 +02:00
Fabian Dill
760fb32016 fix Factorio Recipe Time randomization not being deterministic 2021-06-11 14:47:13 +02:00
Fabian Dill
278f40471b fix open_pyramid default 2021-06-11 14:26:12 +02:00
Fabian Dill
20ca09c730 remove test modules 2021-06-11 14:23:59 +02:00
Fabian Dill
568a71cdbe Start implementing object oriented scaffold for world types
(There's still a lot of work ahead, such as:
registering locations and items to the World, as well as methods to create_item_from_name()
many more method names for various stages
embedding Options into the world type
and many more...)
2021-06-11 14:22:44 +02:00
Fabian Dill
753a5f7cb2 Merge branch 'split' into Archipelago_Main
# Conflicts:
#	Main.py
2021-06-11 13:27:28 +02:00
espeon65536
96e13786cd Fixed broken mirrorless swamp rules 2021-06-10 18:10:25 -05:00
espeon65536
5d6592f296 Merge branch 'main' of https://github.com/espeon65536/Archipelago into main 2021-06-09 11:00:33 -05:00
Fabian Dill
534dd331b9 document item locality options properly 2021-06-09 10:13:18 +02:00
espeon65536
b3b56fcafd removed unnecessary import 2021-06-08 19:32:27 -05:00
espeon65536
671fd50cfb Moved the add_rule for mirrorless swamp to speed it up on invalid entrance shuffle type 2021-06-08 19:19:11 -05:00
espeon65536
eaf19643a9 Cleaned up code for assigning dungeon reentry rules 2021-06-08 19:18:28 -05:00
espeon65536
a582a3781b Moved the addition of HMG-specific connections to fix crossed ER 2021-06-08 18:32:22 -05:00
espeon65536
e0d90e0b21 Properly accounting for agatower not freely opening for dungeon reentry 2021-06-08 18:17:21 -05:00
espeon65536
a73189338c Fixed full ER HMG not ignoring pearl requirements on entrances 2021-06-08 18:15:47 -05:00
Fabian Dill
1e414dd370 fix tests 2021-06-08 22:14:56 +02:00
Fabian Dill
5ea03c71c0 start moving some alttp options over to the new system 2021-06-08 21:58:11 +02:00
espeon65536
d7a46f089e added get_option_name to Range option for spoiler generation 2021-06-08 08:59:06 -05:00
espeon65536
6e33181f05 Changed advancement_goal to a Range option 2021-06-08 08:58:16 -05:00
Fabian Dill
622f8f8158 always check legal range for Range 2021-06-08 15:39:34 +02:00
Fabian Dill
821b0f0f92 document random-high and random-low 2021-06-08 14:56:41 +02:00
Fabian Dill
471b217e99 add random-high and random-low to Range Options 2021-06-08 14:48:00 +02:00
Fabian Dill
adda0eff4a implement Range option type 2021-06-08 14:15:23 +02:00
espeon65536
2001ca6566 Fixed the check on dungeon reentry not working properly 2021-06-08 01:22:16 -05:00
espeon65536
b9a783d7d7 Fixed open connections breaking non-HMG seed generation 2021-06-08 00:50:28 -05:00
espeon65536
eb9ee9f41e Hybrid Major Glitches connections and logic 2021-06-07 20:19:03 -05:00
espeon65536
fae14ad283 Mystery.py correctly recognizes HMG as an option 2021-06-07 19:34:00 -05:00
Fabian Dill
4b5ac3f926 Update VC Redist 2021-06-07 11:53:33 +02:00
Fabian Dill
72e5acfb86 Factorio recipe time: adjust triangular mode 2021-06-07 11:32:39 +02:00
espeon65536
16c6e17a49 Initial handling of hybrid glitch logic outside of UnderworldGlitchRules 2021-06-07 01:19:27 -05:00
espeon65536
ac31671914 initial hybridmg logic file commit 2021-06-07 00:38:30 -05:00
Fabian Dill
4b283242fe FactorioClient: remove duplicate log 2021-06-06 23:59:15 +02:00
Fabian Dill
353ea0fbbe encode correct color 2021-06-06 23:44:04 +02:00
Fabian Dill
fc941f55ef FactorioClientGUI.py: disable multitouch emulation on mouse 2021-06-06 23:23:06 +02:00
Fabian Dill
12600a8cbd FactorioClientGUI.py: fix frozen logging 2021-06-06 23:13:19 +02:00
Fabian Dill
33fa9542e0 move FactorioJSONtoTextParser 2021-06-06 22:49:37 +02:00
Fabian Dill
d872ea32af Update various links 2021-06-06 22:14:13 +02:00
Fabian Dill
46bb2d1367 Factorio: add chaos recipe time and use random.triangular distribution 2021-06-06 21:38:53 +02:00
Fabian Dill
403ddd603f Factorio: implement random recipe times 2021-06-06 21:11:58 +02:00
Fabian Dill
7907838c24 Factorio: Revamp Tech Tree Layouts 2021-06-06 20:26:40 +02:00
Fabian Dill
15bd79186a remove player_name feature in MultiMystery
MultiMystery is slated to be integrated into Mystery and the auto-launch feature is not maintainable for a growing games list
2021-06-06 18:12:19 +02:00
Fabian Dill
4555b77204 FactorioClient.py formatting 2021-06-06 17:50:48 +02:00
Fabian Dill
dd3c612dec Factorio: Colored ingame text relay for AP texts 2021-06-06 17:41:06 +02:00
Fabian Dill
09b6698de8 revamp some spoiler log conditions 2021-06-06 17:13:34 +02:00
Fabian Dill
27ee156706 tiny cleanup 2021-06-06 17:10:49 +02:00
espeon65536
48c3d1fa4a Added campfire for Sticky Situation, by popular demand 2021-06-06 15:10:45 +00:00
espeon65536
286254c5cd require end crystals for Free the End, since it's possible to kill the dragon with beds and not receive the advancement 2021-06-06 15:10:45 +00:00
espeon65536
82cd51f5f4 structure plando for Minecraft 2021-06-06 15:10:45 +00:00
espeon65536
08bf993146 only write Medallions section to spoiler log if there is an ALttP world 2021-06-06 15:10:45 +00:00
espeon65536
a55bcae3ec Minecraft logic improvements
- Very Very Frightening now properly accounts for getting a villager into the overworld by curing a zombie villager
- Hot Tourist Destinations no longer requires striders, since no one was using them anyway
- Saddles are now also obtainable from raids by killing a ravager (100% drop rate)
2021-06-06 15:10:45 +00:00
Fabian Dill
607a14e921 FactorioClient: log kivy exceptions 2021-06-06 16:09:00 +02:00
Fabian Dill
c71387ad00 Factorio: fix single-player static node placement 2021-06-06 16:08:17 +02:00
Fabian Dill
c095c28618 Split requirements into world types, automatically discover and resolve them. 2021-06-06 15:30:20 +02:00
Fabian Dill
cae1188ff8 Allow ModuleUpdate to use multiple requirements files, no longer need to care about naming, and use conventional requirement parsing. Also add WebHost to it. 2021-06-06 15:11:17 +02:00
CaitSith2
7e599c51f8 Make defaults for missing options in host.yaml consistent. 2021-06-05 21:15:54 -07:00
CaitSith2
6ccb9d2dc2 Fix adjuster reference 2021-06-05 13:58:59 -07:00
Fabian Dill
1d00ed463e fix updated name aliases for tracker 2021-06-05 03:54:16 +02:00
Fabian Dill
c99054e479 add /build_factorio to gitignore 2021-06-04 01:00:03 +02:00
Fabian Dill
85a9e0d0bc write Factorio options to spoiler 2021-06-04 00:29:59 +02:00
Fabian Dill
8b4ea3c80c fix max progressive item icon in per-player tracker 2021-06-03 01:02:31 +02:00
Fabian Dill
30dec34b72 update websockets 2021-06-02 04:40:43 +02:00
Fabian Dill
a3d2df7c45 Merge branch 'factorio_gui_client' into Archipelago_Main 2021-06-02 04:31:39 +02:00
Fabian Dill
034f338f45 set default hint cost to 10 2021-06-01 04:28:15 +02:00
Fabian Dill
1d84346705 Factorio: Don't trigger bridge file on receiving a technology from server 2021-05-29 20:02:36 +02:00
Fabian Dill
6e916ebd45 bake correct minimum version for Factorio into multidata 2021-05-29 06:23:35 +02:00
Fabian Dill
a993bed8dc move factorio_client_setup.py into setup.py 2021-05-27 12:26:08 +02:00
Fabian Dill
aa6f65ee1f Prevent logical lockout from Pedestal/Pyramid Fairy in ice rod hunt 2021-05-27 12:14:20 +02:00
Fabian Dill
573931930c remove debugging helper 2021-05-25 01:06:15 +02:00
Fabian Dill
252bb69808 FactorioClient: Read Bridge file after a server log indicates that the file was written 2021-05-25 01:03:04 +02:00
Fabian Dill
0175c8ab8a move FactorioClient log to logs folder 2021-05-24 16:09:10 +02:00
Fabian Dill
f78bb2078d make sure Factorio subprocess is terminated properly 2021-05-24 13:51:27 +02:00
Fabian Dill
bc028a63cd first version of a Factorio Graphical Client 2021-05-24 12:49:01 +02:00
Fabian Dill
4b04f2b918 update icons 2021-05-24 12:48:18 +02:00
Fabian Dill
887a3b0922 update flask and jinja 2021-05-24 05:03:45 +02:00
Fabian Dill
3df78fa387 Factorio add ap_unimportant.png 2021-05-23 20:13:19 +02:00
Fabian Dill
c36ac5baba consider the ability to craft a rocket-silo for factorio completion 2021-05-22 21:13:53 +02:00
Fabian Dill
d8e33fe596 Factorio: Differentiate advancement items. 2021-05-22 10:46:27 +02:00
Fabian Dill
80b7e2e188 Factorio: Build logic for rocket launch, allow beatable only to work correctly
Convert Science requirements to Event of "automate <pack>"
2021-05-22 10:06:21 +02:00
Fabian Dill
14b430a168 Factorio: simplify resulting data-final-fixes.lua after templating a bit. 2021-05-22 08:08:37 +02:00
Fabian Dill
22aa4cbb9f Factorio: Fix Rocket Launch event getting encoded into mod 2021-05-22 07:54:12 +02:00
Fabian Dill
71bb5b850e set correct player ID for Factorio Victory 2021-05-22 07:06:09 +02:00
Fabian Dill
066c830a43 Fix LttP progressive starting Items not writing to ROM 2021-05-22 06:27:22 +02:00
Fabian Dill
760107becf remove no longer needed imports 2021-05-22 03:00:24 +02:00
Fabian Dill
8dad49e385 assign generic tracker's checked locations to correct player 2021-05-20 01:22:18 +02:00
Fabian Dill
518e5db55b use item_name filter for generic tracker 2021-05-19 21:57:10 +02:00
Fabian Dill
31a3c1cf33 Add a generic fallback tracker for all games 2021-05-19 21:55:18 +02:00
Fabian Dill
e1b4975a11 Add Crafting Machine awareness to Factorio logic
(should have no effect on vanilla, mostly for modded gameplay)
2021-05-19 06:52:53 +02:00
Fabian Dill
f8a5e8bfc7 add Factorio Victory Event 2021-05-19 05:33:44 +02:00
Fabian Dill
a656ad5cd2 potential fix for rcon timing issue 2021-05-18 20:45:56 +02:00
Fabian Dill
109eb5b9dc start of split 2021-05-13 01:34:59 +02:00
676 changed files with 94248 additions and 11797 deletions

View File

@@ -20,7 +20,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
python ModuleUpdate.py --yes --force
- name: Unittests
run: |
pytest test

10
.gitignore vendored
View File

@@ -4,9 +4,13 @@
*_Spoiler.txt
*.bmbp
*.apbp
*.apmc
*.apz5
*.pyc
*.pyd
*.sfc
*.z64
*.n64
*.wixobj
*.lck
*.db3
@@ -37,6 +41,8 @@ success.txt
output/
Output Logs/
/factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -142,4 +148,6 @@ dmypy.json
.pytype/
# Cython debug symbols
cython_debug/
cython_debug/
Archipelago.zip

File diff suppressed because it is too large Load Diff

View File

@@ -3,24 +3,20 @@ import logging
import typing
import asyncio
import urllib.parse
import sys
import prompt_toolkit
import websockets
from prompt_toolkit.patch_stdout import patch_stdout
import Utils
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, color, ClientStatus
from Utils import Version
# logging note:
# logging.* gets send to only the text console, logger.* gets send to the WebUI as well, if it's initialized.
from worlds import network_data_package
from worlds.alttp import Items, Regions
from worlds import network_data_package, AutoWorldRegister
logger = logging.getLogger("Client")
gui_enabled = Utils.is_frozen() or "--nogui" not in sys.argv
class ClientCommandProcessor(CommandProcessor):
def __init__(self, ctx: CommonContext):
self.ctx = ctx
@@ -47,19 +43,16 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_received(self) -> bool:
"""List all received items"""
logger.info('Received items:')
logger.info(f'{len(self.ctx.items_received)} received items:')
for index, item in enumerate(self.ctx.items_received, 1):
logging.info('%s from %s (%s) (%d/%d in list)' % (
color(self.ctx.item_name_getter(item.item), 'red', 'bold'),
color(self.ctx.player_names[item.player], 'yellow'),
self.ctx.location_name_getter(item.location), index, len(self.ctx.items_received)))
self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
return True
def _cmd_missing(self) -> bool:
"""List all missing location checks, from your local game state"""
count = 0
checked_count = 0
for location, location_id in Regions.lookup_name_to_id.items():
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
if location_id < 0:
continue
if location_id not in self.ctx.locations_checked:
@@ -78,8 +71,6 @@ class ClientCommandProcessor(CommandProcessor):
self.output("No missing location checks found.")
return True
def _cmd_ready(self):
self.ctx.ready = not self.ctx.ready
if self.ctx.ready:
@@ -93,11 +84,15 @@ class ClientCommandProcessor(CommandProcessor):
def default(self, raw: str):
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]))
class CommonContext():
starting_reconnect_delay = 5
current_reconnect_delay = starting_reconnect_delay
command_processor = ClientCommandProcessor
def __init__(self, server_address, password, found_items: bool):
game: None
ui: None
def __init__(self, server_address, password):
# server state
self.server_address = server_address
self.password = password
@@ -108,7 +103,6 @@ class CommonContext():
# own state
self.finished_game = False
self.ready = False
self.found_items = found_items
self.team = None
self.slot = None
self.auth = None
@@ -149,16 +143,17 @@ class CommonContext():
if local_package and local_package["version"] > network_data_package["version"]:
data_package: dict = local_package
elif network: # check if data from server is newer
for key, value in data_package.items():
if type(value) == dict: # convert to int keys
data_package[key] = \
{int(subkey) if subkey.isdigit() else subkey: subvalue for subkey, subvalue in value.items()}
if data_package["version"] > network_data_package["version"]:
Utils.persistent_store("datapackage", "latest", network_data_package)
item_lookup: dict = data_package["lookup_any_item_id_to_name"]
locations_lookup: dict = data_package["lookup_any_location_id_to_name"]
item_lookup: dict = {}
locations_lookup: dict = {}
for game, gamedata in data_package["games"].items():
for item_name, item_id in gamedata["item_name_to_id"].items():
item_lookup[item_id] = item_name
for location_name, location_id in gamedata["location_name_to_id"].items():
locations_lookup[location_id] = location_name
def get_item_name_from_id(code: int):
return item_lookup.get(code, f'Unknown item (ID:{code})')
@@ -205,7 +200,7 @@ class CommonContext():
self.input_requests += 1
return await self.input_queue.get()
async def connect(self, address= None):
async def connect(self, address=None):
await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address))
@@ -213,9 +208,15 @@ class CommonContext():
logger.info(args["text"])
def on_print_json(self, args: dict):
if not self.found_items and args.get("type", None) == "ItemSend" and args["receiving"] == args["sending"]:
pass # don't want info on other player's local pickups.
logger.info(self.jsontotextparser(args["data"]))
if self.ui:
self.ui.print_json(args["data"])
else:
text = self.jsontotextparser(args["data"])
logger.info(text)
def on_package(self, cmd: str, args: dict):
"""For custom package handling in subclasses."""
pass
async def server_loop(ctx: CommonContext, address=None):
@@ -295,8 +296,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info('Password required')
logger.info(f"Forfeit setting: {args['forfeit_mode']}")
logger.info(f"Remaining setting: {args['remaining_mode']}")
logger.info(f"A !hint costs {args['hint_cost']}% of checks points and you get {args['location_check_points']}"
f" for each location checked. Use !hint for more information.")
logger.info(
f"A !hint costs {args['hint_cost']}% of your total location count as points"
f" and you get {args['location_check_points']}"
f" for each location checked. Use !hint for more information.")
ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points'])
ctx.forfeit_mode = args['forfeit_mode']
@@ -334,9 +337,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.error('Invalid password')
ctx.password = None
await ctx.server_auth(True)
else:
elif errors:
raise Exception("Unknown connection errors: " + str(errors))
raise Exception('Connection refused by the multiworld host, no reason provided')
else:
raise Exception('Connection refused by the multiworld host, no reason provided')
elif cmd == 'Connected':
ctx.team = args["team"]
@@ -395,12 +399,17 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == 'PrintJSON':
ctx.on_print_json(args)
elif cmd == 'InvalidArguments':
logger.warning(f"Invalid Arguments: {args['text']}")
elif cmd == 'InvalidPacket':
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
elif cmd == "Bounced":
pass
else:
logger.debug(f"unknown command {cmd}")
ctx.on_package(cmd, args)
async def console_loop(ctx: CommonContext):
import sys
@@ -420,4 +429,4 @@ async def console_loop(ctx: CommonContext):
if input_text:
commandprocessor(input_text)
except Exception as e:
logging.exception(e)
logger.exception(e)

View File

@@ -1,359 +0,0 @@
Hint description:
Hints will appear in the following ratios across the 15 telepathic tiles that have hints and the five storyteller locations:
4 hints for inconvenient entrances.
4 hints for random entrances (this can by coincidence pick inconvenient entrances that aren't used for the first set of hints).
3 hints for inconvenient item locations.
5 hints for valuable items.
4 junk hints.
In the vanilla, dungeonssimple, and dungeonsfull shuffles, the following ratios will be used instead:
5 hints for inconvenient item locations.
8 hints for valuable items.
7 junk hints.
In the simple, restricted, and restricted legacy shuffles, these are the ratios:
2 hints for inconvenient entrances.
1 hint for an inconvenient dungeon entrance.
4 hints for random entrances (this can by coincidence pick inconvenient entrances that aren't used for the first set of hints).
3 hints for inconvenient item locations.
5 hints for valuable items.
5 junk hints.
These hints will use the following format:
Entrance hints go "[Entrance on overworld] leads to [interior]".
Inconvenient item locations are a little more custom but amount to "[Location] has [item name]". The item name is literal and will specify which dungeon the dungeon specific items hail from (small key/big key/map/compass).
The valuable items are of the format "[item name] can be found [location]". The item name is again literal, and the location text is taken from Ganon's silver arrow hints. Note that the way it works is that every unique valuable item that exists is considered independently, and you won't get multiple hints for the EXACT same item (so you can only get one hint for Progressive Sword no matter how many swords exist in the seed, but if swords are not progressive, you could get hints for both Master Sword and Tempered Sword). More copies of an item existing does not increase the probability of getting a hint for that particular item (you are equally likely to get a hint for a Progressive Sword as for the Hammer). Unlike the IR, item names are never obfuscated by "something unique", and there is no special bias for hints for GT Big Key or Pegasus Boots.
Hint Locations:
Eastern Palace room before Big Chest
Desert Palace bonk torch room
Tower of Hera entrance room
Tower of Hera Big Chest room
Castle Tower after dark rooms
Palace of Darkness before Bow section
Swamp Palace entryway
Thieves' Town upstairs
Ice Palace entrance
Ice Palace after first drop
Ice Palace tall ice floor room
Misery Mire cutscene room
Turtle Rock entrance
Spectacle Rock cave
Spiky Hint cave
PoD Bdlg NPC
Near PoD Storyteller (bug near bomb wall)
Dark Sanctuary Storyteller (long room with tables)
Near Mire Storyteller (feather duster in winding cave)
SE DW Storyteller (owl in winding cave)
Inconvenient entrance list:
Skull Woods Final
Ice Palace
Misery Mire
Turtle Rock
Ganon's Tower
Mimic Ledge
SW DM Foothills Cave (mirror from upper Bumper ledge)
Hammer Pegs (near purple chest)
Super Bomb cracked wall
Inconvenient location list:
Swamp left (two chests)
Mire left (two chests)
Hera basement
Eastern Palace Big Key chest (protected by anti-fairies)
Thieves' Town Big Chest
Ice Palace Big Chest
Ganon's Tower Big Chest
Purple Chest
Spike Cave
Magic Bat
Sahasrahla (Green Pendant)
In the vanilla, dungeonssimple, and dungeonsfull shuffles, the following two locations are added to the inconvenient locations list:
Graveyard Cave
Mimic Cave
Valuable Items are simply all items that are shown on the pause subscreen (Y, B, or A sections) minus Silver Arrows and plus Triforce Pieces, Magic Upgrades (1/2 or 1/4), and the Single Arrow. If key shuffle is being used, you can additionally get hints for Small Keys or Big Keys but not hints for Maps or Compasses.
While the exact verbage of location names and item names can be found in the source code, here's a copy for reference:
Overworld Entrance naming:
Turtle Rock: Turtle Rock Main
Misery Mire: Misery Mire
Ice Palace: Ice Palace
Skull Woods Final Section: The back of Skull Woods
Death Mountain Return Cave (West): The SW DM Foothills Cave
Mimic Cave: Mimic Ledge
Dark World Hammer Peg Cave: The rows of pegs
Pyramid Fairy: The crack on the pyramid
Eastern Palace: Eastern Palace
Elder House (East): Elder House
Elder House (West): Elder House
Two Brothers House (East): Eastern Quarreling Brothers' house
Old Man Cave (West): The lower DM entrance
Hyrule Castle Entrance (South): The ground level castle door
Thieves Town: Thieves' Town
Bumper Cave (Bottom): The lower Bumper Cave
Swamp Palace: Swamp Palace
Dark Death Mountain Ledge (West): The East dark DM connector ledge
Dark Death Mountain Ledge (East): The East dark DM connector ledge
Superbunny Cave (Top): The summit of dark DM cave
Superbunny Cave (Bottom): The base of east dark DM
Hookshot Cave: The rock on dark DM
Desert Palace Entrance (South): The book sealed passage
Tower of Hera: The Tower of Hera
Two Brothers House (West): The door near the race game
Old Man Cave (East): The SW-most cave on west DM
Old Man House (Bottom): A cave with a door on west DM
Old Man House (Top): The eastmost cave on west DM
Death Mountain Return Cave (East): The westmost cave on west DM
Spectacle Rock Cave Peak: The highest cave on west DM
Spectacle Rock Cave: The right ledge on west DM
Spectacle Rock Cave (Bottom): The left ledge on west DM
Paradox Cave (Bottom): The right paired cave on east DM
Paradox Cave (Middle): The southmost cave on east DM
Paradox Cave (Top): The east DM summit cave
Fairy Ascension Cave (Bottom): The east DM cave behind rocks
Fairy Ascension Cave (Top): The central ledge on east DM
Spiral Cave: The left ledge on east DM
Spiral Cave (Bottom): The SWmost cave on east DM
Palace of Darkness: Palace of Darkness
Hyrule Castle Entrance (West): The left castle door
Hyrule Castle Entrance (East): The right castle door
Agahnims Tower: The sealed castle door
Desert Palace Entrance (West): The westmost building in the desert
Desert Palace Entrance (North): The northmost cave in the desert
Blinds Hideout: Blind's old house
Lake Hylia Fairy: A cave NE of Lake Hylia
Light Hype Fairy: The cave south of your house
Desert Fairy: The cave near the desert
Chicken House: The chicken lady's house
Aginahs Cave: The open desert cave
Sahasrahlas Hut: The house near armos
Cave Shop (Lake Hylia): The cave NW Lake Hylia
Blacksmiths Hut: The old smithery
Sick Kids House: The central house in Kakariko
Lost Woods Gamble: A tree trunk door
Fortune Teller (Light): A building NE of Kakariko
Snitch Lady (East): A house guarded by a snitch
Snitch Lady (West): A house guarded by a snitch
Bush Covered House: A house with an uncut lawn
Tavern (Front): A building with a backdoor
Light World Bomb Hut: A Kakariko building with no door
Kakariko Shop: The old Kakariko shop
Mini Moldorm Cave: The cave south of Lake Hylia
Long Fairy Cave: The eastmost portal cave
Good Bee Cave: The open cave SE Lake Hylia
20 Rupee Cave: The rock SE Lake Hylia
50 Rupee Cave: The rock near the desert
Ice Rod Cave: The sealed cave SE Lake Hylia
Library: The old library
Potion Shop: The witch's building
Dam: The old dam
Lumberjack House: The lumberjack house
Lake Hylia Fortune Teller: The building NW Lake Hylia
Kakariko Gamble Game: The old Kakariko gambling den
Waterfall of Wishing: Going behind the waterfall
Capacity Upgrade: The cave on the island
Bonk Rock Cave: The rock pile near Sanctuary
Graveyard Cave: The graveyard ledge
Checkerboard Cave: The NE desert ledge
Cave 45: The ledge south of haunted grove
Kings Grave: The northeastmost grave
Bonk Fairy (Light): The rock pile near your home
Hookshot Fairy: A cave on east DM
Bonk Fairy (Dark): The rock pile near the old bomb shop
Dark Sanctuary Hint: The dark sanctuary cave
Dark Lake Hylia Fairy: The cave NE dark Lake Hylia
C-Shaped House: The NE house in Village of Outcasts
Big Bomb Shop: The old bomb shop
Dark Death Mountain Fairy: The SW cave on dark DM
Dark Lake Hylia Shop: The building NW dark Lake Hylia
Dark World Shop: The hammer sealed building
Red Shield Shop: The fenced in building
Mire Shed: The western hut in the mire
East Dark World Hint: The dark cave near the eastmost portal
Dark Desert Hint: The cave east of the mire
Spike Cave: The ledge cave on west dark DM
Palace of Darkness Hint: The building south of Kiki
Dark Lake Hylia Ledge Spike Cave: The rock SE dark Lake Hylia
Cave Shop (Dark Death Mountain): The base of east dark DM
Dark World Potion Shop: The building near the catfish
Archery Game: The old archery game
Dark World Lumberjack Shop: The northmost Dark World building
Hype Cave: The cave south of the old bomb shop
Brewery: The Village of Outcasts building with no door
Dark Lake Hylia Ledge Hint: The open cave SE dark Lake Hylia
Chest Game: The westmost building in the Village of Outcasts
Dark Desert Fairy: The eastern hut in the mire
Dark Lake Hylia Ledge Fairy: The sealed cave SE dark Lake Hylia
Fortune Teller (Dark): The building NE the Village of Outcasts
Sanctuary: Sanctuary
Lumberjack Tree Cave: The cave Behind Lumberjacks
Lost Woods Hideout Stump: The stump in Lost Woods
North Fairy Cave: The cave East of Graveyard
Bat Cave Cave: The cave in eastern Kakariko
Kakariko Well Cave: The cave in northern Kakariko
Hyrule Castle Secret Entrance Stairs: The tunnel near the castle
Skull Woods First Section Door: The southeastmost skull
Skull Woods Second Section Door (East): The central open skull
Skull Woods Second Section Door (West): The westmost open skull
Desert Palace Entrance (East): The eastern building in the desert
Turtle Rock Isolated Ledge Entrance: The isolated ledge on east dark DM
Bumper Cave (Top): The upper Bumper Cave
Hookshot Cave Back Entrance: The stairs on the floating island
Destination Entrance Naming:
Hyrule Castle: Hyrule Castle (all three entrances)
Eastern Palace: Eastern Palace
Desert Palace: Desert Palace (all four entrances, including final)
Tower of Hera: Tower of Hera
Palace of Darkness: Palace of Darkness
Swamp Palace: Swamp Palace
Skull Woods: Skull Woods (any entrance including final)
Thieves' Town: Thieves' Town
Ice Palace: Ice Palace
Misery Mire: Misery Mire
Turtle Rock: Turtle Rock (all four entrances)
Ganon's Tower: Ganon's Tower
Castle Tower: Agahnim's Tower
A connector: Paradox Cave, Spectacle Rock Cave, Hookshot Cave, Superbunny Cave, Spiral Cave, Old Man Fetch Cave, Old Man House, Elder House, Quarreling Brothers' House, Bumper Cave, DM Fairy Ascent Cave, DM Exit Cave
A bounty of five items: Mini-moldorm cave, Hype Cave, Blind's Hideout
Sahasrahla: Sahasrahla
A cave with two items: Mire hut, Waterfall Fairy, Pyramid Fairy
A fairy fountain: Any healer fairy cave, either bonk cave with four fairies, the "long fairy" cave
A common shop: Any shop that sells bombs by default
The rare shop: The shop that sells the Red Shield by default
The potion shop: Potion Shop
The bomb shop: Bomb Shop
A fortune teller: Any of the three fortune tellers
A house with a chest: Chicken Lady's house, C-House, Brewery
A cave with an item: Checkerboard cave, Hammer Pegs cave, Cave 45, Graveyard Ledge cave
A cave with a chest: Sanc Bonk Rock Cave, Cape Grave Cave, Ice Rod Cave, Aginah's Cave
The dam: Watergate
The sick kid: Sick Kid
The library: Library
Mimic Cave: Mimic Cave
Spike Cave: Spike Cave
A game of 16 chests: VoO chest game (for the item)
A storyteller: The four DW NPCs who charge 20 rupees for a hint as well as the PoD Bdlg guy who gives a free hint
A cave with some cash: 20 rupee cave, 50 rupee cave (both have thieves and some pots)
A game of chance: Gambling game (just for cash, no items)
A game of skill: Archery minigame
The queen of fairies: Capacity Upgrade Fairy
A drop's exit: Sanctuary, LW Thieves' Hideout, Kakariko Well, Magic Bat, Useless Fairy, Uncle Tunnel, Ganon drop exit
A restock room: The Kakariko bomb/arrow restock room
The tavern: The Kakariko tavern
The grass man: The Kakariko man with many beds
A cold bee: The "wrong side" of Ice Rod cave where you can get a Good Bee
Fairies deep in a cave: Hookshot Fairy
Location naming reference:
Mushroom: in the woods
Master Sword Pedestal: at the pedestal
Bottle Merchant: with a merchant
Stumpy: with tree boy
Flute Spot: underground
Digging Game: underground
Lake Hylia Island: on an island
Floating Island: on an island
Bumper Cave Ledge: on a ledge
Spectacle Rock: atop a rock
Maze Race: at the race
Desert Ledge: in the desert
Pyramid: on the pyramid
Catfish: with a catfish
Ether Tablet: at a monument
Bombos Tablet: at a monument
Hobo: with the hobo
Zora's Ledge: near Zora
King Zora: at a high price
Sunken Treasure: underwater
Floodgate Chest: in the dam
Blacksmith: with the smith
Purple Chest: from a box
Old Man: with the old man
Link's Uncle: with your uncle
Secret Passage: near your uncle
Kakariko Well (5 items): in a well
Lost Woods Hideout: near a thief
Lumberjack Tree: in a hole
Magic Bat: with the bat
Paradox Cave (7 items): in a cave with seven chests
Blind's Hideout (5 items): in a basement
Mini Moldorm Cave (5 items): near Moldorms
Hype Cave (4 back chests): near a bat-like man
Hype Cave - Generous Guy: with a bat-like man
Hookshot Cave (4 items): across pits
Sahasrahla's Hut (chests in back): near the elder
Sahasrahla: with the elder
Waterfall Fairy (2 items): near a fairy
Pyramid Fairy (2 items): near a fairy
Mire Shed (2 items): near sparks
Superbunny Cave (2 items): in a connection
Spiral Cave: in spiral cave
Kakariko Tavern: in the bar
Link's House: in your home
Sick Kid: with the sick
Library: near books
Potion Shop: near potions
Spike Cave: beyond spikes
Mimic Cave: in a cave of mimicry
Chest Game: as a prize
Chicken House: near poultry
Aginah's Cave: with Aginah
Ice Rod Cave: in a frozen cave
Brewery: alone in a home
C-Shaped House: alone in a home
Spectacle Rock Cave: alone in a cave
King's Tomb: alone in a cave
Cave 45: alone in a cave
Graveyard Cave: alone in a cave
Checkerboard Cave: alone in a cave
Bonk Rock Cave: alone in a cave
Peg Cave: alone in a cave
Sanctuary: in Sanctuary
Hyrule Castle - Boomerang Chest: in Hyrule Castle
Hyrule Castle - Map Chest: in Hyrule Castle
Hyrule Castle - Zelda's Chest: in Hyrule Castle
Sewers - Dark Cross: in the sewers
Sewers - Secret Room (3 items): in the sewers
Eastern Palace - Boss: with the Armos
Eastern Palace (otherwise, 5 items): in Eastern Palace
Desert Palace - Boss: with Lanmolas
Desert Palace (otherwise, 5 items): in Desert Palace
Tower of Hera - Boss: with Moldorm
Tower of Hera (otherwise, 5 items): in Tower of Hera
Castle Tower (2 items): in Castle Tower
Palace of Darkness - Boss: with Helmasaur King
Palace of Darkness (otherwise, 13 items): in Palace of Darkness
Swamp Palace - Boss: with Arrghus
Swamp Palace (otherwise, 9 items): in Swamp Palace
Skull Woods - Bridge Room: near Mothula
Skull Woods - Boss: with Mothula
Skull Woods (otherwise, 6 items): in Skull Woods
Thieves' Town - Boss: with Blind
Thieves' Town (otherwise, 7 items): in Thieves' Town
Ice Palace - Boss: with Kholdstare
Ice Palace (otherwise, 7 items): in Ice Palace
Misery Mire - Boss: with Vitreous
Misery Mire (otherwise, 7 items): in Misery Mire
Turtle Rock - Boss: with Trinexx
Turtle Rock (otherwise, 11 items): in Turtle Rock
Ganons Tower (after climb, 4 items): atop Ganon's Tower
Ganon's Tower (otherwise, 23 items): in Ganon's Tower

View File

@@ -1,78 +1,84 @@
from __future__ import annotations
import os
import logging
import json
import string
import copy
from concurrent.futures import ThreadPoolExecutor
import subprocess
import factorio_rcon
import colorama
import asyncio
from queue import Queue
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled
from MultiServer import mark_raw
import Utils
import random
from NetUtils import RawJSONtoTextParser, NetworkItem, ClientStatus
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
from worlds.factorio.Technologies import lookup_id_to_name
from worlds.factorio import Factorio
rcon_port = 24242
rcon_password = ''.join(random.choice(string.ascii_letters) for x in range(32))
save_name = "Archipelago"
log_folder = Utils.local_path("logs")
os.makedirs(log_folder, exist_ok=True)
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
options = Utils.get_options()
executable = options["factorio_options"]["executable"]
bin_dir = os.path.dirname(executable)
if not os.path.isdir(bin_dir):
raise FileNotFoundError(bin_dir)
if not os.path.exists(executable):
if os.path.exists(executable + ".exe"):
executable = executable + ".exe"
else:
raise FileNotFoundError(executable)
import sys
server_args = (save_name, "--rcon-port", rcon_port, "--rcon-password", rcon_password, *sys.argv[1:])
threadpool = ThreadPoolExecutor(10)
if gui_enabled:
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
filename=os.path.join(log_folder, "FactorioClient.txt"), filemode="w", force=True)
else:
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO, force=True)
logging.getLogger().addHandler(logging.FileHandler(os.path.join(log_folder, "FactorioClient.txt"), "w"))
class FactorioCommandProcessor(ClientCommandProcessor):
ctx: FactorioContext
@mark_raw
def _cmd_factorio(self, text: str) -> bool:
"""Send the following command to the bound Factorio Server."""
if self.ctx.rcon_client:
# TODO: Print the command non-silently only for race seeds, or otherwise block anything but /factorio /save in race seeds.
self.ctx.print_to_game(f"/factorio {text}")
result = self.ctx.rcon_client.send_command(text)
if result:
self.output(result)
return True
return False
def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server"""
if not self.ctx.auth:
self.output("Cannot connect to a server with unknown own identity, bridge to Factorio first.")
return super(FactorioCommandProcessor, self)._cmd_connect(address)
def _cmd_resync(self):
"""Manually trigger a resync."""
self.ctx.awaiting_bridge = True
class FactorioContext(CommonContext):
command_processor = FactorioCommandProcessor
game = "Factorio"
def __init__(self, *args, **kwargs):
super(FactorioContext, self).__init__(*args, **kwargs)
# updated by spinup server
mod_version: Utils.Version = Utils.Version(0, 0, 0)
def __init__(self, server_address, password):
super(FactorioContext, self).__init__(server_address, password)
self.send_index = 0
self.rcon_client = None
self.raw_json_text_parser = RawJSONtoTextParser(self)
self.awaiting_bridge = False
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
async def server_auth(self, password_requested):
if password_requested and not self.password:
await super(FactorioContext, self).server_auth(password_requested)
if not self.auth:
if self.rcon_client:
get_info(self, self.rcon_client) # retrieve current auth code
else:
raise Exception("Cannot connect to a server with unknown own identity, "
"bridge to Factorio first.")
await self.send_msgs([{"cmd": 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils._version_tuple,
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': ['AP'],
'uuid': Utils.get_unique_identifier(), 'game': "Factorio"
}])
@@ -80,62 +86,75 @@ class FactorioContext(CommonContext):
def on_print(self, args: dict):
logger.info(args["text"])
if self.rcon_client:
cleaned_text = args['text'].replace('"', '')
self.rcon_client.send_command(f"/sc game.print(\"Archipelago: {cleaned_text}\")")
self.print_to_game(args['text'])
def on_print_json(self, args: dict):
if not self.found_items and args.get("type", None) == "ItemSend" and args["receiving"] == args["sending"]:
pass # don't want info on other player's local pickups.
copy_data = copy.deepcopy(args["data"]) # jsontotextparser is destructive currently
logger.info(self.jsontotextparser(args["data"]))
if self.rcon_client:
cleaned_text = self.raw_json_text_parser(copy_data).replace('"', '')
self.rcon_client.send_command(f"/sc game.print(\"Archipelago: {cleaned_text}\")")
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
self.print_to_game(text)
super(FactorioContext, self).on_print_json(args)
async def game_watcher(ctx: FactorioContext, bridge_file: str):
@property
def savegame_name(self) -> str:
return f"AP_{self.seed_name}_{self.auth}.zip"
def print_to_game(self, text):
# TODO: remove around version 0.2
if self.mod_version < Utils.Version(0, 1, 6):
text = text.replace('"', '')
self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] "
f"{text}\")")
else:
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
f"{text}")
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
# catch up sync anything that is already cleared.
if args["checked_locations"]:
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
item_name in args["checked_locations"]})
async def game_watcher(ctx: FactorioContext):
bridge_logger = logging.getLogger("FactorioWatcher")
from worlds.factorio.Technologies import lookup_id_to_name
bridge_counter = 0
try:
while 1:
if os.path.exists(bridge_file):
bridge_logger.info("Found Factorio Bridge file.")
while 1:
with open(bridge_file) as f:
data = json.load(f)
research_data = data["research_done"]
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
ctx.auth = data["slot_name"]
ctx.seed_name = data["seed_name"]
while not ctx.exit_event.is_set():
if ctx.awaiting_bridge and ctx.rcon_client:
ctx.awaiting_bridge = False
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
if data["slot_name"] != ctx.auth:
bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
elif data["seed_name"] != ctx.seed_name:
bridge_logger.warning(
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
else:
data = data["info"]
research_data = data["research_done"]
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
if ctx.locations_checked != research_data:
bridge_logger.info(f"New researches done: "
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
bridge_logger.info(
f"New researches done: "
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
await asyncio.sleep(1)
else:
bridge_counter += 1
if bridge_counter >= 60:
bridge_logger.info(
"Did not find Factorio Bridge file, "
"waiting for mod to run, which requires the server to run, "
"which requires a player to be connected.")
bridge_counter = 0
await asyncio.sleep(1)
await asyncio.sleep(1)
except Exception as e:
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
def stream_factorio_output(pipe, queue):
def stream_factorio_output(pipe, queue, process):
def queuer():
while 1:
while process.poll() is None:
text = pipe.readline().strip()
if text:
queue.put_nowait(text)
@@ -144,91 +163,217 @@ def stream_factorio_output(pipe, queue):
thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True)
thread.start()
return thread
async def factorio_server_watcher(ctx: FactorioContext):
import subprocess
import factorio_rcon
factorio_server_logger = logging.getLogger("FactorioServer")
factorio_process = subprocess.Popen((executable, "--start-server", *(str(elem) for elem in server_args)),
savegame_name = os.path.abspath(ctx.savegame_name)
if not os.path.exists(savegame_name):
logger.info(f"Creating savegame {savegame_name}")
subprocess.run((
executable, "--create", savegame_name, "--preset", "archipelago"
))
factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name,
*(str(elem) for elem in server_args)),
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
encoding="utf-8")
factorio_server_logger.info("Started Factorio Server")
factorio_queue = Queue()
stream_factorio_output(factorio_process.stdout, factorio_queue)
stream_factorio_output(factorio_process.stderr, factorio_queue)
script_folder = None
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
try:
while 1:
while not ctx.exit_event.is_set():
if factorio_process.poll():
factorio_server_logger.info("Factorio server has exited.")
ctx.exit_event.set()
while not factorio_queue.empty():
msg = factorio_queue.get()
factorio_server_logger.info(msg)
if not ctx.rcon_client and "Hosting game at IP ADDR:" in msg:
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
# trigger lua interface confirmation
ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
ctx.rcon_client.send_command("/ap-sync")
if not script_folder and "Write data path:" in msg:
script_folder = msg.split("Write data path: ", 1)[1].split("[", 1)[0].strip()
bridge_file = os.path.join(script_folder, "script-output", "ap_bridge.json")
if os.path.exists(bridge_file):
os.remove(bridge_file)
logging.info(f"Bridge File Path: {bridge_file}")
asyncio.create_task(game_watcher(ctx, bridge_file), name="FactorioProgressionWatcher")
# TODO: remove around version 0.2
if ctx.mod_version < Utils.Version(0, 1, 6):
ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
if not ctx.server:
logger.info("Established bridge to Factorio Server. "
"Ready to connect to Archipelago via /connect")
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
ctx.awaiting_bridge = True
if ctx.rcon_client:
commands = {}
while ctx.send_index < len(ctx.items_received):
transfer_item: NetworkItem = ctx.items_received[ctx.send_index]
item_id = transfer_item.item
player_name = ctx.player_names[transfer_item.player]
if item_id not in lookup_id_to_name:
logging.error(f"Cannot send unknown item ID: {item_id}")
if item_id not in Factorio.item_id_to_name:
factorio_server_logger.error(f"Cannot send unknown item ID: {item_id}")
else:
item_name = lookup_id_to_name[item_id]
item_name = Factorio.item_id_to_name[item_id]
factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.")
ctx.rcon_client.send_command(f'/ap-get-technology {item_name} {player_name}')
commands[ctx.send_index] = f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}'
ctx.send_index += 1
await asyncio.sleep(1)
if commands:
ctx.rcon_client.send_commands(commands)
await asyncio.sleep(0.1)
except Exception as e:
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
ctx.rcon_client = None
ctx.exit_event.set()
finally:
factorio_process.terminate()
factorio_process.wait(5)
async def main():
ctx = FactorioContext(None, None, True)
# testing shortcuts
# ctx.server_address = "localhost"
# ctx.auth = "Nauvis"
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
await asyncio.sleep(3)
input_task = asyncio.create_task(console_loop(ctx), name="Input")
def get_info(ctx, rcon_client):
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
ctx.auth = info["slot_name"]
ctx.seed_name = info["seed_name"]
async def factorio_spinup_server(ctx: FactorioContext):
savegame_name = os.path.abspath("Archipelago.zip")
if not os.path.exists(savegame_name):
logger.info(f"Creating savegame {savegame_name}")
subprocess.run((
executable, "--create", savegame_name
))
factorio_process = subprocess.Popen(
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
encoding="utf-8")
factorio_server_logger.info("Started Information Exchange Factorio Server")
factorio_queue = Queue()
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
rcon_client = None
try:
while not ctx.auth:
while not factorio_queue.empty():
msg = factorio_queue.get()
factorio_server_logger.info(msg)
if "Loading mod AP-" in msg and msg.endswith("(data.lua)"):
parts = msg.split()
ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split(".")))
if not rcon_client and "Starting RCON interface at IP ADDR:" in msg:
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
get_info(ctx, rcon_client)
await asyncio.sleep(0.01)
if ctx.mod_version == ctx.__class__.mod_version:
raise Exception("No Archipelago mod was loaded. Aborting.")
except Exception as e:
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
ctx.exit_event.set()
else:
logger.info(f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}")
finally:
factorio_process.terminate()
factorio_process.wait(5)
async def main(args):
ctx = FactorioContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
input_task = None
from kvui import FactorioManager
ctx.ui = FactorioManager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ui_task = None
factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer")
await factorio_server_task
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
progression_watcher = asyncio.create_task(
game_watcher(ctx), name="FactorioProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
ctx.snes_reconnect_address = None
await asyncio.gather(input_task, factorio_server_task)
if ctx.server is not None and not ctx.server.socket.closed:
await ctx.server.socket.close()
if ctx.server_task is not None:
await ctx.server_task
await progression_watcher
await factorio_server_task
if ctx.server and not ctx.server.socket.closed:
await ctx.server.socket.close()
if ctx.server_task:
await ctx.server_task
while ctx.input_requests > 0:
ctx.input_queue.put_nowait(None)
ctx.input_requests -= 1
await input_task
if ui_task:
await ui_task
if input_task:
input_task.cancel()
class FactorioJSONtoTextParser(JSONtoTextParser):
def _handle_color(self, node: JSONMessagePart):
colors = node["color"].split(";")
for color in colors:
if color in {"red", "green", "blue", "orange", "yellow", "pink", "purple", "white", "black", "gray",
"brown", "cyan", "acid"}:
node["text"] = f"[color={color}]{node['text']}[/color]"
return self._handle_text(node)
elif color == "magenta":
node["text"] = f"[color=pink]{node['text']}[/color]"
return self._handle_text(node)
return self._handle_text(node)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description="Optional arguments to FactorioClient follow. "
"Remaining arguments get passed into bound Factorio instance."
"Refer to Factorio --help for those.")
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
parser.add_argument('--password', default=None, help='Password of the multiworld host.')
if not Utils.is_frozen(): # Frozen state has no cmd window in the first place
parser.add_argument('--nogui', default=False, action='store_true', help="Turns off Client GUI.")
args, rest = parser.parse_known_args()
colorama.init()
rcon_port = args.rcon_port
rcon_password = args.rcon_password if args.rcon_password else ''.join(random.choice(string.ascii_letters) for x in range(32))
factorio_server_logger = logging.getLogger("FactorioServer")
options = Utils.get_options()
executable = options["factorio_options"]["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.")
if os.path.isdir(executable): # user entered a path to a directory, let's find the executable therein
executable = os.path.join(executable, "factorio")
if not os.path.isfile(executable):
if os.path.isfile(executable + ".exe"):
executable = executable + ".exe"
else:
raise FileNotFoundError(f"Path {executable} is not an executable file.")
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.run_until_complete(main(args))
loop.close()
colorama.deinit()

222
Fill.py
View File

@@ -3,16 +3,16 @@ import typing
import collections
import itertools
from BaseClasses import CollectionState, PlandoItem, Location
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Regions import key_drop_data
from BaseClasses import CollectionState, Location, MultiWorld
from worlds.generic import PlandoItem
from worlds.AutoWorld import call_all
class FillError(RuntimeError):
pass
def fill_restrictive(world, base_state: CollectionState, locations, itempool, single_player_placement=False,
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations, itempool, single_player_placement=False,
lock=False):
def sweep_from_pool():
new_state = base_state.copy()
@@ -68,7 +68,7 @@ def fill_restrictive(world, base_state: CollectionState, locations, itempool, si
itempool.extend(unplaced_items)
def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None):
def distribute_items_restrictive(world: MultiWorld, fill_locations=None):
# If not passed in, then get a shuffled list of locations to fill in
if not fill_locations:
fill_locations = world.get_unfilled_locations()
@@ -77,65 +77,32 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
# get items to distribute
world.random.shuffle(world.itempool)
progitempool = []
nonexcludeditempool = []
localrestitempool = {player: [] for player in range(1, world.players + 1)}
nonlocalrestitempool = []
restitempool = []
for item in world.itempool:
if item.advancement:
progitempool.append(item)
elif item.never_exclude: # this only gets nonprogression items which should not appear in excluded locations
nonexcludeditempool.append(item)
elif item.name in world.local_items[item.player]:
localrestitempool[item.player].append(item)
elif item.name in world.non_local_items[item.player]:
nonlocalrestitempool.append(item)
else:
restitempool.append(item)
standard_keyshuffle_players = set()
# fill in gtower locations with trash first
for player in world.alttp_player_ids:
if not gftower_trash or not world.ganonstower_vanilla[player] or \
world.logic[player] in {'owglitches', "nologic"}:
gtower_trash_count = 0
elif 'triforcehunt' in world.goal[player] and ('local' in world.goal[player] or world.players == 1):
gtower_trash_count = world.random.randint(world.crystals_needed_for_gt[player] * 2,
world.crystals_needed_for_gt[player] * 4)
else:
gtower_trash_count = world.random.randint(0, world.crystals_needed_for_gt[player] * 2)
if gtower_trash_count:
gtower_locations = [location for location in fill_locations if
'Ganons Tower' in location.name and location.player == player]
world.random.shuffle(gtower_locations)
trashcnt = 0
localrest = localrestitempool[player]
if localrest:
gt_item_pool = restitempool + localrest
world.random.shuffle(gt_item_pool)
else:
gt_item_pool = restitempool.copy()
while gtower_locations and gt_item_pool and trashcnt < gtower_trash_count:
spot_to_fill = gtower_locations.pop()
item_to_place = gt_item_pool.pop()
if item_to_place in localrest:
localrest.remove(item_to_place)
else:
restitempool.remove(item_to_place)
world.push_item(spot_to_fill, item_to_place, False)
fill_locations.remove(spot_to_fill)
trashcnt += 1
if world.mode[player] == 'standard' and world.keyshuffle[player] is True:
standard_keyshuffle_players.add(player)
# Make sure the escape small key is placed first in standard with key shuffle to prevent running out of spots
if standard_keyshuffle_players:
progitempool.sort(
key=lambda item: 1 if item.name == 'Small Key (Hyrule Castle)' and
item.player in standard_keyshuffle_players else 0)
world.random.shuffle(fill_locations)
call_all(world, "fill_hook", progitempool, nonexcludeditempool, localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
fill_restrictive(world, world.state, fill_locations, progitempool)
if nonexcludeditempool:
world.random.shuffle(fill_locations)
fill_restrictive(world, world.state, fill_locations, nonexcludeditempool) # needs logical fill to not conflict with local items
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
local_locations = {player: [] for player in world.player_ids}
for location in fill_locations:
@@ -154,27 +121,32 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
world.push_item(spot_to_fill, item_to_place, False)
fill_locations.remove(spot_to_fill)
for item_to_place in nonlocalrestitempool:
for i, location in enumerate(fill_locations):
if location.player != item_to_place.player:
world.push_item(fill_locations.pop(i), item_to_place, False)
break
else:
logging.warning(f"Could not place non_local_item {item_to_place} among {fill_locations}, tossing.")
world.random.shuffle(fill_locations)
restitempool, fill_locations = fast_fill(world, restitempool, fill_locations)
unplaced = [item for item in progitempool + restitempool]
unplaced = progitempool + restitempool
unfilled = [location.name for location in fill_locations]
for location in fill_locations:
world.push_item(location, ItemFactory('Nothing', location.player), False)
if unplaced or unfilled:
logging.warning(f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
def fast_fill(world, item_pool: typing.List, fill_locations: typing.List) -> typing.Tuple[typing.List, typing.List]:
def fast_fill(world: MultiWorld, item_pool: typing.List, fill_locations: typing.List) -> typing.Tuple[typing.List, typing.List]:
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):
def flood_items(world: MultiWorld):
# get items to distribute
world.random.shuffle(world.itempool)
itempool = world.itempool
@@ -224,7 +196,7 @@ def flood_items(world):
location_list = world.get_reachable_locations()
world.random.shuffle(location_list)
for location in location_list:
if location.item is not None and not location.item.advancement and not location.item.smallkey and not location.item.bigkey:
if location.item is not None and not location.item.advancement:
# safe to replace
replace_item = location.item
replace_item.location = None
@@ -234,7 +206,7 @@ def flood_items(world):
break
def balance_multiworld_progression(world):
def balance_multiworld_progression(world: MultiWorld):
balanceable_players = {player for player in range(1, world.players + 1) if world.progression_balancing[player]}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
@@ -347,7 +319,8 @@ def balance_multiworld_progression(world):
if world.has_beaten_game(state):
break
elif not sphere_locations:
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
logging.warning("Progression Balancing ran out of paths.")
break
def swap_location_item(location_1: Location, location_2: Location, check_locked=True):
@@ -363,73 +336,78 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked=
location_1.event, location_2.event = location_2.event, location_1.event
def distribute_planned(world):
def distribute_planned(world: MultiWorld):
# TODO: remove. Preferably by implementing key drop
from worlds.alttp.Regions import key_drop_data
world_name_lookup = world.world_name_lookup
for player in world.player_ids:
placement: PlandoItem
for placement in world.plando_items[player]:
if placement.location in key_drop_data:
placement.warn(
f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
continue
item = ItemFactory(placement.item, player)
target_world: int = placement.world
if target_world is False or world.players == 1:
target_world = player # in own world
elif target_world is True: # in any other world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids) - {player}) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
try:
placement: PlandoItem
for placement in world.plando_items[player]:
if placement.location in key_drop_data:
placement.warn(
f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
continue
item = world.worlds[player].create_item(placement.item)
target_world: int = placement.world
if target_world is False or world.players == 1:
target_world = player # in own world
elif target_world is True: # in any other world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids) - {player}) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
continue
target_world = world.random.choice(unfilled).player
elif target_world is None: # any random world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids)) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
continue
target_world = world.random.choice(unfilled).player
elif type(target_world) == int: # target world by player id
if target_world not in range(1, world.players + 1):
placement.failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
ValueError)
continue
else: # find world by name
if target_world not in world_name_lookup:
placement.failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
ValueError)
continue
target_world = world_name_lookup[target_world]
location = world.get_location(placement.location, target_world)
if location.item:
placement.failed(f"Cannot place item into already filled location {location}.")
continue
target_world = world.random.choice(unfilled).player
elif target_world is None: # any random world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids)) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
if location.can_fill(world.state, item, False):
world.push_item(location, item, collect=False)
location.event = True # flag location to be checked during fill
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
else:
placement.failed(f"Can't place {item} at {location} due to fill condition not met.")
continue
target_world = world.random.choice(unfilled).player
elif type(target_world) == int: # target world by player id
if target_world not in range(1, world.players + 1):
placement.failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
ValueError)
continue
else: # find world by name
if target_world not in world_name_lookup:
placement.failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
ValueError)
continue
target_world = world_name_lookup[target_world]
location = world.get_location(placement.location, target_world)
if location.item:
placement.failed(f"Cannot place item into already filled location {location}.")
continue
if location.can_fill(world.state, item, False):
world.push_item(location, item, collect=False)
location.event = True # flag location to be checked during fill
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
else:
placement.failed(f"Can't place {item} at {location} due to fill condition not met.")
continue
if placement.from_pool: # Should happen AFTER the item is placed, in case it was allowed to skip failed placement.
try:
world.itempool.remove(item)
except ValueError:
placement.warn(f"Could not remove {item} from pool as it's already missing from it.")
if placement.from_pool: # Should happen AFTER the item is placed, in case it was allowed to skip failed placement.
try:
world.itempool.remove(item)
except ValueError:
placement.warn(f"Could not remove {item} from pool as it's already missing from it.")
except Exception as e:
raise Exception(f"Error running plando for player {player} ({world.player_name[player]})") from e

738
Generate.py Normal file
View File

@@ -0,0 +1,738 @@
import argparse
import logging
import random
import urllib.request
import urllib.parse
import typing
import os
from collections import Counter
import string
import ModuleUpdate
ModuleUpdate.update()
from worlds.alttp import Options as LttPOptions
from worlds.generic import PlandoItem, PlandoConnection
from Utils import parse_yaml, version_tuple, __version__, tuplize_version, get_options
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from Main import get_seed, seeddigits
import Options
from worlds.alttp.Items import item_table
from worlds.alttp import Bosses
from worlds.alttp.Text import TextTable
from worlds.alttp.Regions import location_table, key_drop_data
from worlds.AutoWorld import AutoWorldRegister
categories = set(AutoWorldRegister.world_types)
def mystery_argparse():
options = get_options()
defaults = options["generator"]
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
parser.add_argument('--weights_file_path', default = defaults["weights_file_path"],
help='Path to the weights file to use for rolling game settings, urls are also valid')
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
action='store_true')
parser.add_argument('--player_files_path', default=defaults["player_files_path"],
help="Input directory for player files.")
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=defaults["players"], type=lambda value: min(max(int(value), 1), 255))
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
parser.add_argument('--rom', default=options["lttp_options"]["rom_file"], help="Path to the 1.0 JP LttP Baserom.")
parser.add_argument('--enemizercli', default=defaults["enemizer_path"])
parser.add_argument('--outputpath', default=options["general_options"]["output_path"])
parser.add_argument('--race', action='store_true', default=defaults["race"])
parser.add_argument('--meta_file_path', default=defaults["meta_file_path"])
parser.add_argument('--log_output_path', help='Path to store output log')
parser.add_argument('--log_level', default='info', help='Sets log level')
parser.add_argument('--yaml_output', default=0, type=lambda value: min(max(int(value), 0), 255),
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
parser.add_argument('--plando', default=defaults["plando_options"],
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
args = parser.parse_args()
if not os.path.isabs(args.weights_file_path):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
return args, options
def get_seed_name(random):
return f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None, callback=ERmain):
if not args:
args, options = mystery_argparse()
seed = get_seed(args.seed)
random.seed(seed)
seed_name = get_seed_name(random)
if args.race:
random.seed() # reset to time-based random source
weights_cache = {}
if args.weights_file_path and os.path.exists(args.weights_file_path):
try:
weights_cache[args.weights_file_path] = read_weights_yaml(args.weights_file_path)
except Exception as e:
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
print(f"Weights: {args.weights_file_path} >> "
f"{get_choice('description', weights_cache[args.weights_file_path], 'No description specified')}")
if args.meta_file_path and os.path.exists(args.meta_file_path):
try:
weights_cache[args.meta_file_path] = read_weights_yaml(args.meta_file_path)
except Exception as 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]
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights, 'No description specified')}")
if args.samesettings:
raise Exception("Cannot mix --samesettings with --meta")
else:
meta_weights = None
player_id = 1
player_files = {}
for file in os.scandir(args.player_files_path):
fname = file.name
if file.is_file() and os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
path = os.path.join(args.player_files_path, fname)
try:
weights_cache[fname] = read_weights_yaml(path)
except Exception as e:
raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e
else:
print(f"P{player_id} Weights: {fname} >> "
f"{get_choice('description', weights_cache[fname], 'No description specified')}")
player_files[player_id] = fname
player_id += 1
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}")
if not weights_cache:
raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. "
f"A mix is also permitted.")
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
erargs.create_spoiler = args.spoiler > 0
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
erargs.race = args.race
erargs.skip_playthrough = args.spoiler < 2
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
# set up logger
if args.log_level:
erargs.loglevel = args.log_level
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
erargs.loglevel]
if args.log_output_path:
os.makedirs(args.log_output_path, exist_ok=True)
logging.basicConfig(format='%(message)s', level=loglevel, force=True,
filename=os.path.join(args.log_output_path, f"{seed}.log"))
else:
logging.basicConfig(format='%(message)s', level=loglevel, force=True)
erargs.rom = args.rom
erargs.enemizercli = args.enemizercli
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
for k, v 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:
for player, path in player_path_cache.items():
weights_cache[path].setdefault("meta_ignore", [])
for key in meta_weights:
option = get_choice(key, meta_weights)
if option is not None:
for player, path in player_path_cache.items():
players_meta = weights_cache[path].get("meta_ignore", [])
if key not in players_meta:
weights_cache[path][key] = option
elif type(players_meta) == dict and players_meta[key] and option not in players_meta[key]:
weights_cache[path][key] = option
name_counter = Counter()
erargs.player_settings = {}
for player in range(1, args.multi + 1):
path = player_path_cache[player]
if path:
try:
settings = settings_cache[path] if settings_cache[path] else \
roll_settings(weights_cache[path], args.plando)
for k, v in vars(settings).items():
if v is not None:
try:
getattr(erargs, k)[player] = v
except AttributeError:
setattr(erargs, k, {player: v})
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
except Exception as e:
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
else:
raise RuntimeError(f'No weights specified for player {player}')
if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}"
elif not erargs.name[player]: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {erargs.name}")
if args.yaml_output:
import yaml
important = {}
for option, player_settings in vars(erargs).items():
if type(player_settings) == dict:
if all(type(value) != list for value in player_settings.values()):
if len(player_settings.values()) > 1:
important[option] = {player: value for player, value in player_settings.items() if
player <= args.yaml_output}
elif len(player_settings.values()) > 0:
important[option] = player_settings[1]
else:
logging.debug(f"No player settings defined for option '{option}'")
else:
if player_settings != "": # is not empty name
important[option] = player_settings
else:
logging.debug(f"No player settings defined for option '{option}'")
if args.outputpath:
os.makedirs(args.outputpath, exist_ok=True)
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
yaml.dump(important, f)
callback(erargs, seed)
def read_weights_yaml(path):
try:
if urllib.parse.urlparse(path).scheme:
yaml = str(urllib.request.urlopen(path).read(), "utf-8")
else:
with open(path, 'rb') as f:
yaml = str(f.read(), "utf-8")
except Exception as e:
raise Exception(f"Failed to read weights ({path})") from e
return parse_yaml(yaml)
def interpret_on_off(value):
return {"on": True, "off": False}.get(value, value)
def convert_to_on_off(value):
return {True: "on", False: "off"}.get(value, value)
def get_choice_legacy(option, root, value=None) -> typing.Any:
if option not in root:
return value
if type(root[option]) is list:
return interpret_on_off(random.choices(root[option])[0])
if type(root[option]) is not dict:
return interpret_on_off(root[option])
if not root[option]:
return value
if any(root[option].values()):
return interpret_on_off(
random.choices(list(root[option].keys()), weights=list(map(int, root[option].values())))[0])
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
def get_choice(option, root, value=None) -> typing.Any:
if option not in root:
return value
if type(root[option]) is list:
return random.choices(root[option])[0]
if type(root[option]) is not dict:
return root[option]
if not root[option]:
return value
if any(root[option].values()):
return random.choices(list(root[option].keys()), weights=list(map(int, root[option].values())))[0]
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
class SafeDict(dict):
def __missing__(self, key):
return '{' + key + '}'
def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name] += 1
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],
NUMBER=(name_counter[name] if name_counter[
name] > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
new_name = new_name.strip()[:16]
if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name
def prefer_int(input_data: str) -> typing.Union[str, int]:
try:
return int(input_data)
except:
return input_data
available_boss_names: typing.Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
{'Agahnim', 'Agahnim2', 'Ganon'}}
available_boss_locations: typing.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 = {
'ganon': 'ganon',
'crystals': 'crystals',
'bosses': 'bosses',
'pedestal': 'pedestal',
'ganon_pedestal': 'ganonpedestal',
'triforce_hunt': 'triforcehunt',
'local_triforce_hunt': 'localtriforcehunt',
'ganon_triforce_hunt': 'ganontriforcehunt',
'local_ganon_triforce_hunt': 'localganontriforcehunt',
'ice_rod_hunt': 'icerodhunt',
}
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
new_options = set(new_weights) - set(weights)
weights.update(new_weights)
if new_options:
for new_option in new_options:
logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
f'overwrite a root option. '
f'This is probably in error.')
return weights
def roll_linked_options(weights: dict) -> dict:
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
for option_set in weights["linked_options"]:
if "name" not in option_set:
raise ValueError("One of your linked options does not have a name.")
try:
if roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.")
new_options = option_set["options"]
for category_name, category_options in new_options.items():
currently_targeted_weights = weights
if category_name:
currently_targeted_weights = currently_targeted_weights[category_name]
update_weights(currently_targeted_weights, category_options, "Linked", option_set["name"])
else:
logging.debug(f"linked option {option_set['name']} skipped.")
except Exception as e:
raise ValueError(f"Linked option {option_set['name']} is destroyed. "
f"Please fix your linked option.") from e
return weights
def roll_triggers(weights: dict) -> dict:
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
weights["_Generator_Version"] = "Archipelago" # Some means for triggers to know if the seed is on main or doors.
for i, option_set in enumerate(weights["triggers"]):
try:
currently_targeted_weights = weights
category = option_set.get("option_category", None)
if category:
currently_targeted_weights = currently_targeted_weights[category]
key = get_choice("option_name", option_set)
if key not in currently_targeted_weights:
logging.warning(f'Specified option name {option_set["option_name"]} did not '
f'match with a root option. '
f'This is probably in error.')
trigger_result = get_choice("option_result", option_set)
result = get_choice(key, currently_targeted_weights)
currently_targeted_weights[key] = result
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
for category_name, category_options in option_set["options"].items():
currently_targeted_weights = weights
if category_name:
currently_targeted_weights = currently_targeted_weights[category_name]
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
except Exception as e:
raise ValueError(f"Your trigger number {i + 1} is destroyed. "
f"Please fix your triggers.") from e
return weights
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
if boss_shuffle in boss_shuffle_options:
return boss_shuffle_options[boss_shuffle]
elif "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 roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))):
if "linked_options" in weights:
weights = roll_linked_options(weights)
if "triggers" in weights:
weights = roll_triggers(weights)
requirements = weights.get("requires", {})
if requirements:
version = requirements.get("version", __version__)
if tuplize_version(version) > version_tuple:
raise Exception(f"Settings reports required version of generator is at least {version}, "
f"however generator is of version {__version__}")
required_plando_options = requirements.get("plando", "")
if required_plando_options:
required_plando_options = set(option.strip() for option in required_plando_options.split(","))
required_plando_options -= plando_options
if required_plando_options:
if len(required_plando_options) == 1:
raise Exception(f"Settings reports required plando module {', '.join(required_plando_options)}, "
f"which is not enabled.")
else:
raise Exception(f"Settings reports required plando modules {', '.join(required_plando_options)}, "
f"which are not enabled.")
ret = argparse.Namespace()
ret.name = get_choice('name', weights)
ret.accessibility = get_choice('accessibility', weights)
ret.progression_balancing = get_choice('progression_balancing', weights, True)
ret.game = get_choice("game", weights)
if ret.game not in weights:
raise Exception(f"No game options for selected game \"{ret.game}\" found.")
world_type = AutoWorldRegister.world_types[ret.game]
game_weights = weights[ret.game]
ret.local_items = set()
for item_name in game_weights.get('local_items', []):
items = world_type.item_name_groups.get(item_name, {item_name})
for item in items:
if item in world_type.item_names:
ret.local_items.add(item)
else:
raise Exception(f"Could not force item {item} to be world-local, as it was not recognized.")
ret.non_local_items = set()
for item_name in game_weights.get('non_local_items', []):
items = world_type.item_name_groups.get(item_name, {item_name})
for item in items:
if item in world_type.item_names:
ret.non_local_items.add(item)
else:
raise Exception(f"Could not force item {item} to be world-non-local, as it was not recognized.")
inventoryweights = game_weights.get('start_inventory', {})
startitems = []
for item in inventoryweights.keys():
itemvalue = get_choice_legacy(item, inventoryweights)
if isinstance(itemvalue, int):
for i in range(int(itemvalue)):
startitems.append(item)
elif itemvalue:
startitems.append(item)
ret.startinventory = startitems
ret.start_hints = set(game_weights.get('start_hints', []))
ret.excluded_locations = set()
for location in game_weights.get('exclude_locations', []):
if location in world_type.location_names:
ret.excluded_locations.add(location)
else:
raise Exception(f"Could not exclude location {location}, as it was not recognized.")
if ret.game in AutoWorldRegister.world_types:
for option_name, option in AutoWorldRegister.world_types[ret.game].options.items():
if option_name in game_weights:
try:
if issubclass(option, Options.OptionDict) or issubclass(option, Options.OptionList):
setattr(ret, option_name, option.from_any(game_weights[option_name]))
else:
setattr(ret, option_name, option.from_any(get_choice(option_name, game_weights)))
except Exception as e:
raise Exception(f"Error generating option {option_name} in {ret.game}") from e
else:
setattr(ret, option_name, option(option.default))
if ret.game == "Minecraft":
# bad hardcoded behavior to make this work for now
ret.plando_connections = []
if "connections" in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement, "both")
))
elif ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
else:
raise Exception(f"Unsupported game {ret.game}")
return ret
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")
glitches_required = get_choice_legacy('glitches_required', weights)
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
logging.warning("Only NMG, OWG, HMG and No Logic supported")
glitches_required = 'none'
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
glitches_required]
ret.dark_room_logic = get_choice_legacy("dark_room_logic", weights, "lamp")
if not ret.dark_room_logic: # None/False
ret.dark_room_logic = "none"
if ret.dark_room_logic == "sconces":
ret.dark_room_logic = "torches"
if ret.dark_room_logic not in {"lamp", "torches", "none"}:
raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
ret.restrict_dungeon_item_on_boss = get_choice_legacy('restrict_dungeon_item_on_boss', weights, False)
entrance_shuffle = get_choice_legacy('entrance_shuffle', weights, 'vanilla')
if entrance_shuffle.startswith('none-'):
ret.shuffle = 'vanilla'
else:
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
goal = get_choice_legacy('goals', weights, 'ganon')
ret.goal = goals[goal]
# TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when
# fast ganon + ganon at hole
ret.open_pyramid = get_choice_legacy('open_pyramid', weights, 'goal')
extra_pieces = get_choice_legacy('triforce_pieces_mode', weights, 'available')
ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice_legacy('triforce_pieces_required', weights, 20))
# sum a percentage to required
if extra_pieces == 'percentage':
percentage = max(100, float(get_choice_legacy('triforce_pieces_percentage', weights, 150))) / 100
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
# vanilla mode (specify how many pieces are)
elif extra_pieces == 'available':
ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any(
get_choice_legacy('triforce_pieces_available', weights, 30))
# required pieces + fixed extra
elif extra_pieces == 'extra':
extra_pieces = max(0, int(get_choice_legacy('triforce_pieces_extra', weights, 10)))
ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
# change minimum to required pieces to avoid problems
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
ret.shop_shuffle = get_choice_legacy('shop_shuffle', weights, '')
if not ret.shop_shuffle:
ret.shop_shuffle = ''
ret.mode = get_choice_legacy("mode", weights)
ret.retro = get_choice_legacy("retro", weights)
ret.hints = get_choice_legacy('hints', weights)
ret.swordless = get_choice_legacy('swordless', weights, False)
ret.difficulty = get_choice_legacy('item_pool', 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_shuffle = bool(get_choice_legacy('enemy_shuffle', weights, False))
ret.killable_thieves = get_choice_legacy('killable_thieves', weights, False)
ret.tile_shuffle = get_choice_legacy('tile_shuffle', weights, False)
ret.bush_shuffle = get_choice_legacy('bush_shuffle', weights, False)
ret.enemy_damage = {None: 'default',
'default': 'default',
'shuffled': 'shuffled',
'random': 'chaos', # to be removed
'chaos': 'chaos',
}[get_choice_legacy('enemy_damage', weights)]
ret.enemy_health = get_choice_legacy('enemy_health', weights)
ret.shufflepots = get_choice_legacy('pot_shuffle', weights)
ret.beemizer = int(get_choice_legacy('beemizer', weights, 0))
ret.timer = {'none': False,
None: False,
False: False,
'timed': 'timed',
'timed_ohko': 'timed-ohko',
'ohko': 'ohko',
'timed_countdown': 'timed-countdown',
'display': 'display'}[get_choice_legacy('timer', weights, False)]
ret.countdown_start_time = int(get_choice_legacy('countdown_start_time', weights, 10))
ret.red_clock_time = int(get_choice_legacy('red_clock_time', weights, -2))
ret.blue_clock_time = int(get_choice_legacy('blue_clock_time', weights, 2))
ret.green_clock_time = int(get_choice_legacy('green_clock_time', weights, 4))
ret.dungeon_counters = get_choice_legacy('dungeon_counters', weights, 'default')
ret.shuffle_prizes = get_choice_legacy('shuffle_prizes', weights, "g")
ret.required_medallions = [get_choice_legacy("misery_mire_medallion", weights, "random"),
get_choice_legacy("turtle_rock_medallion", weights, "random")]
for index, medallion in enumerate(ret.required_medallions):
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
.get(medallion.lower(), None)
if not ret.required_medallions[index]:
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
ret.plando_items = []
if "items" in plando_options:
def add_plando_item(item: str, location: str):
if item not in item_table:
raise Exception(f"Could not plando item {item} as the item was not recognized")
if location not in location_table and location not in key_drop_data:
raise Exception(
f"Could not plando item {item} at location {location} as the location was not recognized")
ret.plando_items.append(PlandoItem(item, location, location_world, from_pool, force))
options = weights.get("plando_items", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
from_pool = get_choice_legacy("from_pool", placement, PlandoItem._field_defaults["from_pool"])
location_world = get_choice_legacy("world", placement, PlandoItem._field_defaults["world"])
force = str(get_choice_legacy("force", placement, PlandoItem._field_defaults["force"])).lower()
if "items" in placement and "locations" in placement:
items = placement["items"]
locations = placement["locations"]
if isinstance(items, dict):
item_list = []
for key, value in items.items():
item_list += [key] * value
items = item_list
if not items or not locations:
raise Exception("You must specify at least one item and one location to place items.")
random.shuffle(items)
random.shuffle(locations)
for item, location in zip(items, locations):
add_plando_item(item, location)
else:
item = get_choice_legacy("item", placement, get_choice_legacy("items", placement))
location = get_choice_legacy("location", placement)
add_plando_item(item, location)
ret.plando_texts = {}
if "texts" in plando_options:
tt = TextTable()
tt.removeUnwantedText()
options = weights.get("plando_texts", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
at = str(get_choice_legacy("at", placement))
if at not in tt:
raise Exception(f"No text target \"{at}\" found.")
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
ret.plando_connections = []
if "connections" in plando_options:
options = weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice_legacy("entrance", placement),
get_choice_legacy("exit", placement),
get_choice_legacy("direction", placement, "both")
))
ret.sprite_pool = weights.get('sprite_pool', [])
ret.sprite = get_choice_legacy('sprite', weights, "Link")
if 'random_sprite_on_event' in weights:
randomoneventweights = weights['random_sprite_on_event']
if get_choice_legacy('enabled', randomoneventweights, False):
ret.sprite = 'randomon'
ret.sprite += '-hit' if get_choice_legacy('on_hit', randomoneventweights, True) else ''
ret.sprite += '-enter' if get_choice_legacy('on_enter', randomoneventweights, False) else ''
ret.sprite += '-exit' if get_choice_legacy('on_exit', randomoneventweights, False) else ''
ret.sprite += '-slash' if get_choice_legacy('on_slash', randomoneventweights, False) else ''
ret.sprite += '-item' if get_choice_legacy('on_item', randomoneventweights, False) else ''
ret.sprite += '-bonk' if get_choice_legacy('on_bonk', randomoneventweights, False) else ''
ret.sprite = 'randomonall' if get_choice_legacy('on_everything', randomoneventweights, False) else ret.sprite
ret.sprite = 'randomonnone' if ret.sprite == 'randomon' else ret.sprite
if (not ret.sprite_pool or get_choice_legacy('use_weighted_sprite_pool', randomoneventweights, False)) \
and 'sprite' in weights: # Use sprite as a weighted sprite pool, if a sprite pool is not already defined.
for key, value in weights['sprite'].items():
if key.startswith('random'):
ret.sprite_pool += ['random'] * int(value)
else:
ret.sprite_pool += [key] * int(value)
if __name__ == '__main__':
import atexit
confirmation = atexit.register(input, "Press enter to close.")
main()
# in case of error-free exit should not need confirmation
atexit.unregister(confirmation)

1926
Gui.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,194 +0,0 @@
import queue
import threading
import tkinter as tk
from Utils import local_path
def set_icon(window):
er16 = tk.PhotoImage(file=local_path('data', 'ER16.gif'))
er32 = tk.PhotoImage(file=local_path('data', 'ER32.gif'))
er48 = tk.PhotoImage(file=local_path('data', 'ER32.gif'))
window.tk.call('wm', 'iconphoto', window._w, er16, er32, er48) # pylint: disable=protected-access
# Although tkinter is intended to be thread safe, there are many reports of issues
# some which may be platform specific, or depend on if the TCL library was compiled without
# multithreading support. Therefore I will assume it is not thread safe to avoid any possible problems
class BackgroundTask(object):
def __init__(self, window, code_to_run, *args):
self.window = window
self.queue = queue.Queue()
self.running = True
self.process_queue()
self.task = threading.Thread(target=code_to_run, args=(self, *args))
self.task.start()
def stop(self):
self.running = False
# safe to call from worker
def queue_event(self, event):
self.queue.put(event)
def process_queue(self):
try:
while True:
if not self.running:
return
event = self.queue.get_nowait()
event()
if self.running:
#if self is no longer running self.window may no longer be valid
self.window.update_idletasks()
except queue.Empty:
pass
if self.running:
self.window.after(100, self.process_queue)
class BackgroundTaskProgress(BackgroundTask):
def __init__(self, parent, code_to_run, title, *args):
self.parent = parent
self.window = tk.Toplevel(parent)
self.window['padx'] = 5
self.window['pady'] = 5
try:
self.window.attributes("-toolwindow", 1)
except tk.TclError:
pass
self.window.wm_title(title)
self.label_var = tk.StringVar()
self.label_var.set("")
self.label = tk.Label(self.window, textvariable=self.label_var, width=50)
self.label.pack()
self.window.resizable(width=False, height=False)
set_icon(self.window)
self.window.focus()
super().__init__(self.window, code_to_run, *args)
#safe to call from worker thread
def update_status(self, text):
self.queue_event(lambda: self.label_var.set(text))
# only call this in an event callback
def close_window(self):
self.stop()
self.window.destroy()
class ToolTips(object):
# This class derived from wckToolTips which is available under the following license:
# Copyright (c) 1998-2007 by Secret Labs AB
# Copyright (c) 1998-2007 by Fredrik Lundh
#
# By obtaining, using, and/or copying this software and/or its
# associated documentation, you agree that you have read, understood,
# and will comply with the following terms and conditions:
#
# Permission to use, copy, modify, and distribute this software and its
# associated documentation for any purpose and without fee is hereby
# granted, provided that the above copyright notice appears in all
# copies, and that both that copyright notice and this permission notice
# appear in supporting documentation, and that the name of Secret Labs
# AB or the author not be used in advertising or publicity pertaining to
# distribution of the software without specific, written prior
# permission.
#
# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO
# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
# FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
label = None
window = None
active = 0
tag = None
after_id = None
@classmethod
def getcontroller(cls, widget):
if cls.tag is None:
cls.tag = "ui_tooltip_%d" % id(cls)
widget.bind_class(cls.tag, "<Enter>", cls.enter)
widget.bind_class(cls.tag, "<Leave>", cls.leave)
widget.bind_class(cls.tag, "<Motion>", cls.motion)
widget.bind_class(cls.tag, "<Destroy>", cls.leave)
# pick suitable colors for tooltips
try:
cls.bg = "systeminfobackground"
cls.fg = "systeminfotext"
widget.winfo_rgb(cls.fg) # make sure system colors exist
widget.winfo_rgb(cls.bg)
except Exception:
cls.bg = "#ffffe0"
cls.fg = "black"
return cls.tag
@classmethod
def register(cls, widget, text):
widget.ui_tooltip_text = text
tags = list(widget.bindtags())
tags.append(cls.getcontroller(widget))
widget.bindtags(tuple(tags))
@classmethod
def unregister(cls, widget):
tags = list(widget.bindtags())
tags.remove(cls.getcontroller(widget))
widget.bindtags(tuple(tags))
# event handlers
@classmethod
def enter(cls, event):
widget = event.widget
if not cls.label:
# create and hide balloon help window
cls.popup = tk.Toplevel(bg=cls.fg, bd=1)
cls.popup.overrideredirect(1)
cls.popup.withdraw()
cls.label = tk.Label(
cls.popup, fg=cls.fg, bg=cls.bg, bd=0, padx=2, justify=tk.LEFT
)
cls.label.pack()
cls.active = 0
cls.xy = event.x_root + 16, event.y_root + 10
cls.event_xy = event.x, event.y
cls.after_id = widget.after(200, cls.display, widget)
@classmethod
def motion(cls, event):
cls.xy = event.x_root + 16, event.y_root + 10
cls.event_xy = event.x, event.y
@classmethod
def display(cls, widget):
if not cls.active:
# display balloon help window
text = widget.ui_tooltip_text
if callable(text):
text = text(widget, cls.event_xy)
cls.label.config(text=text)
cls.popup.deiconify()
cls.popup.lift()
cls.popup.geometry("+%d+%d" % cls.xy)
cls.active = 1
cls.after_id = None
@classmethod
def leave(cls, event):
widget = event.widget
if cls.active:
cls.popup.withdraw()
cls.active = 0
if cls.after_id:
widget.after_cancel(cls.after_id)
cls.after_id = None

View File

@@ -1,24 +1,33 @@
#!/usr/bin/env python3
import argparse
import json
import os
import logging
import queue
import random
import shutil
import textwrap
import sys
import threading
import time
from tkinter import Tk
import tkinter as tk
from argparse import Namespace
from concurrent.futures import as_completed, ThreadPoolExecutor
from glob import glob
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, LEFT, X, TOP, LabelFrame, \
IntVar, Checkbutton, E, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
from urllib.parse import urlparse
from urllib.request import urlopen
from Gui import update_sprites
from GuiUtils import BackgroundTaskProgress
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings
from Utils import output_path
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
from Utils import output_path, local_path, open_file
class AdjusterWorld(object):
def __init__(self, sprite_pool):
import random
self.sprite_pool = {1: sprite_pool}
self.rom_seeds = {1: random}
self.slot_seeds = {1: random}
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
@@ -35,7 +44,7 @@ def main():
help='Path to an ALttP JAP(1.0) rom to use as a base.')
parser.add_argument('--loglevel', default='info', const='info', nargs='?',
choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.')
parser.add_argument('--fastmenu', default='normal', const='normal', nargs='?',
parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?',
choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'],
help='''\
Select the rate at which the menu opens and closes.
@@ -91,6 +100,7 @@ def main():
parser.add_argument('--names', default='', type=str)
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
args = parser.parse_args()
args.music = not args.disablemusic
if args.update_sprites:
run_sprite_update()
sys.exit()
@@ -141,7 +151,7 @@ def adjust(args):
if hasattr(args, "world"):
world = getattr(args, "world")
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.fastmenu, args.disablemusic,
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world)
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
rom.write_to_file(path)
@@ -153,10 +163,8 @@ def adjust(args):
def adjustGUI():
from tkinter import Checkbutton, OptionMenu, Toplevel, LabelFrame, PhotoImage, Tk, LEFT, RIGHT, BOTTOM, TOP, \
StringVar, IntVar, Frame, Label, W, E, X, BOTH, Entry, Spinbox, Button, filedialog, messagebox, ttk
from Gui import get_rom_options_frame, get_rom_frame
from GuiUtils import set_icon
from tkinter import Tk, LEFT, BOTTOM, TOP, \
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
from argparse import Namespace
from Main import __version__ as MWVersion
adjustWindow = Tk()
@@ -188,14 +196,14 @@ def adjustGUI():
guiargs = Namespace()
guiargs.heartbeep = rom_vars.heartbeepVar.get()
guiargs.heartcolor = rom_vars.heartcolorVar.get()
guiargs.fastmenu = rom_vars.fastMenuVar.get()
guiargs.menuspeed = rom_vars.menuspeedVar.get()
guiargs.ow_palettes = rom_vars.owPalettesVar.get()
guiargs.uw_palettes = rom_vars.uwPalettesVar.get()
guiargs.hud_palettes = rom_vars.hudPalettesVar.get()
guiargs.sword_palettes = rom_vars.swordPalettesVar.get()
guiargs.shield_palettes = rom_vars.shieldPalettesVar.get()
guiargs.quickswap = bool(rom_vars.quickSwapVar.get())
guiargs.disablemusic = bool(rom_vars.disableMusicVar.get())
guiargs.music = bool(rom_vars.MusicVar.get())
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
guiargs.rom = romVar2.get()
guiargs.baserom = romVar.get()
@@ -214,7 +222,6 @@ def adjustGUI():
else:
messagebox.showinfo(title="Success", message=f"Rom patched successfully to {path}")
from Utils import persistent_store
from worlds.alttp.Rom import Sprite
if isinstance(guiargs.sprite, Sprite):
guiargs.sprite = guiargs.sprite.name
persistent_store("adjuster", "last_settings_3", guiargs)
@@ -239,5 +246,859 @@ def run_sprite_update():
print("Done updating sprites")
def update_sprites(task, on_finish=None):
resultmessage = ""
successful = True
sprite_dir = local_path("data", "sprites", "alttpr")
os.makedirs(sprite_dir, exist_ok=True)
def finished():
task.close_window()
if on_finish:
on_finish(successful, resultmessage)
try:
task.update_status("Downloading alttpr sprites list")
with urlopen('https://alttpr.com/sprites') as response:
sprites_arr = json.loads(response.read().decode("utf-8"))
except Exception as e:
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
successful = False
task.queue_event(finished)
return
try:
task.update_status("Determining needed sprites")
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if filename not in current_sprites]
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
except Exception as e:
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
successful = False
task.queue_event(finished)
return
def dl(sprite_url, filename):
target = os.path.join(sprite_dir, filename)
with urlopen(sprite_url) as response, open(target, 'wb') as out:
shutil.copyfileobj(response, out)
def rem(sprite):
os.remove(os.path.join(sprite_dir, sprite))
with ThreadPoolExecutor() as pool:
dl_tasks = []
rem_tasks = []
for (sprite_url, filename) in needed_sprites:
dl_tasks.append(pool.submit(dl, sprite_url, filename))
for sprite in obsolete_sprites:
rem_tasks.append(pool.submit(rem, sprite))
deleted = 0
updated = 0
for dl_task in as_completed(dl_tasks):
updated += 1
task.update_status("Downloading needed sprite %g/%g" % (updated, len(needed_sprites)))
try:
dl_task.result()
except Exception as e:
logging.exception(e)
resultmessage = "Error downloading sprite. Not all sprites updated.\n\n%s: %s" % (
type(e).__name__, e)
successful = False
for rem_task in as_completed(rem_tasks):
deleted += 1
task.update_status("Removing obsolete sprite %g/%g" % (deleted, len(obsolete_sprites)))
try:
rem_task.result()
except Exception as e:
logging.exception(e)
resultmessage = "Error removing obsolete sprite. Not all sprites updated.\n\n%s: %s" % (
type(e).__name__, e)
successful = False
if successful:
resultmessage = "alttpr sprites updated successfully"
task.queue_event(finished)
def set_icon(window):
logo = tk.PhotoImage(file=local_path('data', 'icon.png'))
window.tk.call('wm', 'iconphoto', window._w, logo)
class BackgroundTask(object):
def __init__(self, window, code_to_run, *args):
self.window = window
self.queue = queue.Queue()
self.running = True
self.process_queue()
self.task = threading.Thread(target=code_to_run, args=(self, *args))
self.task.start()
def stop(self):
self.running = False
# safe to call from worker
def queue_event(self, event):
self.queue.put(event)
def process_queue(self):
try:
while True:
if not self.running:
return
event = self.queue.get_nowait()
event()
if self.running:
#if self is no longer running self.window may no longer be valid
self.window.update_idletasks()
except queue.Empty:
pass
if self.running:
self.window.after(100, self.process_queue)
class BackgroundTaskProgress(BackgroundTask):
def __init__(self, parent, code_to_run, title, *args):
self.parent = parent
self.window = tk.Toplevel(parent)
self.window['padx'] = 5
self.window['pady'] = 5
try:
self.window.attributes("-toolwindow", 1)
except tk.TclError:
pass
self.window.wm_title(title)
self.label_var = tk.StringVar()
self.label_var.set("")
self.label = tk.Label(self.window, textvariable=self.label_var, width=50)
self.label.pack()
self.window.resizable(width=False, height=False)
set_icon(self.window)
self.window.focus()
super().__init__(self.window, code_to_run, *args)
# safe to call from worker thread
def update_status(self, text):
self.queue_event(lambda: self.label_var.set(text))
# only call this in an event callback
def close_window(self):
self.stop()
self.window.destroy()
def get_rom_frame(parent=None):
romFrame = Frame(parent)
baseRomLabel = Label(romFrame, text='LttP Base Rom: ')
romVar = StringVar(value="Zelda no Densetsu - Kamigami no Triforce (Japan).sfc")
romEntry = Entry(romFrame, textvariable=romVar)
def RomSelect():
rom = filedialog.askopenfilename(filetypes=[("Rom Files", (".sfc", ".smc")), ("All Files", "*")])
try:
get_base_rom_bytes(rom) # throws error on checksum fail
except Exception as e:
logging.exception(e)
messagebox.showerror(title="Error while reading ROM", message=str(e))
else:
romVar.set(rom)
romSelectButton['state'] = "disabled"
romSelectButton["text"] = "ROM verified"
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
baseRomLabel.pack(side=LEFT)
romEntry.pack(side=LEFT, expand=True, fill=X)
romSelectButton.pack(side=LEFT)
romFrame.pack(side=TOP, expand=True, fill=X)
return romFrame, romVar
def get_rom_options_frame(parent=None):
romOptionsFrame = LabelFrame(parent, text="Rom options")
romOptionsFrame.columnconfigure(0, weight=1)
romOptionsFrame.columnconfigure(1, weight=1)
for i in range(5):
romOptionsFrame.rowconfigure(i, weight=1)
vars = Namespace()
vars.MusicVar = IntVar()
vars.MusicVar.set(1)
MusicCheckbutton = Checkbutton(romOptionsFrame, text="Music", variable=vars.MusicVar)
MusicCheckbutton.grid(row=0, column=0, sticky=E)
vars.disableFlashingVar = IntVar(value=1)
disableFlashingCheckbutton = Checkbutton(romOptionsFrame, text="Disable flashing (anti-epilepsy)", variable=vars.disableFlashingVar)
disableFlashingCheckbutton.grid(row=6, column=0, sticky=E)
spriteDialogFrame = Frame(romOptionsFrame)
spriteDialogFrame.grid(row=0, column=1)
baseSpriteLabel = Label(spriteDialogFrame, text='Sprite:')
vars.spriteNameVar = StringVar()
vars.sprite = None
def set_sprite(sprite_param):
nonlocal vars
if isinstance(sprite_param, str):
vars.sprite = sprite_param
vars.spriteNameVar.set(sprite_param)
elif sprite_param is None or not sprite_param.valid:
vars.sprite = None
vars.spriteNameVar.set('(unchanged)')
else:
vars.sprite = sprite_param
vars.spriteNameVar.set(vars.sprite.name)
set_sprite(None)
vars.spriteNameVar.set('(unchanged)')
spriteEntry = Label(spriteDialogFrame, textvariable=vars.spriteNameVar)
def SpriteSelect():
nonlocal vars
SpriteSelector(parent, set_sprite, spritePool=vars.sprite_pool)
spriteSelectButton = Button(spriteDialogFrame, text='...', command=SpriteSelect)
baseSpriteLabel.pack(side=LEFT)
spriteEntry.pack(side=LEFT)
spriteSelectButton.pack(side=LEFT)
vars.quickSwapVar = IntVar(value=1)
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
menuspeedFrame = Frame(romOptionsFrame)
menuspeedFrame.grid(row=1, column=1, sticky=E)
menuspeedLabel = Label(menuspeedFrame, text='Menu speed')
menuspeedLabel.pack(side=LEFT)
vars.menuspeedVar = StringVar()
vars.menuspeedVar.set('normal')
menuspeedOptionMenu = OptionMenu(menuspeedFrame, vars.menuspeedVar, 'normal', 'instant', 'double', 'triple', 'quadruple', 'half')
menuspeedOptionMenu.pack(side=LEFT)
heartcolorFrame = Frame(romOptionsFrame)
heartcolorFrame.grid(row=2, column=0, sticky=E)
heartcolorLabel = Label(heartcolorFrame, text='Heart color')
heartcolorLabel.pack(side=LEFT)
vars.heartcolorVar = StringVar()
vars.heartcolorVar.set('red')
heartcolorOptionMenu = OptionMenu(heartcolorFrame, vars.heartcolorVar, 'red', 'blue', 'green', 'yellow', 'random')
heartcolorOptionMenu.pack(side=LEFT)
heartbeepFrame = Frame(romOptionsFrame)
heartbeepFrame.grid(row=2, column=1, sticky=E)
heartbeepLabel = Label(heartbeepFrame, text='Heartbeep')
heartbeepLabel.pack(side=LEFT)
vars.heartbeepVar = StringVar()
vars.heartbeepVar.set('normal')
heartbeepOptionMenu = OptionMenu(heartbeepFrame, vars.heartbeepVar, 'double', 'normal', 'half', 'quarter', 'off')
heartbeepOptionMenu.pack(side=LEFT)
owPalettesFrame = Frame(romOptionsFrame)
owPalettesFrame.grid(row=3, column=0, sticky=E)
owPalettesLabel = Label(owPalettesFrame, text='Overworld palettes')
owPalettesLabel.pack(side=LEFT)
vars.owPalettesVar = StringVar()
vars.owPalettesVar.set('default')
owPalettesOptionMenu = OptionMenu(owPalettesFrame, vars.owPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
owPalettesOptionMenu.pack(side=LEFT)
uwPalettesFrame = Frame(romOptionsFrame)
uwPalettesFrame.grid(row=3, column=1, sticky=E)
uwPalettesLabel = Label(uwPalettesFrame, text='Dungeon palettes')
uwPalettesLabel.pack(side=LEFT)
vars.uwPalettesVar = StringVar()
vars.uwPalettesVar.set('default')
uwPalettesOptionMenu = OptionMenu(uwPalettesFrame, vars.uwPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
uwPalettesOptionMenu.pack(side=LEFT)
hudPalettesFrame = Frame(romOptionsFrame)
hudPalettesFrame.grid(row=4, column=0, sticky=E)
hudPalettesLabel = Label(hudPalettesFrame, text='HUD palettes')
hudPalettesLabel.pack(side=LEFT)
vars.hudPalettesVar = StringVar()
vars.hudPalettesVar.set('default')
hudPalettesOptionMenu = OptionMenu(hudPalettesFrame, vars.hudPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
hudPalettesOptionMenu.pack(side=LEFT)
swordPalettesFrame = Frame(romOptionsFrame)
swordPalettesFrame.grid(row=4, column=1, sticky=E)
swordPalettesLabel = Label(swordPalettesFrame, text='Sword palettes')
swordPalettesLabel.pack(side=LEFT)
vars.swordPalettesVar = StringVar()
vars.swordPalettesVar.set('default')
swordPalettesOptionMenu = OptionMenu(swordPalettesFrame, vars.swordPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
swordPalettesOptionMenu.pack(side=LEFT)
shieldPalettesFrame = Frame(romOptionsFrame)
shieldPalettesFrame.grid(row=5, column=0, sticky=E)
shieldPalettesLabel = Label(shieldPalettesFrame, text='Shield palettes')
shieldPalettesLabel.pack(side=LEFT)
vars.shieldPalettesVar = StringVar()
vars.shieldPalettesVar.set('default')
shieldPalettesOptionMenu = OptionMenu(shieldPalettesFrame, vars.shieldPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
shieldPalettesOptionMenu.pack(side=LEFT)
spritePoolFrame = Frame(romOptionsFrame)
spritePoolFrame.grid(row=5, column=1)
baseSpritePoolLabel = Label(spritePoolFrame, text='Sprite Pool:')
vars.spritePoolCountVar = StringVar()
vars.sprite_pool = []
def set_sprite_pool(sprite_param):
nonlocal vars
operation = "add"
if isinstance(sprite_param, tuple):
operation, sprite_param = sprite_param
if isinstance(sprite_param, Sprite) and sprite_param.valid:
sprite_param = sprite_param.name
if isinstance(sprite_param, str):
if operation == "add":
vars.sprite_pool.append(sprite_param)
elif operation == "remove":
vars.sprite_pool.remove(sprite_param)
elif operation == "clear":
vars.sprite_pool.clear()
vars.spritePoolCountVar.set(str(len(vars.sprite_pool)))
set_sprite_pool(None)
vars.spritePoolCountVar.set('0')
spritePoolEntry = Label(spritePoolFrame, textvariable=vars.spritePoolCountVar)
def SpritePoolSelect():
nonlocal vars
SpriteSelector(parent, set_sprite_pool, randomOnEvent=False, spritePool=vars.sprite_pool)
def SpritePoolClear():
nonlocal vars
vars.sprite_pool.clear()
vars.spritePoolCountVar.set('0')
spritePoolSelectButton = Button(spritePoolFrame, text='...', command=SpritePoolSelect)
spritePoolClearButton = Button(spritePoolFrame, text='Clear', command=SpritePoolClear)
baseSpritePoolLabel.pack(side=LEFT)
spritePoolEntry.pack(side=LEFT)
spritePoolSelectButton.pack(side=LEFT)
spritePoolClearButton.pack(side=LEFT)
return romOptionsFrame, vars, set_sprite
class SpriteSelector():
def __init__(self, parent, callback, adjuster=False, randomOnEvent=True, spritePool=None):
self.deploy_icons()
self.parent = parent
self.window = Toplevel(parent)
self.callback = callback
self.adjuster = adjuster
self.randomOnEvent = randomOnEvent
self.spritePoolButtons = None
self.window.wm_title("TAKE ANY ONE YOU WANT")
self.window['padx'] = 5
self.window['pady'] = 5
self.spritesPerRow = 32
self.all_sprites = []
self.sprite_pool = spritePool
def open_custom_sprite_dir(_evt):
open_file(self.custom_sprite_dir)
alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
custom_frametitle = Frame(self.window)
title_text = Label(custom_frametitle, text="Custom Sprites")
title_link = Label(custom_frametitle, text="(open)", fg="blue", cursor="hand2")
title_text.pack(side=LEFT)
title_link.pack(side=LEFT)
title_link.bind("<Button-1>", open_custom_sprite_dir)
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir, 'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
self.icon_section(custom_frametitle, self.custom_sprite_dir, 'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
if not randomOnEvent:
self.sprite_pool_section(spritePool)
frame = Frame(self.window)
frame.pack(side=BOTTOM, fill=X, pady=5)
if self.randomOnEvent:
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
button.pack(side=RIGHT, padx=(5, 0))
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
button.pack(side=RIGHT, padx=(5, 0))
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
button.pack(side=LEFT, padx=(0, 5))
self.randomButtonText = StringVar()
button = Button(frame, textvariable=self.randomButtonText, command=self.use_random_sprite)
button.pack(side=LEFT, padx=(0, 5))
self.randomButtonText.set("Random")
self.randomOnEventText = StringVar()
self.randomOnHitVar = IntVar()
self.randomOnEnterVar = IntVar()
self.randomOnExitVar = IntVar()
self.randomOnSlashVar = IntVar()
self.randomOnItemVar = IntVar()
self.randomOnBonkVar = IntVar()
self.randomOnRandomVar = IntVar()
if self.randomOnEvent:
button = Checkbutton(frame, text="Hit", command=self.update_random_button, variable=self.randomOnHitVar)
button.pack(side=LEFT, padx=(0, 5))
button = Checkbutton(frame, text="Enter", command=self.update_random_button, variable=self.randomOnEnterVar)
button.pack(side=LEFT, padx=(0, 5))
button = Checkbutton(frame, text="Exit", command=self.update_random_button, variable=self.randomOnExitVar)
button.pack(side=LEFT, padx=(0, 5))
button = Checkbutton(frame, text="Slash", command=self.update_random_button, variable=self.randomOnSlashVar)
button.pack(side=LEFT, padx=(0, 5))
button = Checkbutton(frame, text="Item", command=self.update_random_button, variable=self.randomOnItemVar)
button.pack(side=LEFT, padx=(0, 5))
button = Checkbutton(frame, text="Bonk", command=self.update_random_button, variable=self.randomOnBonkVar)
button.pack(side=LEFT, padx=(0, 5))
button = Checkbutton(frame, text="Random", command=self.update_random_button, variable=self.randomOnRandomVar)
button.pack(side=LEFT, padx=(0, 5))
if adjuster:
button = Button(frame, text="Current sprite from rom", command=self.use_default_sprite)
button.pack(side=LEFT, padx=(0, 5))
set_icon(self.window)
self.window.focus()
def remove_from_sprite_pool(self, button, spritename):
self.callback(("remove", spritename))
self.spritePoolButtons.buttons.remove(button)
button.destroy()
def add_to_sprite_pool(self, spritename):
if isinstance(spritename, str):
if spritename == "random":
button = Button(self.spritePoolButtons, text="?")
button['font'] = font.Font(size=19)
button.configure(command=lambda spr="random": self.remove_from_sprite_pool(button, spr))
ToolTips.register(button, "Random")
self.spritePoolButtons.buttons.append(button)
else:
spritename = Sprite.get_sprite_from_name(spritename)
if isinstance(spritename, Sprite) and spritename.valid:
image = get_image_for_sprite(spritename)
if image is None:
return
button = Button(self.spritePoolButtons, image=image)
button.configure(command=lambda spr=spritename: self.remove_from_sprite_pool(button, spr.name))
ToolTips.register(button, spritename.name +
f"\nBy: {spritename.author_name if spritename.author_name else ''}")
button.image = image
self.spritePoolButtons.buttons.append(button)
self.grid_fill_sprites(self.spritePoolButtons)
def sprite_pool_section(self, spritePool):
def clear_sprite_pool(_evt):
self.callback(("clear", "Clear"))
for button in self.spritePoolButtons.buttons:
button.destroy()
self.spritePoolButtons.buttons.clear()
frametitle = Frame(self.window)
title_text = Label(frametitle, text="Sprite Pool")
title_link = Label(frametitle, text="(clear)", fg="blue", cursor="hand2")
title_text.pack(side=LEFT)
title_link.pack(side=LEFT)
title_link.bind("<Button-1>", clear_sprite_pool)
self.spritePoolButtons = LabelFrame(self.window, labelwidget=frametitle, padx=5, pady=5)
self.spritePoolButtons.pack(side=TOP, fill=X)
self.spritePoolButtons.buttons = []
def update_sprites(event):
self.spritesPerRow = (event.width - 10) // 38
self.grid_fill_sprites(self.spritePoolButtons)
self.grid_fill_sprites(self.spritePoolButtons)
self.spritePoolButtons.bind("<Configure>", update_sprites)
if spritePool:
for sprite in spritePool:
self.add_to_sprite_pool(sprite)
def icon_section(self, frame_label, path, no_results_label):
frame = LabelFrame(self.window, labelwidget=frame_label, padx=5, pady=5)
frame.pack(side=TOP, fill=X)
sprites = []
for file in os.listdir(path):
sprites.append((file, Sprite(os.path.join(path, file))))
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())
frame.buttons = []
for file, sprite in sprites:
image = get_image_for_sprite(sprite)
if image is None:
continue
self.all_sprites.append(sprite)
button = Button(frame, image=image, command=lambda spr=sprite: self.select_sprite(spr))
ToolTips.register(button, sprite.name +
("\nBy: %s" % sprite.author_name if sprite.author_name else "") +
f"\nFrom: {file}")
button.image = image
frame.buttons.append(button)
if not frame.buttons:
label = Label(frame, text=no_results_label)
label.pack()
def update_sprites(event):
self.spritesPerRow = (event.width - 10) // 38
self.grid_fill_sprites(frame)
self.grid_fill_sprites(frame)
frame.bind("<Configure>", update_sprites)
def grid_fill_sprites(self, frame):
for i, button in enumerate(frame.buttons):
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
def update_alttpr_sprites(self):
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
self.window.destroy()
self.parent.update()
def on_finish(successful, resultmessage):
if successful:
messagebox.showinfo("Sprite Updater", resultmessage)
else:
logging.error(resultmessage)
messagebox.showerror("Sprite Updater", resultmessage)
SpriteSelector(self.parent, self.callback, self.adjuster)
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
def browse_for_sprite(self):
sprite = filedialog.askopenfilename(
filetypes=[("All Sprite Sources", (".zspr", ".spr", ".sfc", ".smc")),
("ZSprite files", ".zspr"),
("Sprite files", ".spr"),
("Rom Files", (".sfc", ".smc")),
("All Files", "*")])
try:
self.callback(Sprite(sprite))
except Exception:
self.callback(None)
self.window.destroy()
def use_default_sprite(self):
self.callback(None)
self.window.destroy()
def use_default_link_sprite(self):
if self.randomOnEvent:
self.callback(Sprite.default_link_sprite())
self.window.destroy()
else:
self.callback("link")
self.add_to_sprite_pool("link")
def update_random_button(self):
if self.randomOnRandomVar.get():
randomon = "random"
else:
randomon = "-hit" if self.randomOnHitVar.get() else ""
randomon += "-enter" if self.randomOnEnterVar.get() else ""
randomon += "-exit" if self.randomOnExitVar.get() else ""
randomon += "-slash" if self.randomOnSlashVar.get() else ""
randomon += "-item" if self.randomOnItemVar.get() else ""
randomon += "-bonk" if self.randomOnBonkVar.get() else ""
self.randomOnEventText.set(f"randomon{randomon}" if randomon else None)
self.randomButtonText.set("Random On Event" if randomon else "Random")
def use_random_sprite(self):
if not self.randomOnEvent:
self.callback("random")
self.add_to_sprite_pool("random")
return
elif self.randomOnEventText.get():
self.callback(self.randomOnEventText.get())
elif self.sprite_pool:
self.callback(random.choice(self.sprite_pool))
elif self.all_sprites:
self.callback(random.choice(self.all_sprites))
else:
self.callback(None)
self.window.destroy()
def select_sprite(self, spritename):
self.callback(spritename)
if self.randomOnEvent:
self.window.destroy()
else:
self.add_to_sprite_pool(spritename)
def deploy_icons(self):
if not os.path.exists(self.custom_sprite_dir):
os.makedirs(self.custom_sprite_dir)
@property
def alttpr_sprite_dir(self):
return local_path("data", "sprites", "alttpr")
@property
def custom_sprite_dir(self):
return local_path("data", "sprites", "custom")
def get_image_for_sprite(sprite, gif_only: bool = False):
if not sprite.valid:
return None
height = 24
width = 16
def draw_sprite_into_gif(add_palette_color, set_pixel_color_index):
def drawsprite(spr, pal_as_colors, offset):
for y, row in enumerate(spr):
for x, pal_index in enumerate(row):
if pal_index:
color = pal_as_colors[pal_index - 1]
set_pixel_color_index(x + offset[0], y + offset[1], color)
add_palette_color(16, (40, 40, 40))
shadow = [
[0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0],
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
[0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0],
]
drawsprite(shadow, [16], (2, 17))
palettes = sprite.decode_palette()
for i in range(15):
add_palette_color(i + 1, palettes[0][i])
body = sprite.decode16(0x4C0)
drawsprite(body, list(range(1, 16)), (0, 8))
head = sprite.decode16(0x40)
drawsprite(head, list(range(1, 16)), (0, 0))
def make_gif(callback):
gif_header = b'GIF89a'
gif_lsd = bytearray(7)
gif_lsd[0] = width
gif_lsd[2] = height
gif_lsd[4] = 0xF4 # 32 color palette follows. transparant + 15 for sprite + 1 for shadow=17 which rounds up to 32 as nearest power of 2
gif_lsd[5] = 0 # background color is zero
gif_lsd[6] = 0 # aspect raio not specified
gif_gct = bytearray(3 * 32)
gif_gce = bytearray(8)
gif_gce[0] = 0x21 # start of extention blocked
gif_gce[1] = 0xF9 # identifies this as the Graphics Control extension
gif_gce[2] = 4 # we are suppling only the 4 four bytes
gif_gce[3] = 0x01 # this gif includes transparency
gif_gce[4] = gif_gce[5] = 0 # animation frrame delay (unused)
gif_gce[6] = 0 # transparent color is index 0
gif_gce[7] = 0 # end of gif_gce
gif_id = bytearray(10)
gif_id[0] = 0x2c
# byte 1,2 are image left. 3,4 are image top both are left as zerosuitsamus
gif_id[5] = width
gif_id[7] = height
gif_id[9] = 0 # no local color table
gif_img_minimum_code_size = bytes([7]) # we choose 7 bits, so that each pixel is represented by a byte, for conviennce.
clear = 0x80
stop = 0x81
unchunked_image_data = bytearray(height * (width + 1) + 1)
# we technically need a Clear code once every 125 bytes, but we do it at the start of every row for simplicity
for row in range(height):
unchunked_image_data[row * (width + 1)] = clear
unchunked_image_data[-1] = stop
def add_palette_color(index, color):
gif_gct[3 * index] = color[0]
gif_gct[3 * index + 1] = color[1]
gif_gct[3 * index + 2] = color[2]
def set_pixel_color_index(x, y, color):
unchunked_image_data[y * (width + 1) + x + 1] = color
callback(add_palette_color, set_pixel_color_index)
def chunk_image(img):
for i in range(0, len(img), 255):
chunk = img[i:i + 255]
yield bytes([len(chunk)])
yield chunk
gif_img = b''.join([gif_img_minimum_code_size] + list(chunk_image(unchunked_image_data)) + [b'\x00'])
gif = b''.join([gif_header, gif_lsd, gif_gct, gif_gce, gif_id, gif_img, b'\x3b'])
return gif
gif_data = make_gif(draw_sprite_into_gif)
if gif_only:
return gif_data
image = PhotoImage(data=gif_data)
return image.zoom(2)
class ToolTips(object):
# This class derived from wckToolTips which is available under the following license:
# Copyright (c) 1998-2007 by Secret Labs AB
# Copyright (c) 1998-2007 by Fredrik Lundh
#
# By obtaining, using, and/or copying this software and/or its
# associated documentation, you agree that you have read, understood,
# and will comply with the following terms and conditions:
#
# Permission to use, copy, modify, and distribute this software and its
# associated documentation for any purpose and without fee is hereby
# granted, provided that the above copyright notice appears in all
# copies, and that both that copyright notice and this permission notice
# appear in supporting documentation, and that the name of Secret Labs
# AB or the author not be used in advertising or publicity pertaining to
# distribution of the software without specific, written prior
# permission.
#
# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO
# THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
# FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
# OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
label = None
window = None
active = 0
tag = None
after_id = None
@classmethod
def getcontroller(cls, widget):
if cls.tag is None:
cls.tag = "ui_tooltip_%d" % id(cls)
widget.bind_class(cls.tag, "<Enter>", cls.enter)
widget.bind_class(cls.tag, "<Leave>", cls.leave)
widget.bind_class(cls.tag, "<Motion>", cls.motion)
widget.bind_class(cls.tag, "<Destroy>", cls.leave)
# pick suitable colors for tooltips
try:
cls.bg = "systeminfobackground"
cls.fg = "systeminfotext"
widget.winfo_rgb(cls.fg) # make sure system colors exist
widget.winfo_rgb(cls.bg)
except Exception:
cls.bg = "#ffffe0"
cls.fg = "black"
return cls.tag
@classmethod
def register(cls, widget, text):
widget.ui_tooltip_text = text
tags = list(widget.bindtags())
tags.append(cls.getcontroller(widget))
widget.bindtags(tuple(tags))
@classmethod
def unregister(cls, widget):
tags = list(widget.bindtags())
tags.remove(cls.getcontroller(widget))
widget.bindtags(tuple(tags))
# event handlers
@classmethod
def enter(cls, event):
widget = event.widget
if not cls.label:
# create and hide balloon help window
cls.popup = tk.Toplevel(bg=cls.fg, bd=1)
cls.popup.overrideredirect(1)
cls.popup.withdraw()
cls.label = tk.Label(
cls.popup, fg=cls.fg, bg=cls.bg, bd=0, padx=2, justify=tk.LEFT
)
cls.label.pack()
cls.active = 0
cls.xy = event.x_root + 16, event.y_root + 10
cls.event_xy = event.x, event.y
cls.after_id = widget.after(200, cls.display, widget)
@classmethod
def motion(cls, event):
cls.xy = event.x_root + 16, event.y_root + 10
cls.event_xy = event.x, event.y
@classmethod
def display(cls, widget):
if not cls.active:
# display balloon help window
text = widget.ui_tooltip_text
if callable(text):
text = text(widget, cls.event_xy)
cls.label.config(text=text)
cls.popup.deiconify()
cls.popup.lift()
cls.popup.geometry("+%d+%d" % cls.xy)
cls.active = 1
cls.after_id = None
@classmethod
def leave(cls, event):
widget = event.widget
if cls.active:
cls.popup.withdraw()
cls.active = 0
if cls.after_id:
widget.after_cancel(cls.after_id)
cls.after_id = None
if __name__ == '__main__':
main()
main()

View File

@@ -1,21 +1,19 @@
import argparse
import atexit
exit_func = atexit.register(input, "Press enter to close.")
import threading
import time
import functools
import webbrowser
import multiprocessing
import socket
import os
import subprocess
import base64
import shutil
import logging
import asyncio
from json import loads, dumps
from random import randrange
from Utils import get_item_name_from_id
exit_func = atexit.register(input, "Press enter to close.")
import ModuleUpdate
@@ -28,11 +26,23 @@ from NetUtils import *
from worlds.alttp import Regions, Shops
from worlds.alttp import Items
import Utils
from CommonClient import CommonContext, server_loop, logger, console_loop, ClientCommandProcessor
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, gui_enabled
snes_logger = logging.getLogger("SNES")
from MultiServer import mark_raw
log_folder = Utils.local_path("logs")
os.makedirs(log_folder, exist_ok=True)
# Log to file in gui case
if gui_enabled:
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
filename=os.path.join(log_folder, "LttPClient.txt"), filemode="w", force=True)
else:
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO, force=True)
logging.getLogger().addHandler(logging.FileHandler(os.path.join(log_folder, "LttPClient.txt"), "w"))
class LttPCommandProcessor(ClientCommandProcessor):
def _cmd_slow_mode(self, toggle: str = ""):
@@ -45,10 +55,26 @@ class LttPCommandProcessor(ClientCommandProcessor):
self.output(f"Setting slow mode to {self.ctx.slow_mode}")
@mark_raw
def _cmd_snes(self, snes_address: str = "") -> bool:
"""Connect to a snes. Optionally include network address of a snes to connect to, otherwise show available devices"""
def _cmd_snes(self, snes_options: str = "") -> bool:
"""Connect to a snes. Optionally include network address of a snes to connect to, otherwise show available devices; and a SNES device number if more than one SNES is detected"""
snes_address = self.ctx.snes_address
snes_device_number = -1
options = snes_options.split()
num_options = len(options)
if num_options > 0:
snes_address = options[0]
if num_options > 1:
try:
snes_device_number = int(options[1])
except:
pass
self.ctx.snes_reconnect_address = None
asyncio.create_task(snes_connect(self.ctx, snes_address if snes_address else self.ctx.snes_address))
asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number))
return True
def _cmd_snes_close(self) -> bool:
@@ -62,8 +88,10 @@ class LttPCommandProcessor(ClientCommandProcessor):
class Context(CommonContext):
command_processor = LttPCommandProcessor
def __init__(self, snes_address, server_address, password, found_items):
super(Context, self).__init__(server_address, password, found_items)
game = "A Link to the Past"
def __init__(self, snes_address, server_address, password):
super(Context, self).__init__(server_address, password)
# snes stuff
self.snes_address = snes_address
@@ -73,8 +101,8 @@ class Context(CommonContext):
self.snes_reconnect_address = None
self.snes_recv_queue = asyncio.Queue()
self.snes_request_lock = asyncio.Lock()
self.is_sd2snes = False
self.snes_write_buffer = []
self.snes_connector_lock = threading.Lock()
self.awaiting_rom = False
self.rom = None
@@ -95,14 +123,14 @@ class Context(CommonContext):
await super(Context, self).server_auth(password_requested)
if self.rom is None:
self.awaiting_rom = True
logger.info(
snes_logger.info(
'No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)')
return
self.awaiting_rom = False
self.auth = self.rom
auth = base64.b64encode(self.rom).decode()
await self.send_msgs([{"cmd": 'Connect',
'password': self.password, 'name': auth, 'version': Utils._version_tuple,
'password': self.password, 'name': auth, 'version': Utils.version_tuple,
'tags': get_tags(self),
'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past"
}])
@@ -143,8 +171,6 @@ SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte
SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes
location_shop_order = [name for name, info in
Shops.shop_table.items()] # probably don't leave this here. This relies on python 3.6+ dictionary keys having defined order
location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
@@ -415,26 +441,32 @@ class SNESState(enum.IntEnum):
SNES_ATTACHED = 3
def launch_qusb2snes(ctx: Context):
qusb2snes_path = Utils.get_options()["lttp_options"]["qusb2snes"]
def launch_sni(ctx: Context):
sni_path = Utils.get_options()["lttp_options"]["sni"]
if not os.path.isfile(qusb2snes_path):
qusb2snes_path = Utils.local_path(qusb2snes_path)
if not os.path.isdir(sni_path):
sni_path = Utils.local_path(sni_path)
if os.path.isdir(sni_path):
for file in os.listdir(sni_path):
if file.startswith("sni.") and not file.endswith(".proto"):
sni_path = os.path.join(sni_path, file)
if os.path.isfile(qusb2snes_path):
logger.info(f"Attempting to start {qusb2snes_path}")
if os.path.isfile(sni_path):
snes_logger.info(f"Attempting to start {sni_path}")
import subprocess
subprocess.Popen(qusb2snes_path, cwd=os.path.dirname(qusb2snes_path))
if Utils.is_frozen(): # if it spawns a visible console, may as well populate it
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path))
else:
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
logger.info(
f"Attempt to start (Q)Usb2Snes was aborted as path {qusb2snes_path} was not found, "
snes_logger.info(
f"Attempt to start SNI was aborted as path {sni_path} was not found, "
f"please start it yourself if it is not running")
async def _snes_connect(ctx: Context, address: str):
address = f"ws://{address}" if "://" not in address else address
logger.info("Connecting to QUsb2snes at %s ..." % address)
snes_logger.info("Connecting to SNI at %s ..." % address)
seen_problems = set()
succesful = False
while not succesful:
@@ -446,11 +478,11 @@ async def _snes_connect(ctx: Context, address: str):
# only tell the user about new problems, otherwise silently lay in wait for a working connection
if problem not in seen_problems:
seen_problems.add(problem)
logger.error(f"Error connecting to QUsb2snes ({problem})")
snes_logger.error(f"Error connecting to SNI ({problem})")
if len(seen_problems) == 1:
# this is the first problem. Let's try launching QUsb2snes if it isn't already running
launch_qusb2snes(ctx)
# this is the first problem. Let's try launching SNI if it isn't already running
launch_sni(ctx)
await asyncio.sleep(1)
else:
@@ -469,7 +501,7 @@ async def get_snes_devices(ctx: Context):
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
if not devices:
logger.info('No SNES device found. Ensure QUsb2Snes is running and connect it to the multibridge.')
snes_logger.info('No SNES device found. Please connect a SNES device to SNI.')
while not devices:
await asyncio.sleep(1)
await socket.send(dumps(DeviceList_Request))
@@ -481,12 +513,16 @@ async def get_snes_devices(ctx: Context):
return devices
async def snes_connect(ctx: Context, address):
async def snes_connect(ctx: Context, address, deviceIndex = -1):
global SNES_RECONNECT_DELAY
if ctx.snes_socket is not None and ctx.snes_state == SNESState.SNES_CONNECTED:
logger.error('Already connected to snes')
if ctx.rom:
snes_logger.error('Already connected to SNES, with rom loaded.')
else:
snes_logger.error('Already connected to SNI, likely awaiting a device.')
return
device = None
recv_task = None
ctx.snes_state = SNESState.SNES_CONNECTING
socket = await _snes_connect(ctx, address)
@@ -495,19 +531,33 @@ async def snes_connect(ctx: Context, address):
try:
devices = await get_snes_devices(ctx)
numDevices = len(devices)
if len(devices) == 1:
if numDevices == 1:
device = devices[0]
elif ctx.snes_reconnect_address:
if ctx.snes_attached_device[1] in devices:
device = ctx.snes_attached_device[1]
else:
device = devices[ctx.snes_attached_device[0]]
else:
elif numDevices > 1:
if deviceIndex == -1:
snes_logger.info("Found " + str(numDevices) + " SNES devices; connect to one with /snes <address> <device number>:")
for idx, availableDevice in enumerate(devices):
snes_logger.info(str(idx + 1) + ": " + availableDevice)
elif (deviceIndex < 0) or (deviceIndex - 1) > numDevices:
snes_logger.warning("SNES device number out of range")
else:
device = devices[deviceIndex - 1]
if device is None:
await snes_disconnect(ctx)
return
logger.info("Attaching to " + device)
snes_logger.info("Attaching to " + device)
Attach_Request = {
"Opcode": "Attach",
@@ -517,20 +567,10 @@ async def snes_connect(ctx: Context, address):
await ctx.snes_socket.send(dumps(Attach_Request))
ctx.snes_state = SNESState.SNES_ATTACHED
ctx.snes_attached_device = (devices.index(device), device)
if 'sd2snes' in device.lower() or 'COM' in device:
logger.info("SD2SNES/FXPAK Detected")
ctx.is_sd2snes = True
await ctx.snes_socket.send(dumps({"Opcode": "Info", "Space": "SNES"}))
reply = loads(await ctx.snes_socket.recv())
if reply and 'Results' in reply:
logger.info(reply['Results'])
else:
ctx.is_sd2snes = False
ctx.snes_reconnect_address = address
recv_task = asyncio.create_task(snes_recv_loop(ctx))
SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay
snes_logger.info(f"Attached to {device}")
except Exception as e:
if recv_task is not None:
@@ -543,9 +583,9 @@ async def snes_connect(ctx: Context, address):
ctx.snes_socket = None
ctx.snes_state = SNESState.SNES_DISCONNECTED
if not ctx.snes_reconnect_address:
logger.error("Error connecting to snes (%s)" % e)
snes_logger.error("Error connecting to snes (%s)" % e)
else:
logger.error(f"Error connecting to snes, attempt again in {SNES_RECONNECT_DELAY}s")
snes_logger.error(f"Error connecting to snes, attempt again in {SNES_RECONNECT_DELAY}s")
asyncio.create_task(snes_autoreconnect(ctx))
SNES_RECONNECT_DELAY *= 2
@@ -572,11 +612,11 @@ async def snes_recv_loop(ctx: Context):
try:
async for msg in ctx.snes_socket:
ctx.snes_recv_queue.put_nowait(msg)
logger.warning("Snes disconnected")
snes_logger.warning("Snes disconnected")
except Exception as e:
if not isinstance(e, websockets.WebSocketException):
logger.exception(e)
logger.error("Lost connection to the snes, type /snes to reconnect")
snes_logger.exception(e)
snes_logger.error("Lost connection to the snes, type /snes to reconnect")
finally:
socket, ctx.snes_socket = ctx.snes_socket, None
if socket is not None and not socket.closed:
@@ -589,7 +629,7 @@ async def snes_recv_loop(ctx: Context):
ctx.rom = None
if ctx.snes_reconnect_address:
logger.info(f"...reconnecting in {SNES_RECONNECT_DELAY}s")
snes_logger.info(f"...reconnecting in {SNES_RECONNECT_DELAY}s")
asyncio.create_task(snes_autoreconnect(ctx))
@@ -618,11 +658,10 @@ async def snes_read(ctx: Context, address, size):
break
if len(data) != size:
logger.error('Error reading %s, requested %d bytes, received %d' % (hex(address), size, len(data)))
snes_logger.error('Error reading %s, requested %d bytes, received %d' % (hex(address), size, len(data)))
if len(data):
logger.error(str(data))
logger.warning('Unable to connect to SNES Device because QUsb2Snes broke temporarily.'
'Try un-selecting and re-selecting the SNES Device.')
snes_logger.error(str(data))
snes_logger.warning('Communication Failure with SNI')
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
await ctx.snes_socket.close()
return None
@@ -641,45 +680,16 @@ async def snes_write(ctx: Context, write_list):
return False
PutAddress_Request = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
if ctx.is_sd2snes:
cmd = b'\x00\xE2\x20\x48\xEB\x48'
try:
for address, data in write_list:
if (address < WRAM_START) or ((address + len(data)) > (WRAM_START + WRAM_SIZE)):
logger.error("SD2SNES: Write out of range %s (%d)" % (hex(address), len(data)))
return False
for ptr, byte in enumerate(data, address + 0x7E0000 - WRAM_START):
cmd += b'\xA9' # LDA
cmd += bytes([byte])
cmd += b'\x8F' # STA.l
cmd += bytes([ptr & 0xFF, (ptr >> 8) & 0xFF, (ptr >> 16) & 0xFF])
cmd += b'\xA9\x00\x8F\x00\x2C\x00\x68\xEB\x68\x28\x6C\xEA\xFF\x08'
PutAddress_Request['Space'] = 'CMD'
PutAddress_Request['Operands'] = ["2C00", hex(len(cmd) - 1)[2:], "2C00", "1"]
try:
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(cmd)
await ctx.snes_socket.send(data)
else:
logger.warning(f"Could not send data to SNES: {cmd}")
except websockets.ConnectionClosed:
return False
else:
PutAddress_Request['Space'] = 'SNES'
try:
# will pack those requests as soon as qusb2snes actually supports that for real
for address, data in write_list:
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data)
else:
logger.warning(f"Could not send data to SNES: {data}")
except websockets.ConnectionClosed:
return False
snes_logger.warning(f"Could not send data to SNES: {data}")
except websockets.ConnectionClosed:
return False
return True
finally:
@@ -709,9 +719,6 @@ def get_tags(ctx: Context):
return tags
async def track_locations(ctx: Context, roomid, roomdata):
new_locations = []
@@ -719,16 +726,16 @@ async def track_locations(ctx: Context, roomid, roomdata):
new_locations.append(location_id)
ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(location_id)
logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
try:
if roomid in location_shop_ids:
misc_data = await snes_read(ctx, SHOP_ADDR, (len(location_shop_order) * 3) + 5)
misc_data = await snes_read(ctx, SHOP_ADDR, (len(Shops.shop_table) * 3) + 5)
for cnt, b in enumerate(misc_data):
if int(b) and (Shops.SHOP_ID_START + cnt) not in ctx.locations_checked:
new_check(Shops.SHOP_ID_START + cnt)
except Exception as e:
logger.info(f"Exception: {e}")
snes_logger.info(f"Exception: {e}")
for location_id, (loc_roomid, loc_mask) in location_table_uw_id.items():
try:
@@ -737,7 +744,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
roomdata << 4) & loc_mask != 0:
new_check(location_id)
except Exception as e:
logger.exception(f"Exception: {e}")
snes_logger.exception(f"Exception: {e}")
uw_begin = 0x129
ow_end = uw_end = 0
@@ -820,7 +827,7 @@ async def game_watcher(ctx: Context):
await ctx.server_auth(False)
if ctx.auth and ctx.auth != ctx.rom:
logger.warning("ROM change detected, please reconnect to the multiworld server")
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
await ctx.disconnect()
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
@@ -862,10 +869,11 @@ async def game_watcher(ctx: Context):
if recv_index < len(ctx.items_received) and recv_item == 0:
item = ctx.items_received[recv_index]
recv_index += 1
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_name_getter(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_name_getter(item.location), recv_index + 1, len(ctx.items_received)))
recv_index += 1
ctx.location_name_getter(item.location), recv_index, len(ctx.items_received)))
snes_buffered_write(ctx, RECV_PROGRESS_ADDR, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
snes_buffered_write(ctx, RECV_ITEM_ADDR, bytes([item.item]))
snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR, bytes([item.player if item.player != ctx.slot else 0]))
@@ -896,12 +904,14 @@ async def main():
parser = argparse.ArgumentParser()
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a Archipelago Binary Patch file')
parser.add_argument('--snes', default='localhost:8080', help='Address of the QUsb2snes server.')
parser.add_argument('--snes', default='localhost:8080', help='Address of the SNI server.')
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
parser.add_argument('--password', default=None, help='Password of the multiworld host.')
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
parser.add_argument('--founditems', default=False, action='store_true',
help='Show items found by other players for themselves.')
if not Utils.is_frozen(): # Frozen state has no cmd window in the first place
parser.add_argument('--nogui', default=False, action='store_true', help="Turns off Client GUI.")
args = parser.parse_args()
logging.basicConfig(format='%(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
if args.diff_file:
@@ -910,7 +920,7 @@ async def main():
meta, romfile = Patch.create_rom_file(args.diff_file)
args.connect = meta["server"]
logging.info(f"Wrote rom file to {romfile}")
adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile)
adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile, gui_enabled)
if adjusted:
try:
shutil.move(adjustedromfile, romfile)
@@ -919,25 +929,33 @@ async def main():
logging.exception(e)
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
port = None
ctx = Context(args.snes, args.connect, args.password, args.founditems)
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ctx = Context(args.snes, args.connect, args.password)
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
input_task = None
from kvui import LttPManager
ctx.ui = LttPManager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ui_task = None
snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address))
watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher")
await ctx.exit_event.wait()
if snes_connect_task:
snes_connect_task.cancel()
ctx.server_address = None
ctx.snes_reconnect_address = None
await watcher_task
if ctx.server is not None and not ctx.server.socket.closed:
if ctx.server and not ctx.server.socket.closed:
await ctx.server.socket.close()
if ctx.server_task is not None:
if ctx.server_task:
await ctx.server_task
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
@@ -947,7 +965,11 @@ async def main():
ctx.input_queue.put_nowait(None)
ctx.input_requests -= 1
await input_task
if ui_task:
await ui_task
if input_task:
input_task.cancel()
if __name__ == '__main__':

665
Main.py
View File

@@ -1,4 +1,3 @@
import copy
from itertools import zip_longest
import logging
import os
@@ -7,30 +6,18 @@ import time
import zlib
import concurrent.futures
import pickle
import tempfile
import zipfile
from typing import Dict, Tuple
from BaseClasses import MultiWorld, CollectionState, Region, Item
from worlds.alttp.Items import ItemFactory, item_name_groups
from worlds.alttp.Regions import create_regions, mark_light_world_regions, \
lookup_vanilla_location_to_entrance
from worlds.alttp.InvertedRegions import create_inverted_regions, mark_dark_world_regions
from worlds.alttp.EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect
from worlds.alttp.Rom import patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, LocalRom, get_hash_string
from worlds.alttp.Rules import set_rules
from worlds.alttp.Dungeons import create_dungeons, fill_dungeons, fill_dungeons_restrictive
from BaseClasses import MultiWorld, CollectionState, Region, RegionType
from worlds.alttp.Items import item_name_groups
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
from worlds.alttp.Shops import create_shops, ShopSlotFill, SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from worlds.alttp.ItemPool import generate_itempool, difficulties, fill_prizes
from Utils import output_path, parse_player_names, get_options, __version__, _version_tuple
from worlds.hk import gen_hollow
from worlds.hk import create_regions as hk_create_regions
from worlds.factorio import gen_factorio, factorio_create_regions
from worlds.factorio.Mod import generate_mod
from worlds.minecraft import gen_minecraft, fill_minecraft_slot_data, generate_mc_data
from worlds.minecraft.Regions import minecraft_create_regions
from worlds.generic.Rules import locality_rules
from worlds import Games, lookup_any_item_name_to_id
import Patch
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from Utils import output_path, get_options, __version__, version_tuple
from worlds.generic.Rules import locality_rules, exclusion_rules
from worlds import AutoWorld
seeddigits = 20
@@ -42,15 +29,6 @@ def get_seed(seed=None):
return seed
def get_same_seed(world: MultiWorld, seed_def: tuple) -> str:
seeds: Dict[tuple, str] = getattr(world, "__named_seeds", {})
if seed_def in seeds:
return seeds[seed_def]
seeds[seed_def] = str(world.random.randint(0, 2 ** 64))
world.__named_seeds = seeds
return seeds[seed_def]
def main(args, seed=None):
if args.outputpath:
os.makedirs(args.outputpath, exist_ok=True)
@@ -76,10 +54,9 @@ def main(args, seed=None):
world.difficulty = args.difficulty.copy()
world.item_functionality = args.item_functionality.copy()
world.timer = args.timer.copy()
world.progressive = args.progressive.copy()
world.goal = args.goal.copy()
world.local_items = args.local_items.copy()
if hasattr(args, "algorithm"): # current GUI options
if hasattr(args, "algorithm"): # current GUI options
world.algorithm = args.algorithm
world.shuffleganon = args.shuffleganon
world.custom = args.custom
@@ -89,17 +66,6 @@ def main(args, seed=None):
world.retro = args.retro.copy()
world.hints = args.hints.copy()
world.mapshuffle = args.mapshuffle.copy()
world.compassshuffle = args.compassshuffle.copy()
world.keyshuffle = args.keyshuffle.copy()
world.bigkeyshuffle = args.bigkeyshuffle.copy()
world.crystals_needed_for_ganon = {
player: world.random.randint(0, 7) if args.crystals_ganon[player] == 'random' else int(
args.crystals_ganon[player]) for player in range(1, world.players + 1)}
world.crystals_needed_for_gt = {
player: world.random.randint(0, 7) if args.crystals_gt[player] == 'random' else int(args.crystals_gt[player])
for player in range(1, world.players + 1)}
world.open_pyramid = args.open_pyramid.copy()
world.boss_shuffle = args.shufflebosses.copy()
world.enemy_shuffle = args.enemy_shuffle.copy()
@@ -115,13 +81,10 @@ def main(args, seed=None):
world.blue_clock_time = args.blue_clock_time.copy()
world.green_clock_time = args.green_clock_time.copy()
world.shufflepots = args.shufflepots.copy()
world.progressive = args.progressive.copy()
world.dungeon_counters = args.dungeon_counters.copy()
world.glitch_boots = args.glitch_boots.copy()
world.triforce_pieces_available = args.triforce_pieces_available.copy()
world.triforce_pieces_required = args.triforce_pieces_required.copy()
world.shop_shuffle = args.shop_shuffle.copy()
world.shop_shuffle_slots = args.shop_shuffle_slots.copy()
world.progression_balancing = args.progression_balancing.copy()
world.shuffle_prizes = args.shuffle_prizes.copy()
world.sprite_pool = args.sprite_pool.copy()
@@ -133,148 +96,68 @@ def main(args, seed=None):
world.restrict_dungeon_item_on_boss = args.restrict_dungeon_item_on_boss.copy()
world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy()
import Options
for hk_option in Options.hollow_knight_options:
setattr(world, hk_option, getattr(args, hk_option, {}))
for factorio_option in Options.factorio_options:
setattr(world, factorio_option, getattr(args, factorio_option, {}))
for minecraft_option in Options.minecraft_options:
setattr(world, minecraft_option, getattr(args, minecraft_option, {}))
world.set_options(args)
world.player_name = args.name.copy()
world.alttp_rom = args.rom
world.enemizer = args.enemizercli
world.sprite = args.sprite.copy()
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
world.rom_seeds = {player: random.Random(world.random.randint(0, 999999999)) for player in range(1, world.players + 1)}
for player in range(1, world.players+1):
world.er_seeds[player] = str(world.random.randint(0, 2 ** 64))
if "-" in world.shuffle[player]:
shuffle, seed = world.shuffle[player].split("-", 1)
world.shuffle[player] = shuffle
if shuffle == "vanilla":
world.er_seeds[player] = "vanilla"
elif seed.startswith("group-") or args.race:
# renamed from team to group to not confuse with existing team name use
world.er_seeds[player] = get_same_seed(world, (shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
else: # not a race or group seed, use set seed as is.
world.er_seeds[player] = seed
elif world.shuffle[player] == "vanilla":
world.er_seeds[player] = "vanilla"
world.slot_seeds = {player: random.Random(world.random.getrandbits(64)) for player in
range(1, world.players + 1)}
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
parsed_names = parse_player_names(args.names, world.players, args.teams)
world.teams = len(parsed_names)
for i, team in enumerate(parsed_names, 1):
if world.players > 1:
logger.info('%s%s', 'Team%d: ' % i if world.teams > 1 else 'Players: ', ', '.join(team))
for player, name in enumerate(team, 1):
world.player_names[player].append(name)
logger.info("Found World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
numlength = 8
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} Items | "
f"{len(cls.location_names):3} Locations")
logger.info(f" Item IDs: {min(cls.item_id_to_name):{numlength}} - "
f"{max(cls.item_id_to_name):{numlength}} | "
f"Location IDs: {min(cls.location_id_to_name):{numlength}} - "
f"{max(cls.location_id_to_name):{numlength}}")
AutoWorld.call_all(world, "generate_early")
logger.info('')
for player in world.player_ids:
for item_name in args.startinventory[player]:
item = Item(item_name, True, lookup_any_item_name_to_id[item_name], player)
world.push_precollected(item)
for player in world.alttp_player_ids:
world.difficulty_requirements[player] = difficulties[world.difficulty[player]]
world.push_precollected(world.create_item(item_name, player))
for player in world.player_ids:
if player in world.get_game_players("A Link to the Past"):
# enforce pre-defined local items.
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
world.local_items[player].add('Triforce Piece')
# enforce pre-defined local items.
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
world.local_items[player].add('Triforce Piece')
# Not possible to place pendants/crystals out side of boss prizes yet.
world.non_local_items[player] -= item_name_groups['Pendants']
world.non_local_items[player] -= item_name_groups['Crystals']
# items can't be both local and non-local, prefer local
world.non_local_items[player] -= world.local_items[player]
# dungeon items can't be in non-local if the appropriate dungeon item shuffle setting is not set.
if not world.mapshuffle[player]:
world.non_local_items[player] -= item_name_groups['Maps']
logger.info('Creating World.')
AutoWorld.call_all(world, "create_regions")
if not world.compassshuffle[player]:
world.non_local_items[player] -= item_name_groups['Compasses']
if not world.keyshuffle[player]:
world.non_local_items[player] -= item_name_groups['Small Keys']
if not world.bigkeyshuffle[player]:
world.non_local_items[player] -= item_name_groups['Big Keys']
# Not possible to place pendants/crystals out side of boss prizes yet.
world.non_local_items[player] -= item_name_groups['Pendants']
world.non_local_items[player] -= item_name_groups['Crystals']
for player in world.hk_player_ids:
hk_create_regions(world, player)
for player in world.factorio_player_ids:
factorio_create_regions(world, player)
for player in world.minecraft_player_ids:
minecraft_create_regions(world, player)
for player in world.alttp_player_ids:
if world.open_pyramid[player] == 'goal':
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt', 'localganontriforcehunt', 'ganonpedestal'}
elif world.open_pyramid[player] == 'auto':
world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt', 'localganontriforcehunt', 'ganonpedestal'} and \
(world.shuffle[player] in {'vanilla', 'dungeonssimple', 'dungeonsfull', 'dungeonscrossed'} or not world.shuffle_ganon)
else:
world.open_pyramid[player] = {'on': True, 'off': False, 'yes': True, 'no': False}.get(world.open_pyramid[player], world.open_pyramid[player])
world.triforce_pieces_available[player] = max(world.triforce_pieces_available[player], world.triforce_pieces_required[player])
if world.mode[player] != 'inverted':
create_regions(world, player)
else:
create_inverted_regions(world, player)
create_shops(world, player)
create_dungeons(world, player)
logger.info('Shuffling the World about.')
for player in world.alttp_player_ids:
if world.logic[player] not in ["noglitches", "minorglitches"] and world.shuffle[player] in \
{"vanilla", "dungeonssimple", "dungeonsfull", "simple", "restricted", "full"}:
world.fix_fake_world[player] = False
# seeded entrance shuffle
old_random = world.random
world.random = random.Random(world.er_seeds[player])
if world.mode[player] != 'inverted':
link_entrances(world, player)
mark_light_world_regions(world, player)
else:
link_inverted_entrances(world, player)
mark_dark_world_regions(world, player)
world.random = old_random
plando_connect(world, player)
logger.info('Generating Item Pool.')
for player in world.alttp_player_ids:
generate_itempool(world, player)
logger.info('Creating Items.')
AutoWorld.call_all(world, "create_items")
logger.info('Calculating Access Rules.')
if world.players > 1:
for player in world.player_ids:
locality_rules(world, player)
for player in world.alttp_player_ids:
set_rules(world, player)
AutoWorld.call_all(world, "set_rules")
for player in world.hk_player_ids:
gen_hollow(world, player)
for player in world.player_ids:
exclusion_rules(world, player, args.excluded_locations[player])
for player in world.factorio_player_ids:
gen_factorio(world, player)
for player in world.minecraft_player_ids:
gen_minecraft(world, player)
AutoWorld.call_all(world, "generate_basic")
logger.info("Running Item Plando")
@@ -283,307 +166,199 @@ def main(args, seed=None):
distribute_planned(world)
logger.info('Placing Dungeon Prizes.')
logger.info('Running Pre Main Fill.')
fill_prizes(world)
logger.info('Placing Dungeon Items.')
if world.algorithm in ['balanced', 'vt26'] or any(
list(args.mapshuffle.values()) + list(args.compassshuffle.values()) +
list(args.keyshuffle.values()) + list(args.bigkeyshuffle.values())):
fill_dungeons_restrictive(world)
else:
fill_dungeons(world)
AutoWorld.call_all(world, "pre_fill")
logger.info('Fill the world.')
if world.algorithm == 'flood':
flood_items(world) # different algo, biased towards early game progress items
elif world.algorithm == 'vt25':
distribute_items_restrictive(world, False)
elif world.algorithm == 'vt26':
distribute_items_restrictive(world, True)
elif world.algorithm == 'balanced':
distribute_items_restrictive(world, True)
distribute_items_restrictive(world)
logger.info("Filling Shop Slots")
ShopSlotFill(world)
AutoWorld.call_all(world, 'post_fill')
if world.players > 1:
balance_multiworld_progression(world)
logger.info('Generating output files.')
logger.info(f'Beginning output...')
outfilebase = 'AP_' + world.seed_name
rom_names = []
def _gen_rom(team: int, player: int):
use_enemizer = (world.boss_shuffle[player] != 'none' or world.enemy_shuffle[player]
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
or world.shufflepots[player] or world.bush_shuffle[player]
or world.killable_thieves[player])
output = tempfile.TemporaryDirectory()
with output as temp_dir:
with concurrent.futures.ThreadPoolExecutor() as pool:
check_accessibility_task = pool.submit(world.fulfills_accessibility)
rom = LocalRom(args.rom)
output_file_futures = []
patch_rom(world, rom, player, team, use_enemizer)
for player in world.player_ids:
# skip starting a thread for methods that say "pass".
if AutoWorld.World.generate_output.__code__ is not world.worlds[player].generate_output.__code__:
output_file_futures.append(pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
output_file_futures.append(pool.submit(AutoWorld.call_stage, world, "generate_output", temp_dir))
if use_enemizer:
patch_enemizer(world, team, player, rom, args.enemizercli)
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)
if args.race:
patch_race_rom(rom, world, player)
# collect ER hint info
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
world.shuffle[player] != "vanilla" or world.retro[player]}
world.spoiler.hashes[(player, team)] = get_hash_string(rom.hash)
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
palettes_options={}
palettes_options['dungeon']=args.uw_palettes[player]
palettes_options['overworld']=args.ow_palettes[player]
palettes_options['hud']=args.hud_palettes[player]
palettes_options['sword']=args.sword_palettes[player]
palettes_options['shield']=args.shield_palettes[player]
palettes_options['link']=args.link_palettes[player]
ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
apply_rom_settings(rom, args.heartbeep[player], args.heartcolor[player], args.quickswap[player],
args.fastmenu[player], args.disablemusic[player], args.sprite[player],
palettes_options, world, player, True,
reduceflashing=args.reduceflashing[player] or args.race,
triforcehud=args.triforcehud[player])
checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)}
mcsb_name = ''
if all([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
world.bigkeyshuffle[player]]):
mcsb_name = '-keysanity'
elif [world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
world.bigkeyshuffle[player]].count(True) == 1:
mcsb_name = '-mapshuffle' if world.mapshuffle[player] else \
'-compassshuffle' if world.compassshuffle[player] else \
'-universal_keys' if world.keyshuffle[player] == "universal" else \
'-keyshuffle' if world.keyshuffle[player] else '-bigkeyshuffle'
elif any([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
world.bigkeyshuffle[player]]):
mcsb_name = '-%s%s%s%sshuffle' % (
'M' if world.mapshuffle[player] else '', 'C' if world.compassshuffle[player] else '',
'U' if world.keyshuffle[player] == "universal" else 'S' if world.keyshuffle[player] else '',
'B' if world.bigkeyshuffle[player] else '')
for player in range(1, world.players + 1):
checks_in_area[player]["Total"] = 0
outfilepname = f'_P{player}'
outfilepname += f"_{world.player_names[player][team].replace(' ', '_')}" \
if world.player_names[player][team] != 'Player%d' % player else ''
outfilestuffs = {
"logic": world.logic[player], # 0
"difficulty": world.difficulty[player], # 1
"item_functionality": world.item_functionality[player], # 2
"mode": world.mode[player], # 3
"goal": world.goal[player], # 4
"timer": str(world.timer[player]), # 5
"shuffle": world.shuffle[player], # 6
"algorithm": world.algorithm, # 7
"mscb": mcsb_name, # 8
"retro": world.retro[player], # 9
"progressive": world.progressive, # A
"hints": 'True' if world.hints[player] else 'False' # B
}
# 0 1 2 3 4 5 6 7 8 9 A B
outfilesuffix = ('_%s_%s-%s-%s-%s%s_%s-%s%s%s%s%s' % (
# 0 1 2 3 4 5 6 7 8 9 A B C
# _noglitches_normal-normal-open-ganon-ohko_simple-balanced-keysanity-retro-prog_random-nohints
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity-retro
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -prog_random
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -nohints
outfilestuffs["logic"], # 0
for location in world.get_filled_locations():
if type(location.address) is int:
main_entrance = get_entrance_to_region(location.parent_region)
if location.game != "A Link to the Past":
checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.dungeon:
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
'Inverted Ganons Tower': 'Ganons Tower'} \
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
checks_in_area[location.player][dungeonname].append(location.address)
elif main_entrance.parent_region.type == RegionType.LightWorld:
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
outfilestuffs["difficulty"], # 1
outfilestuffs["item_functionality"], # 2
outfilestuffs["mode"], # 3
outfilestuffs["goal"], # 4
"" if outfilestuffs["timer"] in ['False', 'none', 'display'] else "-" + outfilestuffs["timer"], # 5
oldmancaves = []
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
for index, take_any in enumerate(takeanyregions):
for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if
world.retro[player]]:
item = world.create_item(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
region.player)
player = region.player
location_id = SHOP_ID_START + total_shop_slots + index
outfilestuffs["shuffle"], # 6
outfilestuffs["algorithm"], # 7
outfilestuffs["mscb"], # 8
main_entrance = get_entrance_to_region(region)
if main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[player]["Light World"].append(location_id)
else:
checks_in_area[player]["Dark World"].append(location_id)
checks_in_area[player]["Total"] += 1
"-retro" if outfilestuffs["retro"] == "True" else "", # 9
"-prog_" + outfilestuffs["progressive"] if outfilestuffs["progressive"] in ['off', 'random'] else "", # A
"-nohints" if not outfilestuffs["hints"] == "True" else "") # B
) if not args.outputname else ''
rompath = output_path(f'{outfilebase}{outfilepname}{outfilesuffix}.sfc')
rom.write_to_file(rompath, hide_enemizer=True)
if args.create_diff:
Patch.create_patch_file(rompath, player=player, player_name = world.player_names[player][team])
return player, team, bytes(rom.name)
er_hint_data[player][location_id] = main_entrance.name
oldmancaves.append(((location_id, player), (item.code, player)))
pool = concurrent.futures.ThreadPoolExecutor()
multidata_task = None
check_accessibility_task = pool.submit(world.fulfills_accessibility)
FillDisabledShopSlots(world)
rom_futures = []
mod_futures = []
for team in range(world.teams):
for player in world.alttp_player_ids:
rom_futures.append(pool.submit(_gen_rom, team, player))
for player in world.factorio_player_ids:
mod_futures.append(pool.submit(generate_mod, world, player))
def write_multidata():
import NetUtils
slot_data = {}
client_versions = {}
minimum_versions = {"server": (0, 1, 1), "clients": client_versions}
games = {}
for slot in world.player_ids:
client_versions[slot] = world.worlds[slot].get_required_client_version()
games[slot] = world.game[slot]
precollected_items = {player: [] for player in range(1, world.players + 1)}
for item in world.precollected_items:
precollected_items[item.player].append(item.code)
precollected_hints = {player: set() for player in range(1, world.players + 1)}
# for now special case Factorio tech_tree_information
sending_visible_players = set()
for player in world.get_game_players("Factorio"):
if world.tech_tree_information[player].value == 2:
sending_visible_players.add(player)
def get_entrance_to_region(region: Region):
for entrance in region.entrances:
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld):
return entrance
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
return get_entrance_to_region(entrance.parent_region)
for slot in world.player_ids:
slot_data[slot] = world.worlds[slot].fill_slot_data()
# collect ER hint info
er_hint_data = {player: {} for player in range(1, world.players + 1) if world.shuffle[player] != "vanilla" or world.retro[player]}
from worlds.alttp.Regions import RegionType
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
locations_data: Dict[int, Dict[int, Tuple[int, int]]] = {player: {} for player in world.player_ids}
for location in world.get_filled_locations():
if type(location.address) == int:
# item code None should be event, location.address should then also be None
assert location.item.code is not None
locations_data[location.player][location.address] = location.item.code, location.item.player
if location.player in sending_visible_players and location.item.player != location.player:
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False)
precollected_hints[location.player].add(hint)
precollected_hints[location.item.player].add(hint)
elif location.item.name in args.start_hints[location.item.player]:
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False,
er_hint_data.get(location.player, {}).get(location.address, ""))
precollected_hints[location.player].add(hint)
precollected_hints[location.item.player].add(hint)
ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
multidata = {
"slot_data": slot_data,
"games": games,
"names": [[name for player, name in sorted(world.player_name.items())]],
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
"remote_items": {player for player in world.player_ids if
world.worlds[player].remote_items},
"locations": locations_data,
"checks_in_area": checks_in_area,
"server_options": get_options()["server_options"],
"er_hint_data": er_hint_data,
"precollected_items": precollected_items,
"precollected_hints": precollected_hints,
"version": tuple(version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": world.seed_name
}
AutoWorld.call_all(world, "modify_multidata", multidata)
checks_in_area = {player: {area: list() for area in ordered_areas}
for player in range(1, world.players + 1)}
multidata = zlib.compress(pickle.dumps(multidata), 9)
for player in range(1, world.players + 1):
checks_in_area[player]["Total"] = 0
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([1])) # version of format
f.write(multidata)
for location in [loc for loc in world.get_filled_locations() if type(loc.address) is int]:
main_entrance = get_entrance_to_region(location.parent_region)
if location.game != Games.LTTP:
checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.dungeon:
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower',
'Inverted Ganons Tower': 'Ganons Tower'}\
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
checks_in_area[location.player][dungeonname].append(location.address)
elif main_entrance.parent_region.type == RegionType.LightWorld:
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
multidata_task = pool.submit(write_multidata)
if not check_accessibility_task.result():
if not world.can_beat_game():
raise Exception("Game appears as unbeatable. Aborting.")
else:
logger.warning("Location Accessibility requirements not fulfilled.")
oldmancaves = []
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
for index, take_any in enumerate(takeanyregions):
for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if world.retro[player]]:
item = ItemFactory(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'], region.player)
player = region.player
location_id = SHOP_ID_START + total_shop_slots + index
# retrieve exceptions via .result() if they occured.
if multidata_task:
multidata_task.result()
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures)):
if i % 10 == 0:
logger.info(f'Generating output files ({i}/{len(output_file_futures)}).')
future.result()
main_entrance = get_entrance_to_region(region)
if main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[player]["Light World"].append(location_id)
else:
checks_in_area[player]["Dark World"].append(location_id)
checks_in_area[player]["Total"] += 1
if not args.skip_playthrough:
logger.info('Calculating playthrough.')
create_playthrough(world)
er_hint_data[player][location_id] = main_entrance.name
oldmancaves.append(((location_id, player), (item.code, player)))
if args.create_spoiler:
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
FillDisabledShopSlots(world)
def write_multidata(roms, mods):
import base64
import NetUtils
for future in roms:
rom_name = future.result()
rom_names.append(rom_name)
slot_data = {}
client_versions = {}
minimum_versions = {"server": (0, 1, 1), "clients": client_versions}
games = {}
for slot in world.player_ids:
client_versions[slot] = (0, 0, 3)
games[slot] = world.game[slot]
connect_names = {base64.b64encode(rom_name).decode(): (team, slot) for
slot, team, rom_name in rom_names}
precollected_items = {player: [] for player in range(1, world.players+1)}
for item in world.precollected_items:
precollected_items[item.player].append(item.code)
precollected_hints = {player: set() for player in range(1, world.players+1)}
# for now special case Factorio visibility
sending_visible_players = set()
for player in world.factorio_player_ids:
if world.visibility[player]:
sending_visible_players.add(player)
for i, team in enumerate(parsed_names):
for player, name in enumerate(team, 1):
if player not in world.alttp_player_ids:
connect_names[name] = (i, player)
for slot in world.hk_player_ids:
slots_data = slot_data[slot] = {}
for option_name in Options.hollow_knight_options:
option = getattr(world, option_name)[slot]
slots_data[option_name] = int(option.value)
for slot in world.minecraft_player_ids:
slot_data[slot] = fill_minecraft_slot_data(world, slot)
locations_data: Dict[int, Dict[int, Tuple[int, int]]] = {player: {} for player in world.player_ids}
for location in world.get_filled_locations():
if type(location.address) == int:
locations_data[location.player][location.address] = (location.item.code, location.item.player)
if location.player in sending_visible_players and location.item.player != location.player:
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False)
precollected_hints[location.player].add(hint)
precollected_hints[location.item.player].add(hint)
elif location.item.name in args.start_hints[location.item.player]:
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False,
er_hint_data.get(location.player, {}).get(location.address, ""))
precollected_hints[location.player].add(hint)
precollected_hints[location.item.player].add(hint)
multidata = zlib.compress(pickle.dumps({
"slot_data" : slot_data,
"games": games,
"names": parsed_names,
"connect_names": connect_names,
"remote_items": {player for player in range(1, world.players + 1) if
world.remote_items[player]},
"locations": locations_data,
"checks_in_area": checks_in_area,
"server_options": get_options()["server_options"],
"er_hint_data": er_hint_data,
"precollected_items": precollected_items,
"precollected_hints": precollected_hints,
"version": tuple(_version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": world.seed_name
}), 9)
with open(output_path('%s.archipelago' % outfilebase), 'wb') as f:
f.write(bytes([1])) # version of format
f.write(multidata)
for future in mods:
future.result() # collect errors if they occured
multidata_task = pool.submit(write_multidata, rom_futures, mod_futures)
if not check_accessibility_task.result():
if not world.can_beat_game():
raise Exception("Game appears as unbeatable. Aborting.")
else:
logger.warning("Location Accessibility requirements not fulfilled.")
if multidata_task:
multidata_task.result() # retrieve exception if one exists
pool.shutdown() # wait for all queued tasks to complete
for player in world.minecraft_player_ids: # Doing this after shutdown prevents the .apmc from being generated if there's an error
generate_mc_data(world, player)
if not args.skip_playthrough:
logger.info('Calculating playthrough.')
create_playthrough(world)
if args.create_spoiler: # needs spoiler.hashes to be filled, that depend on rom_futures being done
world.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
zipfilename = output_path(f"AP_{world.seed_name}.zip")
logger.info(f'Creating final archive at {zipfilename}.')
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
for file in os.scandir(temp_dir):
zf.write(file.path, arcname=file.name)
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
return world
@@ -599,9 +374,9 @@ def create_playthrough(world):
sphere_candidates = set(prog_locations)
logging.debug('Building up collection spheres.')
while sphere_candidates:
state.sweep_for_events(key_only=True)
# build up spheres of collection radius. Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
sphere = {location for location in sphere_candidates if state.can_reach(location)}
@@ -632,7 +407,8 @@ def create_playthrough(world):
to_delete = set()
for location in sphere:
# we remove the item at location and check if game is still beatable
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name, location.item.player)
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
location.item.player)
old_item = location.item
location.item = None
if world.can_beat_game(state_cache[num]):
@@ -677,7 +453,8 @@ def create_playthrough(world):
collection_spheres.append(sphere)
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere), len(required_locations))
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
len(sphere), len(required_locations))
if not sphere:
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
@@ -694,16 +471,22 @@ def create_playthrough(world):
pathpairs = zip_longest(pathsiter, pathsiter)
return list(pathpairs)
world.spoiler.paths = dict()
for player in range(1, world.players + 1):
world.spoiler.paths.update({ str(location) : get_path(state, location.parent_region) for sphere in collection_spheres for location in sphere if location.player == player})
if player in world.alttp_player_ids:
for path in dict(world.spoiler.paths).values():
if any(exit == 'Pyramid Fairy' for (_, exit) in path):
if world.mode[player] != 'inverted':
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = get_path(state, world.get_region('Big Bomb Shop', player))
else:
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = get_path(state, world.get_region('Inverted Big Bomb Shop', player))
world.spoiler.paths = {}
topology_worlds = (player for player in world.player_ids if world.worlds[player].topology_present)
for player in topology_worlds:
world.spoiler.paths.update(
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
sphere if location.player == player})
if player in world.get_game_players("A Link to the Past"):
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
# Maybe move the big bomb over to the Event system instead?
if any(exit_path == 'Pyramid Fairy' for path in world.spoiler.paths.values() for (_, exit_path) in path):
if world.mode[player] != 'inverted':
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = \
get_path(state, world.get_region('Big Bomb Shop', player))
else:
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = \
get_path(state, world.get_region('Inverted Big Bomb Shop', player))
# we can finally output our playthrough
world.spoiler.playthrough = {"0": sorted([str(item) for item in world.precollected_items if item.advancement])}

185
MinecraftClient.py Normal file
View File

@@ -0,0 +1,185 @@
import argparse
import os, sys
import re
import atexit
from subprocess import Popen
from shutil import copyfile
from base64 import b64decode
from time import strftime
import requests
import Utils
atexit.register(input, "Press enter to exit.")
# 1 or more digits followed by m or g, then optional b
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
def prompt_yes_no(prompt):
yes_inputs = {'yes', 'ye', 'y'}
no_inputs = {'no', 'n'}
while True:
choice = input(prompt + " [y/n] ").lower()
if choice in yes_inputs:
return True
elif choice in no_inputs:
return False
else:
print('Please respond with "y" or "n".')
# Find Forge jar file; raise error if not found
def find_forge_jar(forge_dir):
for entry in os.scandir(forge_dir):
if ".jar" in entry.name and "forge" in entry.name:
print(f"Found forge .jar: {entry.name}")
return entry.name
raise FileNotFoundError(f"Could not find forge .jar in {forge_dir}.")
# Create mods folder if needed; find AP randomizer jar; return None if not found.
def find_ap_randomizer_jar(forge_dir):
mods_dir = os.path.join(forge_dir, 'mods')
if os.path.isdir(mods_dir):
ap_mod_re = re.compile(r"^aprandomizer-[\d\.]+\.jar$")
for entry in os.scandir(mods_dir):
match = ap_mod_re.match(entry.name)
if match:
print(f"Found AP randomizer mod: {match.group()}")
return match.group()
return None
else:
os.mkdir(mods_dir)
print(f"Created mods folder in {forge_dir}")
return None
# Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory.
def replace_apmc_files(forge_dir, apmc_file):
if apmc_file is None:
return
apdata_dir = os.path.join(forge_dir, 'APData')
copy_apmc = True
if not os.path.isdir(apdata_dir):
os.mkdir(apdata_dir)
print(f"Created APData folder in {forge_dir}")
for entry in os.scandir(apdata_dir):
if entry.name.endswith(".apmc") and entry.is_file():
if not os.path.samefile(apmc_file, entry.path):
os.remove(entry.path)
print(f"Removed {entry.name} in {apdata_dir}")
else: # apmc already in apdata
copy_apmc = False
if copy_apmc:
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
print(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
# Check mod version, download new mod from GitHub releases page if needed.
def update_mod(forge_dir):
ap_randomizer = find_ap_randomizer_jar(forge_dir)
client_releases_endpoint = "https://api.github.com/repos/KonoTyran/Minecraft_AP_Randomizer/releases"
resp = requests.get(client_releases_endpoint)
if resp.status_code == 200: # OK
latest_release = resp.json()[0]
if ap_randomizer != latest_release['assets'][0]['name']:
print(f"A new release of the Minecraft AP randomizer mod was found: {latest_release['assets'][0]['name']}")
if ap_randomizer is not None:
print(f"Your current mod is {ap_randomizer}.")
else:
print(f"You do not have the AP randomizer mod installed.")
if prompt_yes_no("Would you like to update?"):
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
new_ap_mod = os.path.join(forge_dir, 'mods', latest_release['assets'][0]['name'])
print("Downloading AP randomizer mod. This may take a moment...")
apmod_resp = requests.get(latest_release['assets'][0]['browser_download_url'])
if apmod_resp.status_code == 200:
with open(new_ap_mod, 'wb') as f:
f.write(apmod_resp.content)
print(f"Wrote new mod file to {new_ap_mod}")
if old_ap_mod is not None:
os.remove(old_ap_mod)
print(f"Removed old mod file from {old_ap_mod}")
else:
print(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
print(f"Please report this issue on the Archipelago Discord server.")
sys.exit(1)
else:
print(f"Error checking for randomizer mod updates (status code {resp.status_code}).")
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
if not prompt_yes_no("Continue anyways?"):
sys.exit(0)
# Check if the EULA is agreed to, and prompt the user to read and agree if necessary.
def check_eula(forge_dir):
eula_path = os.path.join(forge_dir, "eula.txt")
if not os.path.isfile(eula_path):
# Create eula.txt
with open(eula_path, 'w') as f:
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
f.write("eula=false\n")
with open(eula_path, 'r+') as f:
text = f.read()
if 'false' in text:
# Prompt user to agree to the EULA
print("You need to agree to the Minecraft EULA in order to run the server.")
print("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
if prompt_yes_no("Do you agree to the EULA?"):
f.seek(0)
f.write(text.replace('false', 'true'))
f.truncate()
print(f"Set {eula_path} to true")
else:
sys.exit(0)
# Run the Forge server. Return process object
def run_forge_server(forge_dir, heap_arg):
forge_server = find_forge_jar(forge_dir)
java_exe = os.path.abspath(os.path.join('jre8', 'bin', 'java.exe'))
if not os.path.isfile(java_exe):
java_exe = "java" # try to fall back on java in the PATH
heap_arg = max_heap_re.match(max_heap).group()
if heap_arg[-1] in ['b', 'B']:
heap_arg = heap_arg[:-1]
heap_arg = "-Xmx" + heap_arg
argstring = ' '.join([java_exe, heap_arg, "-jar", forge_server, "-nogui"])
print(f"Running Forge server: {argstring}")
os.chdir(forge_dir)
return Popen(argstring)
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
args = parser.parse_args()
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
# Change to executable's working directory
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
options = Utils.get_options()
forge_dir = options["minecraft_options"]["forge_directory"]
max_heap = options["minecraft_options"]["max_heap_size"]
if apmc_file is not None and not os.path.isfile(apmc_file):
raise FileNotFoundError(f"Path {apmc_file} does not exist or could not be accessed.")
if not os.path.isdir(forge_dir):
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
if not max_heap_re.match(max_heap):
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
update_mod(forge_dir)
replace_apmc_files(forge_dir, apmc_file)
check_eula(forge_dir)
server_process = run_forge_server(forge_dir, max_heap)
server_process.wait()

View File

@@ -1,56 +1,59 @@
import os
import sys
import subprocess
import importlib
import pkg_resources
requirements_files = {'requirements.txt'}
if sys.version_info < (3, 8, 6):
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
update_ran = hasattr(sys, "frozen") and getattr(sys, "frozen") # don't run update if environment is frozen/compiled
update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled
if not update_ran:
for entry in os.scandir("worlds"):
if entry.is_dir():
req_file = os.path.join(entry.path, "requirements.txt")
if os.path.exists(req_file):
requirements_files.add(req_file)
def update_command():
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', 'requirements.txt', '--upgrade'])
for file in requirements_files:
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade'])
naming_specialties = {"PyYAML": "yaml", # PyYAML is imported as the name yaml
"maseya-z3pr": "maseya",
"factorio-rcon-py": "factorio_rcon"}
def update():
def update(yes = False, force = False):
global update_ran
if not update_ran:
update_ran = True
path = os.path.join(os.path.dirname(sys.argv[0]), 'requirements.txt')
if not os.path.exists(path):
path = os.path.join(os.path.dirname(__file__), 'requirements.txt')
with open(path) as requirementsfile:
for line in requirementsfile.readlines():
module, remote_version = line.split(">=")
module = naming_specialties.get(module, module)
try:
module = importlib.import_module(module)
except:
import traceback
traceback.print_exc()
input(f'Required python module {module} not found, press enter to install it')
update_command()
return
else:
if hasattr(module, "__version__"):
module_version = module.__version__
module = module.__name__ # also unloads the module to make it writable
if type(module_version) == str:
module_version = tuple(int(part.strip()) for part in module_version.split("."))
remote_version = tuple(int(part.strip()) for part in remote_version.split("."))
if module_version < remote_version:
input(f'Required python module {module} is outdated ({module_version}<{remote_version}),'
' press enter to upgrade it')
update_command()
return
if force:
update_command()
return
for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
if not os.path.exists(path):
path = os.path.join(os.path.dirname(__file__), req_file)
with open(path) as requirementsfile:
requirements = pkg_resources.parse_requirements(requirementsfile)
for requirement in requirements:
requirement = str(requirement)
try:
pkg_resources.require(requirement)
except pkg_resources.ResolutionError:
if not yes:
import traceback
traceback.print_exc()
input(f'Requirement {requirement} is not satisfied, press enter to install it')
update_command()
return
if __name__ == "__main__":
update()
import argparse
parser = argparse.ArgumentParser(description='Install archipelago requirements')
parser.add_argument('-y', '--yes', dest='yes', action='store_true', help='answer "yes" to all questions')
parser.add_argument('-f', '--force', dest='force', action='store_true', help='force update')
args = parser.parse_args()
update(args.yes, args.force)

View File

@@ -1,232 +0,0 @@
import os
import subprocess
import sys
import threading
import concurrent.futures
import argparse
import logging
import random
def feedback(text: str):
logging.info(text)
input("Press Enter to ignore and probably crash.")
if __name__ == "__main__":
logging.basicConfig(format='%(message)s', level=logging.INFO)
try:
import ModuleUpdate
ModuleUpdate.update()
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--disable_autohost', action='store_true')
args = parser.parse_args()
from Utils import get_public_ipv4, get_options
from Mystery import get_seed_name
options = get_options()
multi_mystery_options = options["multi_mystery_options"]
output_path = options["general_options"]["output_path"]
enemizer_path = multi_mystery_options["enemizer_path"]
player_files_path = multi_mystery_options["player_files_path"]
target_player_count = multi_mystery_options["players"]
glitch_triforce = multi_mystery_options["glitch_triforce_room"]
race = multi_mystery_options["race"]
plando_options = multi_mystery_options["plando_options"]
create_spoiler = multi_mystery_options["create_spoiler"]
zip_roms = multi_mystery_options["zip_roms"]
zip_diffs = multi_mystery_options["zip_diffs"]
zip_apmcs = multi_mystery_options["zip_apmcs"]
zip_spoiler = multi_mystery_options["zip_spoiler"]
zip_multidata = multi_mystery_options["zip_multidata"]
zip_format = multi_mystery_options["zip_format"]
# zip_password = multi_mystery_options["zip_password"] not at this time
player_name = multi_mystery_options["player_name"]
meta_file_path = multi_mystery_options["meta_file_path"]
weights_file_path = multi_mystery_options["weights_file_path"]
pre_roll = multi_mystery_options["pre_roll"]
teams = multi_mystery_options["teams"]
rom_file = options["lttp_options"]["rom_file"]
host = options["server_options"]["host"]
port = options["server_options"]["port"]
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
if not os.path.exists(enemizer_path):
feedback(
f"Enemizer not found at {enemizer_path}, please adjust the path in MultiMystery.py's config or put Enemizer in the default location.")
if not os.path.exists(rom_file):
feedback(f"Base rom is expected as {rom_file} in the Multiworld root folder please place/rename it there.")
player_files = []
os.makedirs(player_files_path, exist_ok=True)
for file in os.listdir(player_files_path):
lfile = file.lower()
if lfile.endswith(".yaml") and lfile != meta_file_path.lower() and lfile != weights_file_path.lower():
player_files.append(file)
logging.info(f"Found player's file {file}.")
player_string = ""
for i, file in enumerate(player_files, 1):
player_string += f"--p{i} \"{os.path.join(player_files_path, file)}\" "
if os.path.exists("ArchipelagoMystery.exe"):
basemysterycommand = "ArchipelagoMystery.exe" # compiled windows
elif os.path.exists("ArchipelagoMystery"):
basemysterycommand = "ArchipelagoMystery" # compiled linux
else:
basemysterycommand = f"py -{py_version} Mystery.py" # source
weights_file_path = os.path.join(player_files_path, weights_file_path)
if os.path.exists(weights_file_path):
target_player_count = max(len(player_files), target_player_count)
else:
target_player_count = len(player_files)
if target_player_count == 0:
feedback(f"No player files found. Please put them in a {player_files_path} folder.")
else:
logging.info(f"{target_player_count} Players found.")
seed_name = get_seed_name(random)
command = f"{basemysterycommand} --multi {target_player_count} {player_string} " \
f"--rom \"{rom_file}\" --enemizercli \"{enemizer_path}\" " \
f"--outputpath \"{output_path}\" --teams {teams} --plando \"{plando_options}\" " \
f"--seed_name {seed_name}"
if create_spoiler:
command += " --create_spoiler"
if create_spoiler == 2:
command += " --skip_playthrough"
if zip_diffs:
command += " --create_diff"
if glitch_triforce:
command += " --glitch_triforce"
if race:
command += " --race"
if os.path.exists(os.path.join(player_files_path, meta_file_path)):
command += f" --meta {os.path.join(player_files_path, meta_file_path)}"
if os.path.exists(weights_file_path):
command += f" --weights {weights_file_path}"
if pre_roll:
command += " --pre_roll"
logging.info(command)
import time
start = time.perf_counter()
text = subprocess.check_output(command, shell=True).decode()
logging.info(f"Took {time.perf_counter() - start:.3f} seconds to generate multiworld.")
multidataname = f"AP_{seed_name}.archipelago"
spoilername = f"AP_{seed_name}_Spoiler.txt"
romfilename = ""
if player_name:
for file in os.listdir(output_path):
if player_name in file:
import MultiClient
import asyncio
asyncio.run(MultiClient.run_game(os.path.join(output_path, file)))
break
if any((zip_roms, zip_multidata, zip_spoiler, zip_diffs, zip_apmcs)):
import zipfile
compression = {1: zipfile.ZIP_DEFLATED,
2: zipfile.ZIP_LZMA,
3: zipfile.ZIP_BZIP2}[zip_format]
typical_zip_ending = {1: "zip",
2: "7z",
3: "bz2"}[zip_format]
ziplock = threading.Lock()
def pack_file(file: str):
with ziplock:
zf.write(os.path.join(output_path, file), file)
logging.info(f"Packed {file} into zipfile {zipname}")
def remove_zipped_file(file: str):
os.remove(os.path.join(output_path, file))
logging.info(f"Removed {file} which is now present in the zipfile")
zipname = os.path.join(output_path, f"AP_{seed_name}.{typical_zip_ending}")
logging.info(f"Creating zipfile {zipname}")
ipv4 = (host if host else get_public_ipv4()) + ":" + str(port)
def _handle_sfc_file(file: str):
if zip_roms:
pack_file(file)
if zip_roms == 2 and player_name.lower() not in file.lower():
remove_zipped_file(file)
def _handle_diff_file(file: str):
if zip_diffs > 0:
pack_file(file)
if zip_diffs == 2:
remove_zipped_file(file)
def _handle_apmc_file(file: str):
if zip_apmcs:
pack_file(file)
if zip_apmcs == 2:
remove_zipped_file(file)
with concurrent.futures.ThreadPoolExecutor() as pool:
futures = []
files = os.listdir(output_path)
with zipfile.ZipFile(zipname, "w", compression=compression, compresslevel=9) as zf:
for file in files:
if seed_name in file:
if file.endswith(".sfc"):
futures.append(pool.submit(_handle_sfc_file, file))
elif file.endswith(".apbp"):
futures.append(pool.submit(_handle_diff_file, file))
elif file.endswith(".apmc"):
futures.append(pool.submit(_handle_apmc_file, file))
# just handle like a diff file for now
elif file.endswith(".zip"):
futures.append(pool.submit(_handle_diff_file, file))
if zip_multidata and os.path.exists(os.path.join(output_path, multidataname)):
pack_file(multidataname)
if zip_multidata == 2:
remove_zipped_file(multidataname)
if zip_spoiler and create_spoiler:
pack_file(spoilername)
if zip_spoiler == 2:
remove_zipped_file(spoilername)
for future in futures:
future.result() # make sure we close the zip AFTER any packing is done
if not args.disable_autohost:
if os.path.exists(os.path.join(output_path, multidataname)):
if os.path.exists("ArchipelagoServer.exe"):
baseservercommand = "ArchipelagoServer.exe" # compiled windows
elif os.path.exists("ArchipelagoServer"):
baseservercommand = "ArchipelagoServer" # compiled linux
else:
baseservercommand = f"py -{py_version} MultiServer.py" # source
# don't have a mac to test that. If you try to run compiled on mac, good luck.
subprocess.call(f"{baseservercommand} --multidata {os.path.join(output_path, multidataname)}")
except:
import traceback
traceback.print_exc()
input("Press enter to close")

View File

@@ -25,19 +25,16 @@ import prompt_toolkit
from prompt_toolkit.patch_stdout import patch_stdout
from fuzzywuzzy import process as fuzzy_process
from worlds.alttp import Items, Regions
from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_item_name_to_id, \
lookup_any_location_id_to_name, lookup_any_location_name_to_id
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
from Utils import get_item_name_from_id, get_location_name_from_id, \
_version_tuple, restricted_loads, Version
from NetUtils import Node, Endpoint, ClientStatus, NetworkItem, decode, NetworkPlayer
version_tuple, restricted_loads, Version
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer
colorama.init()
lttp_console_names = frozenset(set(Items.item_table) | set(Items.item_name_groups) | set(Regions.lookup_name_to_id))
all_items = frozenset(lookup_any_item_name_to_id)
all_locations = frozenset(lookup_any_location_name_to_id)
all_console_names = frozenset(all_items | all_locations)
class Client(Endpoint):
version = Version(0, 0, 0)
@@ -55,7 +52,13 @@ class Client(Endpoint):
self.ctx = weakref.ref(ctx)
class Context(Node):
team_slot = typing.Tuple[int, int]
class Context:
dumper = staticmethod(encode)
loader = staticmethod(decode)
simple_options = {"hint_cost": int,
"location_check_points": int,
"server_password": str,
@@ -67,18 +70,21 @@ class Context(Node):
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", remaining_mode: str = "disabled",
auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2):
auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, log_network: bool = False):
super(Context, self).__init__()
self.log_network = log_network
self.endpoints = []
self.compatibility: int = compatibility
self.shutdown_task = None
self.data_filename = None
self.save_filename = None
self.saving = False
self.player_names = {}
self.connect_names = {} # names of slots clients can connect to
self.player_names: typing.Dict[team_slot, str] = {}
self.player_name_lookup: typing.Dict[str, team_slot] = {}
self.connect_names = {} # names of slots clients can connect to
self.allow_forfeits = {}
self.remote_items = set()
self.locations:typing.Dict[int, typing.Dict[int, typing.Tuple[int, int]]] = {}
self.locations: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int]]] = {}
self.host = host
self.port = port
self.server_password = server_password
@@ -86,21 +92,21 @@ class Context(Node):
self.server = None
self.countdown_timer = 0
self.received_items = {}
self.name_aliases: typing.Dict[typing.Tuple[int, int], str] = {}
self.name_aliases: typing.Dict[team_slot, str] = {}
self.location_checks = collections.defaultdict(set)
self.hint_cost = hint_cost
self.location_check_points = location_check_points
self.hints_used = collections.defaultdict(int)
self.hints: typing.Dict[typing.Tuple[int, int], typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
self.forfeit_mode: str = forfeit_mode
self.remaining_mode: str = remaining_mode
self.item_cheat = item_cheat
self.running = True
self.client_activity_timers: typing.Dict[
typing.Tuple[int, int], datetime.datetime] = {} # datetime of last new item check
team_slot, datetime.datetime] = {} # datetime of last new item check
self.client_connection_timers: typing.Dict[
typing.Tuple[int, int], datetime.datetime] = {} # datetime of last connection
self.client_game_state: typing.Dict[typing.Tuple[int, int], int] = collections.defaultdict(int)
team_slot, datetime.datetime] = {} # datetime of last connection
self.client_game_state: typing.Dict[team_slot, int] = collections.defaultdict(int)
self.er_hint_data: typing.Dict[int, typing.Dict[int, str]] = {}
self.auto_shutdown = auto_shutdown
self.commandprocessor = ServerCommandProcessor(self)
@@ -110,20 +116,91 @@ class Context(Node):
self.auto_saver_thread = None
self.save_dirty = False
self.tags = ['AP']
self.games = {}
self.games: typing.Dict[int, str] = {}
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
self.seed_name = ""
self.random = random.Random()
def get_hint_cost(self, slot):
if self.hint_cost:
return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0
# General networking
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
if not endpoint.socket or not endpoint.socket.open:
return False
msg = self.dumper(msgs)
try:
await endpoint.socket.send(msg)
except websockets.ConnectionClosed:
logging.exception(f"Exception during send_msgs, could not send {msg}")
await self.disconnect(endpoint)
else:
if self.log_network:
logging.info(f"Outgoing message: {msg}")
return True
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
if not endpoint.socket or not endpoint.socket.open:
return False
try:
await endpoint.socket.send(msg)
except websockets.ConnectionClosed:
logging.exception("Exception during send_encoded_msgs")
await self.disconnect(endpoint)
else:
if self.log_network:
logging.info(f"Outgoing message: {msg}")
return True
def broadcast_all(self, msgs):
msgs = self.dumper(msgs)
for endpoint in self.endpoints:
if endpoint.auth:
asyncio.create_task(self.send_encoded_msgs(endpoint, msgs))
def broadcast_team(self, team, msgs):
msgs = self.dumper(msgs)
for client in self.endpoints:
if client.auth and client.team == team:
asyncio.create_task(self.send_encoded_msgs(client, msgs))
async def disconnect(self, endpoint):
if endpoint in self.endpoints:
self.endpoints.remove(endpoint)
await on_client_disconnected(self, endpoint)
# text
def notify_all(self, text):
logging.info("Notice (all): %s" % text)
self.broadcast_all([{"cmd": "Print", "text": text}])
def notify_client(self, client: Client, text: str):
if not client.auth:
return
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}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
if not client.auth:
return
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
# loading
def load(self, multidatapath: str, use_embedded_server_options: bool = False):
with open(multidatapath, 'rb') as f:
data = f.read()
if multidatapath.lower().endswith(".zip"):
import zipfile
with zipfile.ZipFile(multidatapath) as zf:
for file in zf.namelist():
if file.endswith(".archipelago"):
data = zf.read(file)
break
else:
raise Exception("No .archipelago found in archive.")
else:
with open(multidatapath, 'rb') as f:
data = f.read()
self._load(self._decompress(data), use_embedded_server_options)
self._load(self._decompress(data), use_embedded_server_options)
self.data_filename = multidatapath
@staticmethod
@@ -136,9 +213,9 @@ class Context(Node):
def _load(self, decoded_obj: dict, use_embedded_server_options: bool):
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > Utils._version_tuple:
if mdata_ver > Utils.version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
f"however this server is of version {Utils._version_tuple}")
f"however this server is of version {Utils.version_tuple}")
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {}
for player, version in clients_ver.items():
@@ -146,8 +223,10 @@ class Context(Node):
for team, names in enumerate(decoded_obj['names']):
for player, name in enumerate(names, 1):
self.player_names[(team, player)] = name
self.player_names[team, player] = name
self.player_name_lookup[name] = team, player
self.seed_name = decoded_obj["seed_name"]
self.random.seed(self.seed_name)
self.connect_names = decoded_obj['connect_names']
self.remote_items = decoded_obj['remote_items']
self.locations = decoded_obj['locations']
@@ -166,28 +245,7 @@ class Context(Node):
server_options = decoded_obj.get("server_options", {})
self._set_options(server_options)
def get_players_package(self):
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
def _set_options(self, server_options: dict):
for key, value in server_options.items():
data_type = self.simple_options.get(key, None)
if data_type is not None:
if value not in {False, True, None}: # some can be boolean OR text, such as password
try:
value = data_type(value)
except Exception as e:
try:
raise Exception(f"Could not set server option {key}, skipping.") from e
except Exception as e:
logging.exception(e)
logging.debug(f"Setting server option {key} to {value} from supplied multidata")
setattr(self, key, value)
elif key == "disable_item_cheat":
self.item_cheat = not bool(value)
else:
logging.debug(f"Unrecognized server option {key}")
# saving
def save(self, now=False) -> bool:
if self.saving:
@@ -200,7 +258,7 @@ class Context(Node):
return False
def _save(self, exit_save:bool=False) -> bool:
def _save(self, exit_save: bool = False) -> bool:
try:
encoded_save = pickle.dumps(self.get_save())
with open(self.save_filename, "wb") as f:
@@ -215,8 +273,10 @@ class Context(Node):
self.saving = enabled
if self.saving:
if not self.save_filename:
self.save_filename = (self.data_filename[:-11] if self.data_filename.endswith('.archipelago') else (
self.data_filename + '_')) + 'apsave'
import os
name, ext = os.path.splitext(self.data_filename)
self.save_filename = name + '.apsave' if ext.lower() in ('.archipelago','.zip') \
else self.data_filename + '_' + 'apsave'
try:
with open(self.save_filename, 'rb') as f:
save_data = restricted_loads(zlib.decompress(f.read()))
@@ -245,6 +305,7 @@ class Context(Node):
atexit.register(self._save, True) # make sure we save on exit too
def get_save(self) -> dict:
self.recheck_hints()
d = {
"connect_names": self.connect_names,
"received_items": self.received_items,
@@ -257,6 +318,7 @@ class Context(Node):
(key, value.timestamp()) for key, value in self.client_activity_timers.items()),
"client_connection_timers": tuple(
(key, value.timestamp()) for key, value in self.client_connection_timers.items()),
"random_state": self.random.getstate()
}
return d
@@ -277,47 +339,53 @@ class Context(Node):
{tuple(key): datetime.datetime.fromtimestamp(value, datetime.timezone.utc) for key, value
in savedata["client_activity_timers"]})
self.location_checks.update(savedata["location_checks"])
if "random_state" in savedata:
self.random.setstate(savedata["random_state"])
logging.info(f'Loaded save file with {sum([len(p) for p in self.received_items.values()])} received items '
f'for {len(self.received_items)} players')
# rest
def get_hint_cost(self, slot):
if self.hint_cost:
return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0
def recheck_hints(self):
for team, slot in self.hints:
self.hints[team, slot] = {
hint.re_check(self, team) for hint in
self.hints[team, slot]
}
def get_players_package(self):
return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
def _set_options(self, server_options: dict):
for key, value in server_options.items():
data_type = self.simple_options.get(key, None)
if data_type is not None:
if value not in {False, True, None}: # some can be boolean OR text, such as password
try:
value = data_type(value)
except Exception as e:
try:
raise Exception(f"Could not set server option {key}, skipping.") from e
except Exception as e:
logging.exception(e)
logging.debug(f"Setting server option {key} to {value} from supplied multidata")
setattr(self, key, value)
elif key == "disable_item_cheat":
self.item_cheat = not bool(value)
else:
logging.debug(f"Unrecognized server option {key}")
def get_aliased_name(self, team: int, slot: int):
if (team, slot) in self.name_aliases:
return f"{self.name_aliases[team, slot]} ({self.player_names[team, slot]})"
else:
return self.player_names[team, slot]
def notify_all(self, text):
logging.info("Notice (all): %s" % text)
self.broadcast_all([{"cmd": "Print", "text": text}])
def notify_client(self, client: Client, text: str):
if not client.auth:
return
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}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
if not client.auth:
return
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
def broadcast_team(self, team, msgs):
msgs = self.dumper(msgs)
for client in self.endpoints:
if client.auth and client.team == team:
asyncio.create_task(self.send_encoded_msgs(client, msgs))
def broadcast_all(self, msgs):
msgs = self.dumper(msgs)
for endpoint in self.endpoints:
if endpoint.auth:
asyncio.create_task(self.send_encoded_msgs(endpoint, msgs))
async def disconnect(self, endpoint):
await super(Context, self).disconnect(endpoint)
await on_client_disconnected(self, endpoint)
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint]):
concerns = collections.defaultdict(list)
@@ -366,7 +434,8 @@ async def server(websocket, path, ctx: Context):
if not isinstance(e, websockets.WebSocketException):
logging.exception(e)
finally:
logging.info("Disconnected")
if ctx.log_network:
logging.info("Disconnected")
await ctx.disconnect(client)
@@ -374,17 +443,21 @@ async def on_client_connected(ctx: Context, client: Client):
await ctx.send_msgs(client, [{
'cmd': 'RoomInfo',
'password': ctx.password is not None,
'players': [NetworkPlayer(client.team, client.slot, ctx.name_aliases.get((client.team, client.slot), client.name), client.name) for client
in ctx.endpoints if client.auth],
'players': [
NetworkPlayer(client.team, client.slot, ctx.name_aliases.get((client.team, client.slot), client.name),
client.name) for client
in ctx.endpoints if client.auth],
# tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate.
'tags': ctx.tags,
'version': Utils._version_tuple,
'version': Utils.version_tuple,
'forfeit_mode': ctx.forfeit_mode,
'remaining_mode': ctx.remaining_mode,
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points,
'datapackage_version': network_data_package["version"],
'datapackage_versions': {game: game_data["version"] for game, game_data
in network_data_package["games"].items()},
'seed_name': ctx.seed_name
}])
@@ -398,14 +471,17 @@ async def on_client_joined(ctx: Context, client: Client):
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
version_str = '.'.join(str(x) for x in client.version)
ctx.notify_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has joined the game. "
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
f"playing {ctx.games[client.slot]} has joined. "
f"Client({version_str}), {client.tags}).")
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
async def on_client_left(ctx: Context, client: Client):
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
ctx.notify_all("%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1))
ctx.notify_all(
"%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1))
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
@@ -447,7 +523,7 @@ def get_received_items(ctx: Context, team: int, player: int) -> typing.List[Netw
def send_new_items(ctx: Context):
for client in ctx.endpoints:
if client.auth: # can't send to disconnected client
if client.auth: # can't send to disconnected client
items = get_received_items(ctx, client.team, client.slot)
if len(items) > client.send_index:
asyncio.create_task(ctx.send_msgs(client, [{
@@ -504,10 +580,9 @@ def notify_team(ctx: Context, team: int, text: str):
ctx.broadcast_team(team, [['Print', {"text": text}]])
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
hints = []
seeked_item_id = lookup_any_item_name_to_id[item]
seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item]
for finding_player, check_data in ctx.locations.items():
for location_id, result in check_data.items():
item_id, receiving_player = result
@@ -520,8 +595,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[
def collect_hints_location(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
seeked_location: int = Regions.lookup_name_to_id[location]
seeked_location: int = proxy_worlds[ctx.games[slot]].location_name_to_id[location]
item_id, receiving_player = ctx.locations[slot].get(seeked_location, (None, None))
if item_id:
found = seeked_location in ctx.location_checks[team, slot]
@@ -540,6 +614,7 @@ def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
text += f" at {hint.entrance}"
return text + (". (found)" if hint.found else ".")
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
parts = []
NetUtils.add_json_text(parts, net_item.player, type=NetUtils.JSONTypes.player_id)
@@ -557,9 +632,11 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
NetUtils.add_json_text(parts, ")")
return {"cmd": "PrintJSON", "data": parts, "type": "ItemSend",
"receiving": receiving_player, "sending": net_item.player}
"receiving": receiving_player,
"item": net_item}
def get_intended_text(input_text: str, possible_answers: typing.Iterable[str]= all_console_names) -> typing.Tuple[str, bool, str]:
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
picks = fuzzy_process.extract(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
@@ -684,11 +761,12 @@ class CommonCommandProcessor(CommandProcessor):
"""List all current options. Warning: lists password."""
self.output("Current options:")
for option in self.ctx.simple_options:
if option == "server_password" and self.marker == "!": #Do not display the server password to the client.
self.output(f"Option server_password is set to {('*' * random.randint(4,16))}")
if option == "server_password" and self.marker == "!": # Do not display the server password to the client.
self.output(f"Option server_password is set to {('*' * random.randint(4, 16))}")
else:
self.output(f"Option {option} is set to {getattr(self.ctx, option)}")
class ClientMessageProcessor(CommonCommandProcessor):
marker = "!"
@@ -715,11 +793,14 @@ class ClientMessageProcessor(CommonCommandProcessor):
"""Allow remote administration of the multiworld server"""
output = f"!admin {command}"
if output.lower().startswith("!admin login"): # disallow others from seeing the supplied password, whether or not it is correct.
if output.lower().startswith(
"!admin login"): # disallow others from seeing the supplied password, whether or not it is correct.
output = f"!admin login {('*' * random.randint(4, 16))}"
elif output.lower().startswith("!admin /option server_password"): # disallow others from knowing what the new remote administration password is.
elif output.lower().startswith(
"!admin /option server_password"): # disallow others from knowing what the new remote administration password is.
output = f"!admin /option server_password {('*' * random.randint(4, 16))}"
self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + output) # Otherwise notify the others what is happening.
self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team,
self.client.slot) + ': ' + output) # Otherwise notify the others what is happening.
if not self.ctx.server_password:
self.output("Sorry, Remote administration is disabled")
@@ -727,7 +808,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
if not command:
if self.is_authenticated():
self.output("Usage: !admin [Server command].\nUse !admin /help for help.\nUse !admin logout to log out of the current session.")
self.output(
"Usage: !admin [Server command].\nUse !admin /help for help.\nUse !admin logout to log out of the current session.")
else:
self.output("Usage: !admin login [password]")
return True
@@ -810,7 +892,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
"Sorry, !remaining requires you to have beaten the game on this server")
return False
def _cmd_missing(self) -> bool:
"""List all missing location checks from the server's perspective"""
@@ -846,11 +927,15 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_getitem(self, item_name: str) -> bool:
"""Cheat in an item, if it is enabled on this server"""
if self.ctx.item_cheat:
item_name, usable, response = get_intended_text(item_name, Items.item_table.keys())
world = proxy_worlds[self.ctx.games[self.client.slot]]
item_name, usable, response = get_intended_text(item_name,
world.item_names)
if usable:
new_item = NetworkItem(Items.item_table[item_name][2], -1, self.client.slot)
new_item = NetworkItem(world.create_item(item_name).code, -1, self.client.slot)
get_received_items(self.ctx, self.client.team, self.client.slot).append(new_item)
self.ctx.notify_all('Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team, self.client.slot))
self.ctx.notify_all(
'Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team,
self.client.slot))
send_new_items(self.ctx)
return True
else:
@@ -862,27 +947,31 @@ class ClientMessageProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_hint(self, item_or_location: str = "") -> bool:
"""Use !hint {item_name/location_name}, for example !hint Lamp or !hint Link's House. """
"""Use !hint {item_name/location_name},
for example !hint Lamp or !hint Link's House to get a spoiler peek for that location or item.
If hint costs are on, this will only give you one new result,
you can rerun the command to get more in that case."""
points_available = get_client_points(self.ctx, self.client)
if not item_or_location:
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
f"You have {points_available} points.")
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] = hints
notify_hints(self.ctx, self.client.team, list(hints))
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
f"You have {points_available} points.")
return True
else:
item_name, usable, response = get_intended_text(item_or_location)
world = proxy_worlds[self.ctx.games[self.client.slot]]
item_name, usable, response = get_intended_text(item_or_location, world.all_names)
if usable:
if item_name in Items.hint_blacklist:
if item_name in world.hint_blacklist:
self.output(f"Sorry, \"{item_name}\" is marked as non-hintable.")
hints = []
elif item_name in Items.item_name_groups:
elif item_name in world.item_name_groups:
hints = []
for item in Items.item_name_groups[item_name]:
for item in world.item_name_groups[item_name]:
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
elif item_name in lookup_any_item_name_to_id: # item name
elif item_name in world.item_names: # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, item_name)
else: # location name
hints = collect_hints_location(self.ctx, self.client.team, self.client.slot, item_name)
@@ -901,11 +990,11 @@ class ClientMessageProcessor(CommonCommandProcessor):
if not not_found_hints: # everything's been found, no need to pay
can_pay = 1000
elif cost:
can_pay = points_available // cost
can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call
else:
can_pay = 1000
random.shuffle(not_found_hints)
self.ctx.random.shuffle(not_found_hints)
hints = found_hints
while can_pay > 0:
@@ -923,7 +1012,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if not_found_hints:
if hints:
self.output(
"Could not pay for everything. Rerun the hint later with more points to get the remaining hints.")
"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 "
@@ -959,24 +1048,22 @@ def get_client_points(ctx: Context, client: Client) -> int:
async def process_client_cmd(ctx: Context, client: Client, args: dict):
try:
cmd:str = args["cmd"]
cmd: str = args["cmd"]
except:
logging.exception(f"Could not get command from {args}")
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
"text": f"Could not get command from {args} at `cmd`"}])
raise
if type(cmd) is not str:
await ctx.send_msgs(client, [{"cmd": "InvalidCmd", "text": f"Command should be str, got {type(cmd)}"}])
return
if args is not None and type(args) != dict:
await ctx.send_msgs(client, [{"cmd": "InvalidArguments",
'text': f'Expected Optional[dict], got {type(args)} for {cmd}'}])
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
"text": f"Command should be str, got {type(cmd)}"}])
return
if cmd == 'Connect':
if not args or 'password' not in args or type(args['password']) not in [str, type(None)] or \
'game' not in args:
await ctx.send_msgs(client, [{'cmd': 'InvalidArguments', 'text': 'Connect'}])
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", 'text': 'Connect'}])
return
errors = set()
@@ -996,6 +1083,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if clients:
# likely same player with a "ghosted" slot. We bust the ghost.
if "uuid" in args and ctx.client_ids[team, slot] == args["uuid"]:
await ctx.send_msgs(clients[0], [{"cmd": "Print", "text": "You are getting kicked "
"by yourself reconnecting."}])
await clients[0].socket.close() # we have to await the DC of the ghost, so not to create data pasta
client.name = ctx.player_names[(team, slot)]
client.team = team
@@ -1011,10 +1100,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
errors.add('IncompatibleVersion')
# only exact version match allowed
if ctx.compatibility == 0 and args['version'] != _version_tuple:
if ctx.compatibility == 0 and args['version'] != version_tuple:
errors.add('IncompatibleVersion')
if errors:
logging.info(f"A client connection was refused due to: {errors}")
logging.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
await ctx.send_msgs(client, [{"cmd": "ConnectionRefused", "errors": list(errors)}])
else:
ctx.client_ids[client.team, client.slot] = args["uuid"]
@@ -1027,6 +1116,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
"players": ctx.get_players_package(),
"missing_locations": get_missing_checks(ctx, client),
"checked_locations": get_checked_checks(ctx, client),
# get is needed for old multidata that was sparsely populated
"slot_data": ctx.slot_data.get(client.slot, {})
}]
items = get_received_items(ctx, client.team, client.slot)
@@ -1038,14 +1128,23 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
await on_client_joined(ctx, client)
elif cmd == "GetDataPackage":
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": network_data_package}])
exclusions = set(args.get("exclusions", []))
if exclusions:
games = {name: game_data for name, game_data in network_data_package["games"].items()
if name not in exclusions}
package = network_data_package.copy()
package["games"] = games
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": package}])
else:
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": network_data_package}])
elif client.auth:
if cmd == 'Sync':
items = get_received_items(ctx, client.team, client.slot)
if items:
client.send_index = len(items)
await ctx.send_msgs(client, [{"cmd": "ReceivedItems","index": 0,
await ctx.send_msgs(client, [{"cmd": "ReceivedItems", "index": 0,
"items": items}])
elif cmd == 'LocationChecks':
@@ -1055,7 +1154,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
locs = []
for location in args["locations"]:
if type(location) is not int or location not in lookup_any_location_id_to_name:
await ctx.send_msgs(client, [{"cmd": "InvalidArguments", "text": 'LocationScouts'}])
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts'}])
return
target_item, target_player = ctx.locations[client.slot][location]
locs.append(NetworkItem(target_item, location, target_player))
@@ -1065,13 +1164,27 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
elif cmd == 'StatusUpdate':
update_client_status(ctx, client, args["status"])
if cmd == 'Say':
elif cmd == 'Say':
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
await ctx.send_msgs(client, [{"cmd": "InvalidArguments", "text" : 'Say'}])
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'Say'}])
return
client.messageprocessor(args["text"])
elif cmd == "Bounce":
games = set(args.get("games", []))
tags = set(args.get("tags", []))
slots = set(args.get("slots", []))
args["cmd"] = "Bounced"
msg = ctx.dumper([args])
for bounceclient in ctx.endpoints:
if client.team == bounceclient.team and (ctx.games[bounceclient.slot] in games or
set(bounceclient.tags) & tags or
bounceclient.slot in slots):
await ctx.send_encoded_msgs(bounceclient, msg)
def update_client_status(ctx: Context, client: Client, new_status: ClientStatus):
current = ctx.client_game_state[client.team, client.slot]
if current != ClientStatus.CLIENT_GOAL: # can't undo goal completion
@@ -1080,9 +1193,12 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
ctx.notify_all(finished_msg)
if "auto" in ctx.forfeit_mode:
forfeit_player(ctx, client.team, client.slot)
elif proxy_worlds[ctx.games[client.slot]].forced_auto_forfeit:
forfeit_player(ctx, client.team, client.slot)
ctx.client_game_state[client.team, client.slot] = new_status
class ServerCommandProcessor(CommonCommandProcessor):
def __init__(self, ctx: Context):
self.ctx = ctx
@@ -1190,7 +1306,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
self.ctx.allow_forfeits[(team, slot)] = False
self.output(f"Player {player_name} has to follow the server restrictions on use of the !forfeit command.")
self.output(
f"Player {player_name} has to follow the server restrictions on use of the !forfeit command.")
return True
self.output(f"Could not find player {player_name} to forbid the !forfeit command for.")
@@ -1200,17 +1317,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
"""Sends an item to the specified player"""
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
team, slot = self.ctx.player_name_lookup[seeked_player]
item = " ".join(item_name)
item, usable, response = get_intended_text(item, all_items)
world = proxy_worlds[self.ctx.games[slot]]
item, usable, response = get_intended_text(item, world.item_names)
if usable:
for client in self.ctx.endpoints:
if client.name == seeked_player:
new_item = NetworkItem(lookup_any_item_name_to_id[item], -1, 0)
get_received_items(self.ctx, client.team, client.slot).append(new_item)
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' +
self.ctx.get_aliased_name(client.team, client.slot))
send_new_items(self.ctx)
return True
new_item = NetworkItem(world.item_name_to_id[item], -1, 0)
get_received_items(self.ctx, team, slot).append(new_item)
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' +
self.ctx.get_aliased_name(team, slot))
send_new_items(self.ctx)
return True
else:
self.output(response)
return False
@@ -1222,27 +1339,27 @@ class ServerCommandProcessor(CommonCommandProcessor):
"""Send out a hint for a player's item or location to their team"""
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
for (team, slot), name in self.ctx.player_names.items():
if name == seeked_player:
item = " ".join(item_or_location)
item, usable, response = get_intended_text(item)
if usable:
if item in Items.item_name_groups:
hints = []
for item in Items.item_name_groups[item]:
hints.extend(collect_hints(self.ctx, team, slot, item))
elif item in all_items: # item name
hints = collect_hints(self.ctx, team, slot, item)
else: # location name
hints = collect_hints_location(self.ctx, team, slot, item)
if hints:
notify_hints(self.ctx, team, hints)
else:
self.output("No hints found.")
return True
else:
self.output(response)
return False
team, slot = self.ctx.player_name_lookup[seeked_player]
item = " ".join(item_or_location)
world = proxy_worlds[self.ctx.games[slot]]
item, usable, response = get_intended_text(item, world.all_names)
if usable:
if item in world.item_name_groups:
hints = []
for item in world.item_name_groups[item]:
hints.extend(collect_hints(self.ctx, team, slot, item))
elif item in world.item_names: # item name
hints = collect_hints(self.ctx, team, slot, item)
else: # location name
hints = collect_hints_location(self.ctx, team, slot, item)
if hints:
notify_hints(self.ctx, team, hints)
else:
self.output("No hints found.")
return True
else:
self.output(response)
return False
else:
self.output(response)
@@ -1270,6 +1387,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
f"{', '.join(known)}")
return False
async def console(ctx: Context):
session = prompt_toolkit.PromptSession()
while ctx.running:
@@ -1285,11 +1403,11 @@ async def console(ctx: Context):
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
defaults = Utils.get_options()["server_options"]
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
parser.add_argument('--host', default=defaults["host"])
parser.add_argument('--port', default=defaults["port"], type=int)
parser.add_argument('--server_password', default=defaults["server_password"])
parser.add_argument('--password', default=defaults["password"])
parser.add_argument('--multidata', default=defaults["multidata"])
parser.add_argument('--savefile', default=defaults["savefile"])
parser.add_argument('--disable_save', default=defaults["disable_save"], action='store_true')
parser.add_argument('--loglevel', default=defaults["loglevel"],
@@ -1356,13 +1474,12 @@ async def auto_shutdown(ctx, to_cancel=None):
async def main(args: argparse.Namespace):
logging.basicConfig(force = True,
logging.basicConfig(force=True,
format='[%(asctime)s] %(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
args.hint_cost, not args.disable_item_cheat, args.forfeit_mode, args.remaining_mode,
args.auto_shutdown, args.compatibility)
ctx.log_network = args.log_network
args.auto_shutdown, args.compatibility, args.log_network)
data_filename = args.multidata
try:
@@ -1371,7 +1488,7 @@ async def main(args: argparse.Namespace):
import tkinter.filedialog
root = tkinter.Tk()
root.withdraw()
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago"),))
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago *.zip"),))
ctx.load(data_filename, args.use_embedded_options)

View File

@@ -1,831 +0,0 @@
import argparse
import logging
import random
import urllib.request
import urllib.parse
import typing
import os
from collections import Counter
import string
import ModuleUpdate
from worlds.generic import PlandoItem, PlandoConnection
ModuleUpdate.update()
from Utils import parse_yaml
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from Main import get_seed, seeddigits
import Options
from worlds import lookup_any_item_name_to_id
from worlds.alttp.Items import item_name_groups, item_table
from worlds.alttp import Bosses
from worlds.alttp.Text import TextTable
from worlds.alttp.Regions import location_table, key_drop_data
def mystery_argparse():
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--multi', default=1, type=lambda value: min(max(int(value), 1), 255))
multiargs, _ = parser.parse_known_args()
parser = argparse.ArgumentParser()
parser.add_argument('--weights',
help='Path to the weights file to use for rolling game settings, urls are also valid')
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
action='store_true')
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=1, type=lambda value: min(max(int(value), 1), 255))
parser.add_argument('--teams', default=1, type=lambda value: max(int(value), 1))
parser.add_argument('--create_spoiler', action='store_true')
parser.add_argument('--skip_playthrough', action='store_true')
parser.add_argument('--pre_roll', action='store_true')
parser.add_argument('--rom')
parser.add_argument('--enemizercli')
parser.add_argument('--outputpath')
parser.add_argument('--glitch_triforce', action='store_true')
parser.add_argument('--race', action='store_true')
parser.add_argument('--meta', default=None)
parser.add_argument('--log_output_path', help='Path to store output log')
parser.add_argument('--loglevel', default='info', help='Sets log level')
parser.add_argument('--create_diff', action="store_true")
parser.add_argument('--yaml_output', default=0, type=lambda value: min(max(int(value), 0), 255),
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
parser.add_argument('--plando', default="bosses",
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
parser.add_argument('--seed_name')
for player in range(1, multiargs.multi + 1):
parser.add_argument(f'--p{player}', help=argparse.SUPPRESS)
args = parser.parse_args()
args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
return args
def get_seed_name(random):
return f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None, callback=ERmain):
if not args:
args = mystery_argparse()
seed = get_seed(args.seed)
random.seed(seed)
seed_name = args.seed_name if args.seed_name else get_seed_name(random)
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed}")
if args.race:
random.seed() # reset to time-based random source
weights_cache = {}
if args.weights:
try:
weights_cache[args.weights] = get_weights(args.weights)
except Exception as e:
raise ValueError(f"File {args.weights} is destroyed. Please fix your yaml.") from e
print(f"Weights: {args.weights} >> "
f"{get_choice('description', weights_cache[args.weights], 'No description specified')}")
if args.meta:
try:
weights_cache[args.meta] = get_weights(args.meta)
except Exception as e:
raise ValueError(f"File {args.meta} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta]
print(f"Meta: {args.meta} >> {get_choice('meta_description', meta_weights, 'No description specified')}")
if args.samesettings:
raise Exception("Cannot mix --samesettings with --meta")
for player in range(1, args.multi + 1):
path = getattr(args, f'p{player}')
if path:
try:
if path not in weights_cache:
weights_cache[path] = get_weights(path)
print(f"P{player} Weights: {path} >> "
f"{get_choice('description', weights_cache[path], 'No description specified')}")
except Exception as e:
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
erargs.name = {x: "" for x in range(1, args.multi + 1)} # only so it can be overwrittin in mystery
erargs.create_spoiler = args.create_spoiler
erargs.create_diff = args.create_diff
erargs.glitch_triforce = args.glitch_triforce
erargs.race = args.race
erargs.skip_playthrough = args.skip_playthrough
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
erargs.teams = args.teams
# set up logger
if args.loglevel:
erargs.loglevel = args.loglevel
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
erargs.loglevel]
if args.log_output_path:
import sys
class LoggerWriter(object):
def __init__(self, writer):
self._writer = writer
self._msg = ''
def write(self, message):
self._msg = self._msg + message
while '\n' in self._msg:
pos = self._msg.find('\n')
self._writer(self._msg[:pos])
self._msg = self._msg[pos + 1:]
def flush(self):
if self._msg != '':
self._writer(self._msg)
self._msg = ''
log = logging.getLogger("stderr")
log.addHandler(logging.StreamHandler())
sys.stderr = LoggerWriter(log.error)
os.makedirs(args.log_output_path, exist_ok=True)
logging.basicConfig(format='%(message)s', level=loglevel,
filename=os.path.join(args.log_output_path, f"{seed}.log"))
else:
logging.basicConfig(format='%(message)s', level=loglevel)
if args.rom:
erargs.rom = args.rom
if args.enemizercli:
erargs.enemizercli = args.enemizercli
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
for k, v in weights_cache.items()}
player_path_cache = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = getattr(args, f'p{player}') if getattr(args, f'p{player}') else args.weights
if args.meta:
for player, path in player_path_cache.items():
weights_cache[path].setdefault("meta_ignore", [])
meta_weights = weights_cache[args.meta]
for key in meta_weights:
option = get_choice(key, meta_weights)
if option is not None:
for player, path in player_path_cache.items():
players_meta = weights_cache[path].get("meta_ignore", [])
if key not in players_meta:
weights_cache[path][key] = option
elif type(players_meta) == dict and players_meta[key] and option not in players_meta[key]:
weights_cache[path][key] = option
name_counter = Counter()
erargs.player_settings = {}
for player in range(1, args.multi + 1):
path = player_path_cache[player]
if path:
try:
settings = settings_cache[path] if settings_cache[path] else \
roll_settings(weights_cache[path], args.plando)
if args.pre_roll:
import yaml
if path == args.weights:
settings.name = f"Player{player}"
elif not settings.name:
settings.name = os.path.splitext(os.path.split(path)[-1])[0]
if "-" not in settings.shuffle and settings.shuffle != "vanilla":
settings.shuffle += f"-{random.randint(0, 2 ** 64)}"
pre_rolled = dict()
pre_rolled["original_seed_number"] = seed
pre_rolled["original_seed_name"] = seed_name
pre_rolled["pre_rolled"] = vars(settings).copy()
if "plando_items" in pre_rolled["pre_rolled"]:
pre_rolled["pre_rolled"]["plando_items"] = [item.to_dict() for item in
pre_rolled["pre_rolled"]["plando_items"]]
if "plando_connections" in pre_rolled["pre_rolled"]:
pre_rolled["pre_rolled"]["plando_connections"] = [connection.to_dict() for connection in
pre_rolled["pre_rolled"][
"plando_connections"]]
with open(os.path.join(args.outputpath if args.outputpath else ".",
f"{os.path.split(path)[-1].split('.')[0]}_pre_rolled.yaml"), "wt") as f:
yaml.dump(pre_rolled, f)
for k, v in vars(settings).items():
if v is not None:
try:
getattr(erargs, k)[player] = v
except AttributeError:
setattr(erargs, k, {player: v})
except Exception as e:
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
else:
raise RuntimeError(f'No weights specified for player {player}')
if path == args.weights: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}"
elif not erargs.name[player]: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
erargs.names = ",".join(erargs.name[i] for i in range(1, args.multi + 1))
del (erargs.name)
if args.yaml_output:
import yaml
important = {}
for option, player_settings in vars(erargs).items():
if type(player_settings) == dict:
if all(type(value) != list for value in player_settings.values()):
if len(player_settings.values()) > 1:
important[option] = {player: value for player, value in player_settings.items() if
player <= args.yaml_output}
elif len(player_settings.values()) > 0:
important[option] = player_settings[1]
else:
logging.debug(f"No player settings defined for option '{option}'")
else:
if player_settings != "": # is not empty name
important[option] = player_settings
else:
logging.debug(f"No player settings defined for option '{option}'")
if args.outputpath:
os.makedirs(args.outputpath, exist_ok=True)
with open(os.path.join(args.outputpath if args.outputpath else ".", f"mystery_result_{seed}.yaml"), "wt") as f:
yaml.dump(important, f)
callback(erargs, seed)
def get_weights(path):
try:
if urllib.parse.urlparse(path).scheme:
yaml = str(urllib.request.urlopen(path).read(), "utf-8")
else:
with open(path, 'rb') as f:
yaml = str(f.read(), "utf-8")
except Exception as e:
raise Exception(f"Failed to read weights ({path})") from e
return parse_yaml(yaml)
def interpret_on_off(value):
return {"on": True, "off": False}.get(value, value)
def convert_to_on_off(value):
return {True: "on", False: "off"}.get(value, value)
def get_choice(option, root, value=None) -> typing.Any:
if option not in root:
return value
if type(root[option]) is list:
return interpret_on_off(random.choices(root[option])[0])
if type(root[option]) is not dict:
return interpret_on_off(root[option])
if not root[option]:
return value
if any(root[option].values()):
return interpret_on_off(
random.choices(list(root[option].keys()), weights=list(map(int, root[option].values())))[0])
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
class SafeDict(dict):
def __missing__(self, key):
return '{' + key + '}'
def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name] += 1
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],
NUMBER=(name_counter[name] if name_counter[
name] > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
new_name = new_name.strip().replace(' ', '_')[:16]
if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name
def prefer_int(input_data: str) -> typing.Union[str, int]:
try:
return int(input_data)
except:
return input_data
available_boss_names: typing.Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in
{'Agahnim', 'Agahnim2', 'Ganon'}}
available_boss_locations: typing.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 = {
'ganon': 'ganon',
'crystals': 'crystals',
'bosses': 'bosses',
'pedestal': 'pedestal',
'ganon_pedestal': 'ganonpedestal',
'triforce_hunt': 'triforcehunt',
'local_triforce_hunt': 'localtriforcehunt',
'ganon_triforce_hunt': 'ganontriforcehunt',
'local_ganon_triforce_hunt': 'localganontriforcehunt',
'ice_rod_hunt': 'icerodhunt',
}
# remove sometime before 1.0.0, warn before
legacy_boss_shuffle_options = {
# legacy, will go away:
'simple': 'basic',
'random': 'full',
'normal': 'full'
}
legacy_goals = {
'dungeons': 'bosses',
'fast_ganon': 'crystals',
}
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
new_options = set(new_weights) - set(weights)
weights.update(new_weights)
if new_options:
for new_option in new_options:
logging.warning(f'{type} Suboption "{new_option}" of "{name}" did not '
f'overwrite a root option. '
f'This is probably in error.')
return weights
def roll_linked_options(weights: dict) -> dict:
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
for option_set in weights["linked_options"]:
if "name" not in option_set:
raise ValueError("One of your linked options does not have a name.")
try:
if roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.")
if "options" in option_set:
weights = update_weights(weights, option_set["options"], "Linked", option_set["name"])
if "rom_options" in option_set:
rom_weights = weights.get("rom", dict())
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Linked Rom",
option_set["name"])
weights["rom"] = rom_weights
else:
logging.debug(f"linked option {option_set['name']} skipped.")
except Exception as e:
raise ValueError(f"Linked option {option_set['name']} is destroyed. "
f"Please fix your linked option.") from e
return weights
def roll_triggers(weights: dict) -> dict:
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
weights["_Generator_Version"] = "Archipelago" # Some means for triggers to know if the seed is on main or doors.
for i, option_set in enumerate(weights["triggers"]):
try:
key = get_choice("option_name", option_set)
if key not in weights:
logging.warning(f'Specified option name {option_set["option_name"]} did not '
f'match with a root option. '
f'This is probably in error.')
trigger_result = get_choice("option_result", option_set)
result = get_choice(key, weights)
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
if "options" in option_set:
weights = update_weights(weights, option_set["options"], "Triggered", option_set["option_name"])
if "rom_options" in option_set:
rom_weights = weights.get("rom", dict())
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Triggered Rom",
option_set["option_name"])
weights["rom"] = rom_weights
weights[key] = result
except Exception as e:
raise ValueError(f"Your trigger number {i+1} is destroyed. "
f"Please fix your triggers.") from e
return weights
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str:
if boss_shuffle in legacy_boss_shuffle_options:
new_boss_shuffle = legacy_boss_shuffle_options[boss_shuffle]
logging.warning(f"Boss shuffle {boss_shuffle} is deprecated, "
f"please use {new_boss_shuffle} instead")
return new_boss_shuffle
if boss_shuffle in boss_shuffle_options:
return boss_shuffle_options[boss_shuffle]
elif "bosses" in plando_options:
options = boss_shuffle.lower().split(";")
remainder_shuffle = "none" # vanilla
bosses = []
for boss in options:
if boss in legacy_boss_shuffle_options:
remainder_shuffle = legacy_boss_shuffle_options[boss_shuffle]
logging.warning(f"Boss shuffle {boss} is deprecated, "
f"please use {remainder_shuffle} instead")
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 roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))):
if "pre_rolled" in weights:
pre_rolled = weights["pre_rolled"]
if "plando_items" in pre_rolled:
pre_rolled["plando_items"] = [PlandoItem(item["item"],
item["location"],
item["world"],
item["from_pool"],
item["force"]) for item in pre_rolled["plando_items"]]
if "items" not in plando_options and pre_rolled["plando_items"]:
raise Exception("Item Plando is turned off. Reusing this pre-rolled setting not permitted.")
if "plando_connections" in pre_rolled:
pre_rolled["plando_connections"] = [PlandoConnection(connection["entrance"],
connection["exit"],
connection["direction"]) for connection in
pre_rolled["plando_connections"]]
if "connections" not in plando_options and pre_rolled["plando_connections"]:
raise Exception("Connection Plando is turned off. Reusing this pre-rolled setting not permitted.")
if "bosses" not in plando_options:
try:
pre_rolled["shufflebosses"] = get_plando_bosses(pre_rolled["shufflebosses"], plando_options)
except Exception as ex:
raise Exception("Boss Plando is turned off. Reusing this pre-rolled setting not permitted.") from ex
if pre_rolled.get("plando_texts") and "texts" not in plando_options:
raise Exception("Text Plando is turned off. Reusing this pre-rolled setting not permitted.")
return argparse.Namespace(**pre_rolled)
if "linked_options" in weights:
weights = roll_linked_options(weights)
if "triggers" in weights:
weights = roll_triggers(weights)
ret = argparse.Namespace()
ret.name = get_choice('name', weights)
ret.accessibility = get_choice('accessibility', weights)
ret.progression_balancing = get_choice('progression_balancing', weights, True)
ret.game = get_choice("game", weights, "A Link to the Past")
ret.local_items = set()
for item_name in weights.get('local_items', []):
items = item_name_groups.get(item_name, {item_name})
for item in items:
if item in lookup_any_item_name_to_id:
ret.local_items.add(item)
else:
raise Exception(f"Could not force item {item} to be world-local, as it was not recognized.")
ret.non_local_items = set()
for item_name in weights.get('non_local_items', []):
items = item_name_groups.get(item_name, {item_name})
for item in items:
if item in lookup_any_item_name_to_id:
ret.non_local_items.add(item)
else:
raise Exception(f"Could not force item {item} to be world-non-local, as it was not recognized.")
inventoryweights = weights.get('startinventory', {})
startitems = []
for item in inventoryweights.keys():
itemvalue = get_choice(item, inventoryweights)
if isinstance(itemvalue, int):
for i in range(int(itemvalue)):
startitems.append(item)
elif itemvalue:
startitems.append(item)
ret.startinventory = startitems
ret.start_hints = set(weights.get('start_hints', []))
if ret.game == "A Link to the Past":
roll_alttp_settings(ret, weights, plando_options)
elif ret.game == "Hollow Knight":
for option_name, option in Options.hollow_knight_options.items():
setattr(ret, option_name, option.from_any(get_choice(option_name, weights, True)))
elif ret.game == "Factorio":
for option_name, option in Options.factorio_options.items():
if option_name in weights:
if issubclass(option, Options.OptionDict): # get_choice should probably land in the Option class
setattr(ret, option_name, option.from_any(weights[option_name]))
else:
setattr(ret, option_name, option.from_any(get_choice(option_name, weights)))
else:
setattr(ret, option_name, option(option.default))
elif ret.game == "Minecraft":
for option_name, option in Options.minecraft_options.items():
if option_name in weights:
setattr(ret, option_name, option.from_any(get_choice(option_name, weights)))
else:
setattr(ret, option_name, option(option.default))
else:
raise Exception(f"Unsupported game {ret.game}")
return ret
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
glitches_required = get_choice('glitches_required', weights)
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'minor_glitches']:
logging.warning("Only NMG, OWG and No Logic supported")
glitches_required = 'none'
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
'minor_glitches': 'minorglitches'}[
glitches_required]
ret.dark_room_logic = get_choice("dark_room_logic", weights, "lamp")
if not ret.dark_room_logic: # None/False
ret.dark_room_logic = "none"
if ret.dark_room_logic == "sconces":
ret.dark_room_logic = "torches"
if ret.dark_room_logic not in {"lamp", "torches", "none"}:
raise ValueError(f"Unknown Dark Room Logic: \"{ret.dark_room_logic}\"")
ret.restrict_dungeon_item_on_boss = get_choice('restrict_dungeon_item_on_boss', weights, False)
dungeon_items = get_choice('dungeon_items', weights)
if dungeon_items == 'full' or dungeon_items == True:
dungeon_items = 'mcsb'
elif dungeon_items == 'standard':
dungeon_items = ""
elif not dungeon_items:
dungeon_items = ""
if "u" in dungeon_items:
dungeon_items.replace("s", "")
ret.mapshuffle = get_choice('map_shuffle', weights, 'm' in dungeon_items)
ret.compassshuffle = get_choice('compass_shuffle', weights, 'c' in dungeon_items)
ret.keyshuffle = get_choice('smallkey_shuffle', weights,
'universal' if 'u' in dungeon_items else 's' in dungeon_items)
ret.bigkeyshuffle = get_choice('bigkey_shuffle', weights, 'b' in dungeon_items)
entrance_shuffle = get_choice('entrance_shuffle', weights, 'vanilla')
if entrance_shuffle.startswith('none-'):
ret.shuffle = 'vanilla'
else:
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
goal = get_choice('goals', weights, 'ganon')
if goal in legacy_goals:
logging.warning(f"Goal {goal} is depcrecated, please use {legacy_goals[goal]} instead.")
goal = legacy_goals[goal]
ret.goal = goals[goal]
# TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when
# fast ganon + ganon at hole
ret.open_pyramid = get_choice('open_pyramid', weights, 'goal')
ret.crystals_gt = prefer_int(get_choice('tower_open', weights))
ret.crystals_ganon = prefer_int(get_choice('ganon_open', weights))
extra_pieces = get_choice('triforce_pieces_mode', weights, 'available')
ret.triforce_pieces_required = int(get_choice('triforce_pieces_required', weights, 20))
ret.triforce_pieces_required = min(max(1, int(ret.triforce_pieces_required)), 90)
# sum a percentage to required
if extra_pieces == 'percentage':
percentage = max(100, float(get_choice('triforce_pieces_percentage', weights, 150))) / 100
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
# vanilla mode (specify how many pieces are)
elif extra_pieces == 'available':
ret.triforce_pieces_available = int(get_choice('triforce_pieces_available', weights, 30))
# required pieces + fixed extra
elif extra_pieces == 'extra':
extra_pieces = max(0, int(get_choice('triforce_pieces_extra', weights, 10)))
ret.triforce_pieces_available = ret.triforce_pieces_required + extra_pieces
# change minimum to required pieces to avoid problems
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
shuffle_slots = get_choice('shop_shuffle_slots', weights, '0')
if str(shuffle_slots).lower() == "random":
ret.shop_shuffle_slots = random.randint(0, 30)
else:
ret.shop_shuffle_slots = int(shuffle_slots)
ret.shop_shuffle = get_choice('shop_shuffle', weights, '')
if not ret.shop_shuffle:
ret.shop_shuffle = ''
ret.mode = get_choice("mode", weights)
ret.retro = get_choice("retro", weights)
ret.hints = get_choice('hints', weights)
ret.swordless = get_choice('swordless', weights, False)
ret.difficulty = get_choice('item_pool', weights)
ret.item_functionality = get_choice('item_functionality', weights)
boss_shuffle = get_choice('boss_shuffle', weights)
ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options)
ret.enemy_shuffle = bool(get_choice('enemy_shuffle', weights, False))
ret.killable_thieves = get_choice('killable_thieves', weights, False)
ret.tile_shuffle = get_choice('tile_shuffle', weights, False)
ret.bush_shuffle = get_choice('bush_shuffle', weights, False)
ret.enemy_damage = {None: 'default',
'default': 'default',
'shuffled': 'shuffled',
'random': 'chaos'
}[get_choice('enemy_damage', weights)]
ret.enemy_health = get_choice('enemy_health', weights)
ret.shufflepots = get_choice('pot_shuffle', weights)
ret.beemizer = int(get_choice('beemizer', weights, 0))
ret.timer = {'none': False,
None: False,
False: False,
'timed': 'timed',
'timed_ohko': 'timed-ohko',
'ohko': 'ohko',
'timed_countdown': 'timed-countdown',
'display': 'display'}[get_choice('timer', weights, False)]
ret.countdown_start_time = int(get_choice('countdown_start_time', weights, 10))
ret.red_clock_time = int(get_choice('red_clock_time', weights, -2))
ret.blue_clock_time = int(get_choice('blue_clock_time', weights, 2))
ret.green_clock_time = int(get_choice('green_clock_time', weights, 4))
ret.dungeon_counters = get_choice('dungeon_counters', weights, 'default')
ret.progressive = convert_to_on_off(get_choice('progressive', weights, 'on'))
ret.shuffle_prizes = get_choice('shuffle_prizes', weights, "g")
ret.required_medallions = [get_choice("misery_mire_medallion", weights, "random"),
get_choice("turtle_rock_medallion", weights, "random")]
for index, medallion in enumerate(ret.required_medallions):
ret.required_medallions[index] = {"ether": "Ether", "quake": "Quake", "bombos": "Bombos", "random": "random"} \
.get(medallion.lower(), None)
if not ret.required_medallions[index]:
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
ret.glitch_boots = get_choice('glitch_boots', weights, True)
if get_choice("local_keys", weights, "l" in dungeon_items):
# () important for ordering of commands, without them the Big Keys section is part of the Small Key else
ret.local_items |= item_name_groups["Small Keys"] if ret.keyshuffle else set()
ret.local_items |= item_name_groups["Big Keys"] if ret.bigkeyshuffle else set()
ret.plando_items = []
if "items" in plando_options:
def add_plando_item(item: str, location: str):
if item not in item_table:
raise Exception(f"Could not plando item {item} as the item was not recognized")
if location not in location_table and location not in key_drop_data:
raise Exception(
f"Could not plando item {item} at location {location} as the location was not recognized")
ret.plando_items.append(PlandoItem(item, location, location_world, from_pool, force))
options = weights.get("plando_items", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
from_pool = get_choice("from_pool", placement, PlandoItem._field_defaults["from_pool"])
location_world = get_choice("world", placement, PlandoItem._field_defaults["world"])
force = str(get_choice("force", placement, PlandoItem._field_defaults["force"])).lower()
if "items" in placement and "locations" in placement:
items = placement["items"]
locations = placement["locations"]
if isinstance(items, dict):
item_list = []
for key, value in items.items():
item_list += [key] * value
items = item_list
if not items or not locations:
raise Exception("You must specify at least one item and one location to place items.")
random.shuffle(items)
random.shuffle(locations)
for item, location in zip(items, locations):
add_plando_item(item, location)
else:
item = get_choice("item", placement, get_choice("items", placement))
location = get_choice("location", placement)
add_plando_item(item, location)
ret.plando_texts = {}
if "texts" in plando_options:
tt = TextTable()
tt.removeUnwantedText()
options = weights.get("plando_texts", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
at = str(get_choice("at", placement))
if at not in tt:
raise Exception(f"No text target \"{at}\" found.")
ret.plando_texts[at] = str(get_choice("text", placement))
ret.plando_connections = []
if "connections" in plando_options:
options = weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement, "both")
))
if 'rom' in weights:
romweights = weights['rom']
ret.sprite_pool = romweights['sprite_pool'] if 'sprite_pool' in romweights else []
ret.sprite = get_choice('sprite', romweights, "Link")
if 'random_sprite_on_event' in romweights:
randomoneventweights = romweights['random_sprite_on_event']
if get_choice('enabled', randomoneventweights, False):
ret.sprite = 'randomon'
ret.sprite += '-hit' if get_choice('on_hit', randomoneventweights, True) else ''
ret.sprite += '-enter' if get_choice('on_enter', randomoneventweights, False) else ''
ret.sprite += '-exit' if get_choice('on_exit', randomoneventweights, False) else ''
ret.sprite += '-slash' if get_choice('on_slash', randomoneventweights, False) else ''
ret.sprite += '-item' if get_choice('on_item', randomoneventweights, False) else ''
ret.sprite += '-bonk' if get_choice('on_bonk', randomoneventweights, False) else ''
ret.sprite = 'randomonall' if get_choice('on_everything', randomoneventweights, False) else ret.sprite
ret.sprite = 'randomonnone' if ret.sprite == 'randomon' else ret.sprite
if (not ret.sprite_pool or get_choice('use_weighted_sprite_pool', randomoneventweights, False)) \
and 'sprite' in romweights: # Use sprite as a weighted sprite pool, if a sprite pool is not already defined.
for key, value in romweights['sprite'].items():
if key.startswith('random'):
ret.sprite_pool += ['random'] * int(value)
else:
ret.sprite_pool += [key] * int(value)
ret.disablemusic = get_choice('disablemusic', romweights, False)
ret.triforcehud = get_choice('triforcehud', romweights, 'hide_goal')
ret.quickswap = get_choice('quickswap', romweights, True)
ret.fastmenu = get_choice('menuspeed', romweights, "normal")
ret.reduceflashing = get_choice('reduceflashing', romweights, False)
ret.heartcolor = get_choice('heartcolor', romweights, "red")
ret.heartbeep = convert_to_on_off(get_choice('heartbeep', romweights, "normal"))
ret.ow_palettes = get_choice('ow_palettes', romweights, "default")
ret.uw_palettes = get_choice('uw_palettes', romweights, "default")
ret.hud_palettes = get_choice('hud_palettes', romweights, "default")
ret.sword_palettes = get_choice('sword_palettes', romweights, "default")
ret.shield_palettes = get_choice('shield_palettes', romweights, "default")
ret.link_palettes = get_choice('link_palettes', romweights, "default")
else:
ret.quickswap = True
ret.sprite = "Link"
if __name__ == '__main__':
main()

View File

@@ -1,6 +1,4 @@
from __future__ import annotations
import asyncio
import logging
import typing
import enum
from json import JSONEncoder, JSONDecoder
@@ -94,50 +92,6 @@ def _object_hook(o: typing.Any) -> typing.Any:
decode = JSONDecoder(object_hook=_object_hook).decode
class Node:
endpoints: typing.List
dumper = staticmethod(encode)
loader = staticmethod(decode)
def __init__(self):
self.endpoints = []
super(Node, self).__init__()
self.log_network = 0
def broadcast_all(self, msgs):
msgs = self.dumper(msgs)
for endpoint in self.endpoints:
asyncio.create_task(self.send_encoded_msgs(endpoint, msgs))
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]):
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed:
return
msg = self.dumper(msgs)
try:
await endpoint.socket.send(msg)
except websockets.ConnectionClosed:
logging.exception(f"Exception during send_msgs, could not send {msg}")
await self.disconnect(endpoint)
else:
if self.log_network:
logging.info(f"Outgoing message: {msg}")
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str):
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed:
return
try:
await endpoint.socket.send(msg)
except websockets.ConnectionClosed:
logging.exception("Exception during send_msgs")
await self.disconnect(endpoint)
else:
if self.log_network:
logging.info(f"Outgoing message: {msg}")
async def disconnect(self, endpoint):
if endpoint in self.endpoints:
self.endpoints.remove(endpoint)
class Endpoint:
socket: websockets.WebSocketServerProtocol
@@ -242,7 +196,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
return self._handle_item_name(node)
def _handle_entrance_name(self, node: JSONMessagePart):
node["color"] = 'white_bg;black'
node["color"] = 'blue'
return self._handle_color(node)
@@ -307,7 +261,9 @@ class Hint(typing.NamedTuple):
else:
add_json_text(parts, ".")
return {"cmd": "PrintJSON", "data": parts, "type": "hint"}
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
"receiving": self.receiving_player,
"item": NetworkItem(self.item, self.location, self.finding_player)}
@property
def local(self):

View File

@@ -1,22 +1,36 @@
from __future__ import annotations
import typing
import random
class AssembleOptions(type):
def __new__(mcs, name, bases, attrs):
options = attrs["options"] = {}
name_lookup = attrs["name_lookup"] = {}
# merge parent class options
for base in bases:
options.update(base.options)
name_lookup.update(name_lookup)
if getattr(base, "options", None):
options.update(base.options)
name_lookup.update(base.name_lookup)
new_options = {name[7:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("option_")}
if "random" in new_options:
raise Exception("Choice option 'random' cannot be manually assigned.")
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
options.update(new_options)
# apply aliases, without name_lookup
options.update({name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")})
# auto-validate schema on __init__
if "schema" in attrs.keys():
def validate_decorator(func):
def validate(self, *args, **kwargs):
func(self, *args, **kwargs)
self.value = self.schema.validate(self.value)
return validate
attrs["__init__"] = validate_decorator(attrs["__init__"])
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
@@ -25,19 +39,35 @@ class Option(metaclass=AssembleOptions):
name_lookup: typing.Dict[int, str]
default = 0
def __repr__(self):
return f"{self.__class__.__name__}({self.get_option_name()})"
# convert option_name_long into Name Long as displayname, otherwise name_long is the result.
# Handled in get_option_name()
autodisplayname = False
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.get_current_option_name()})"
def __hash__(self):
return hash(self.value)
def get_option_name(self):
@property
def current_key(self) -> str:
return self.name_lookup[self.value]
def __int__(self):
def get_current_option_name(self) -> str:
"""For display purposes."""
return self.get_option_name(self.value)
@classmethod
def get_option_name(cls, value: typing.Any) -> str:
if cls.autodisplayname:
return cls.name_lookup[value].replace("_", " ").title()
else:
return cls.name_lookup[value]
def __int__(self) -> int:
return self.value
def __bool__(self):
def __bool__(self) -> bool:
return bool(self.value)
@classmethod
@@ -85,18 +115,29 @@ class Toggle(Option):
def __int__(self):
return int(self.value)
def get_option_name(self):
return bool(self.value)
@classmethod
def get_option_name(cls, value):
return ["No", "Yes"][int(value)]
class DefaultOnToggle(Toggle):
default = 1
class Choice(Option):
autodisplayname = True
def __init__(self, value: int):
self.value: int = value
@classmethod
def from_text(cls, text: str) -> Choice:
text = text.lower()
# TODO: turn on after most people have adjusted their yamls to no longer have suboptions with "random" in them
# maybe in 0.2?
# if text == "random":
# return cls(random.choice(list(cls.options.values())))
for optionname, value in cls.options.items():
if optionname == text.lower():
if optionname == text:
return cls(value)
raise KeyError(
f'Could not find option "{text}" for "{cls.__name__}", '
@@ -108,6 +149,67 @@ class Choice(Option):
return cls(data)
return cls.from_text(str(data))
def __eq__(self, other):
if isinstance(other, str):
assert other in self.options
return other == self.current_key
elif isinstance(other, int):
assert other in self.name_lookup
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__}")
def __ne__(self, other):
if isinstance(other, str):
assert other in self.options
return other != self.current_key
elif isinstance(other, int):
assert other in self.name_lookup
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 Range(Option, int):
range_start = 0
range_end = 1
def __init__(self, value: int):
if value < self.range_start:
raise Exception(f"{value} is lower than minimum {self.range_start} for option {self.__class__.__name__}")
elif value > self.range_end:
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__}")
self.value = value
@classmethod
def from_text(cls, text: str) -> Range:
text = text.lower()
if text.startswith("random"):
if text == "random-low":
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_start), 0)))
elif text == "random-high":
return cls(int(round(random.triangular(cls.range_start, cls.range_end, cls.range_end), 0)))
elif text == "random-middle":
return cls(int(round(random.triangular(cls.range_start, cls.range_end), 0)))
else:
return cls(random.randint(cls.range_start, cls.range_end))
return cls(int(text))
@classmethod
def from_any(cls, data: typing.Any) -> Range:
if type(data) == int:
return cls(data)
return cls.from_text(str(data))
def get_option_name(self, value):
return str(self.value)
def __str__(self):
return str(self.value)
class OptionNameSet(Option):
default = frozenset()
@@ -139,229 +241,52 @@ class OptionDict(Option):
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
class Logic(Choice):
option_no_glitches = 0
option_minor_glitches = 1
option_overworld_glitches = 2
option_no_logic = 4
alias_owg = 2
def get_option_name(self, value):
return str(value)
class Objective(Choice):
option_crystals = 0
# option_pendants = 1
option_triforce_pieces = 2
option_pedestal = 3
option_bingo = 4
class OptionList(Option):
default = []
def __init__(self, value: typing.List[str, typing.Any]):
self.value = value
@classmethod
def from_text(cls, text: str):
return cls([option.strip() for option in text.split(",")])
@classmethod
def from_any(cls, data: typing.Any):
if type(data) == list:
return cls(data)
return cls.from_text(str(data))
def get_option_name(self, value):
return str(value)
local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
class Goal(Choice):
option_kill_ganon = 0
option_kill_ganon_and_gt_agahnim = 1
option_hand_in = 2
class Accessibility(Choice):
option_locations = 0
option_items = 1
option_beatable = 2
class Crystals(Choice):
# can't use IntEnum since there's also random
option_0 = 0
option_1 = 1
option_2 = 2
option_3 = 3
option_4 = 4
option_5 = 5
option_6 = 6
option_7 = 7
option_random = -1
class WorldState(Choice):
option_standard = 1
option_open = 0
option_inverted = 2
class Bosses(Choice):
option_vanilla = 0
option_simple = 1
option_full = 2
option_chaos = 3
option_singularity = 4
class Enemies(Choice):
option_vanilla = 0
option_shuffled = 1
option_chaos = 2
mapshuffle = Toggle
compassshuffle = Toggle
keyshuffle = Toggle
bigkeyshuffle = Toggle
hints = Toggle
RandomizeDreamers = Toggle
RandomizeSkills = Toggle
RandomizeCharms = Toggle
RandomizeKeys = Toggle
RandomizeGeoChests = Toggle
RandomizeMaskShards = Toggle
RandomizeVesselFragments = Toggle
RandomizeCharmNotches = Toggle
RandomizePaleOre = Toggle
RandomizeRancidEggs = Toggle
RandomizeRelics = Toggle
RandomizeMaps = Toggle
RandomizeStags = Toggle
RandomizeGrubs = Toggle
RandomizeWhisperingRoots = Toggle
RandomizeRocks = Toggle
RandomizeSoulTotems = Toggle
RandomizePalaceTotems = Toggle
RandomizeLoreTablets = Toggle
RandomizeLifebloodCocoons = Toggle
RandomizeFlames = Toggle
hollow_knight_randomize_options: typing.Dict[str, Option] = {
"RandomizeDreamers": RandomizeDreamers,
"RandomizeSkills": RandomizeSkills,
"RandomizeCharms": RandomizeCharms,
"RandomizeKeys": RandomizeKeys,
"RandomizeGeoChests": RandomizeGeoChests,
"RandomizeMaskShards": RandomizeMaskShards,
"RandomizeVesselFragments": RandomizeVesselFragments,
"RandomizeCharmNotches": RandomizeCharmNotches,
"RandomizePaleOre": RandomizePaleOre,
"RandomizeRancidEggs": RandomizeRancidEggs,
"RandomizeRelics": RandomizeRelics,
"RandomizeMaps": RandomizeMaps,
"RandomizeStags": RandomizeStags,
"RandomizeGrubs": RandomizeGrubs,
"RandomizeWhisperingRoots": RandomizeWhisperingRoots,
"RandomizeRocks": RandomizeRocks,
"RandomizeSoulTotems": RandomizeSoulTotems,
"RandomizePalaceTotems": RandomizePalaceTotems,
"RandomizeLoreTablets": RandomizeLoreTablets,
"RandomizeLifebloodCocoons": RandomizeLifebloodCocoons,
"RandomizeFlames": RandomizeFlames
}
hollow_knight_skip_options: typing.Dict[str, type(Option)] = {
"MILDSKIPS": Toggle,
"SPICYSKIPS": Toggle,
"FIREBALLSKIPS": Toggle,
"ACIDSKIPS": Toggle,
"SPIKETUNNELS": Toggle,
"DARKROOMS": Toggle,
"CURSED": Toggle,
"SHADESKIPS": Toggle,
}
hollow_knight_options: typing.Dict[str, type(Option)] = {**hollow_knight_randomize_options,
**hollow_knight_skip_options}
class MaxSciencePack(Choice):
option_automation_science_pack = 0
option_logistic_science_pack = 1
option_military_science_pack = 2
option_chemical_science_pack = 3
option_production_science_pack = 4
option_utility_science_pack = 5
option_space_science_pack = 6
default = 6
def get_allowed_packs(self):
return {option.replace("_", "-") for option, value in self.options.items()
if value <= self.value}
class TechCost(Choice):
option_very_easy = 0
option_easy = 1
option_kind = 2
option_normal = 3
option_hard = 4
option_very_hard = 5
option_insane = 6
default = 3
class FreeSamples(Choice):
option_none = 0
option_single_craft = 1
option_half_stack = 2
option_stack = 3
default = 3
class TechTreeLayout(Choice):
option_single = 0
option_small_diamonds = 1
option_medium_diamonds = 2
option_pyramid = 3
option_funnel = 4
default = 0
class Visibility(Choice):
option_none = 0
option_sending = 1
default = 1
class FactorioStartItems(OptionDict):
default = {"burner-mining-drill": 19, "stone-furnace": 19}
factorio_options: typing.Dict[str, type(Option)] = {"max_science_pack": MaxSciencePack,
"tech_tree_layout": TechTreeLayout,
"tech_cost": TechCost,
"free_samples": FreeSamples,
"visibility": Visibility,
"random_tech_ingredients": Toggle,
"starting_items": FactorioStartItems}
class AdvancementGoal(Choice):
option_few = 0
option_normal = 1
option_many = 2
default = 1
class CombatDifficulty(Choice):
option_easy = 0
option_normal = 1
option_hard = 2
default = 1
minecraft_options: typing.Dict[str, type(Option)] = {
"advancement_goal": AdvancementGoal,
"combat_difficulty": CombatDifficulty,
"include_hard_advancements": Toggle,
"include_insane_advancements": Toggle,
"include_postgame_advancements": Toggle,
"shuffle_structures": Toggle
}
if __name__ == "__main__":
import argparse
from worlds.alttp.Options import Logic
import argparse
map_shuffle = Toggle
compass_shuffle = Toggle
keyshuffle = Toggle
bigkey_shuffle = Toggle
hints = Toggle
test = argparse.Namespace()
test.logic = Logic.from_text("no_logic")
test.mapshuffle = mapshuffle.from_text("ON")
test.map_shuffle = map_shuffle.from_text("ON")
test.hints = hints.from_text('OFF')
try:
test.logic = Logic.from_text("overworld_glitches_typo")
@@ -371,7 +296,7 @@ if __name__ == "__main__":
test.logic_owg = Logic.from_text("owg")
except KeyError as e:
print(e)
if test.mapshuffle:
print("Mapshuffle is on")
if test.map_shuffle:
print("map_shuffle is on")
print(f"Hints are {bool(test.hints)}")
print(test)

View File

@@ -2,7 +2,6 @@ import bsdiff4
import yaml
import os
import lzma
import hashlib
import threading
import concurrent.futures
import zipfile
@@ -10,37 +9,13 @@ import sys
from typing import Tuple, Optional
import Utils
from worlds.alttp.Rom import JAP10HASH
current_patch_version = 2
def get_base_rom_path(file_name: str = "") -> str:
options = Utils.get_options()
if not file_name:
file_name = options["lttp_options"]["rom_file"]
if not os.path.exists(file_name):
file_name = Utils.local_path(file_name)
return file_name
def get_base_rom_bytes(file_name: str = "") -> bytes:
from worlds.alttp.Rom import read_rom
base_rom_bytes = getattr(get_base_rom_bytes, "base_rom_bytes", None)
if not base_rom_bytes:
file_name = get_base_rom_path(file_name)
base_rom_bytes = bytes(read_rom(open(file_name, "rb")))
basemd5 = hashlib.md5()
basemd5.update(base_rom_bytes)
if JAP10HASH != basemd5.hexdigest():
raise Exception('Supplied Base Rom does not match known MD5 for JAP(1.0) release. '
'Get the correct game and version, then dump it')
get_base_rom_bytes.base_rom_bytes = base_rom_bytes
return base_rom_bytes
def generate_yaml(patch: bytes, metadata: Optional[dict] = None) -> bytes:
from worlds.alttp.Rom import JAP10HASH
patch = yaml.dump({"meta": metadata,
"patch": patch,
"game": "A Link to the Past",
@@ -52,6 +27,7 @@ def generate_yaml(patch: bytes, metadata: Optional[dict] = None) -> bytes:
def generate_patch(rom: bytes, metadata: Optional[dict] = None) -> bytes:
from worlds.alttp.Rom import get_base_rom_bytes
if metadata is None:
metadata = {}
patch = bsdiff4.diff(get_base_rom_bytes(), rom)
@@ -71,6 +47,7 @@ def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
from worlds.alttp.Rom import get_base_rom_bytes
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
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.")
@@ -184,3 +161,11 @@ if __name__ == "__main__":
traceback.print_exc()
input("Press enter to close.")
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

View File

@@ -6,8 +6,12 @@ Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past
* Factorio
* Minecraft
* Subnautica
* Slay the Spire
* Risk of Rain 2
* The Legend of Zelda: Ocarina of Time
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial).
For setup and instructions check out our [tutorials page](http://archipelago.gg:48484/tutorial).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
windows binaries.
@@ -36,6 +40,7 @@ This project makes use of multiple other projects. We wouldn't be here without t
* [z3randomizer](https://github.com/CaitSith2/z3randomizer)
* [Enemizer](https://github.com/Ijwu/Enemizer)
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
## Contributing
Contributions are welcome. We have a few asks of any new contributors.

201
Utils.py
View File

@@ -3,7 +3,7 @@ from __future__ import annotations
import typing
def tuplize_version(version: str) -> typing.Tuple[int, ...]:
def tuplize_version(version: str) -> Version:
return Version(*(int(piece, 10) for piece in version.split(".")))
@@ -12,8 +12,9 @@ class Version(typing.NamedTuple):
minor: int
build: int
__version__ = "0.1.1"
_version_tuple = tuplize_version(__version__)
__version__ = "0.1.7"
version_tuple = tuplize_version(__version__)
import builtins
import os
@@ -22,6 +23,7 @@ import sys
import pickle
import functools
import io
import collections
from yaml import load, dump, safe_load
@@ -49,26 +51,22 @@ def snes_to_pc(value):
return ((value & 0x7F0000) >> 1) | (value & 0x7FFF)
def parse_player_names(names, players, teams):
names = tuple(n for n in (n.strip() for n in names.split(",")) if n)
if len(names) != len(set(names)):
import collections
name_counter = collections.Counter(names)
raise ValueError(f"Duplicate Player names is not supported, "
f'found multiple "{name_counter.most_common(1)[0][0]}".')
ret = []
while names or len(ret) < teams:
team = [n[:16] for n in names[:players]]
# 16 bytes in rom per player, which will map to more in unicode, but those characters later get filtered
while len(team) != players:
team.append(f"Player{len(team) + 1}")
ret.append(team)
def cache_argsless(function):
if function.__code__.co_argcount:
raise Exception("Can only cache 0 argument functions with this cache.")
names = names[players:]
return ret
result = sentinel = object()
def _wrap():
nonlocal result
if result is sentinel:
result = function()
return result
return _wrap
def is_bundled() -> bool:
def is_frozen() -> bool:
return getattr(sys, 'frozen', False)
@@ -76,7 +74,7 @@ def local_path(*path):
if local_path.cached_path:
return os.path.join(local_path.cached_path, *path)
elif is_bundled():
elif is_frozen():
if hasattr(sys, "_MEIPASS"):
# we are running in a PyInstaller bundle
local_path.cached_path = sys._MEIPASS # pylint: disable=protected-access,no-member
@@ -118,20 +116,11 @@ def open_file(filename):
subprocess.call([open_command, filename])
def close_console():
if sys.platform == 'win32':
# windows
import ctypes.wintypes
try:
ctypes.windll.kernel32.FreeConsole()
except Exception:
pass
parse_yaml = safe_load
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
@cache_argsless
def get_public_ipv4() -> str:
import socket
import urllib.request
@@ -148,6 +137,7 @@ def get_public_ipv4() -> str:
return ip
@cache_argsless
def get_public_ipv6() -> str:
import socket
import urllib.request
@@ -161,77 +151,62 @@ def get_public_ipv6() -> str:
return ip
@cache_argsless
def get_default_options() -> dict:
if not hasattr(get_default_options, "options"):
# Refer to host.yaml for comments as to what all these options mean.
options = {
"general_options": {
"output_path": "output",
},
"factorio_options": {
"executable": "factorio\\bin\\x64\\factorio",
},
"lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"qusb2snes": "QUsb2Snes\\QUsb2Snes.exe",
"rom_start": True,
# Refer to host.yaml for comments as to what all these options mean.
options = {
"general_options": {
"output_path": "output",
},
"factorio_options": {
"executable": "factorio\\bin\\x64\\factorio",
},
"lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"sni": "SNI",
"rom_start": True,
},
"server_options": {
"host": None,
"port": 38281,
"password": None,
"multidata": None,
"savefile": None,
"disable_save": False,
"loglevel": "info",
"server_password": None,
"disable_item_cheat": False,
"location_check_points": 1,
"hint_cost": 1000,
"forfeit_mode": "goal",
"remaining_mode": "goal",
"auto_shutdown": 0,
"compatibility": 2,
"log_network": 0
},
"multi_mystery_options": {
"teams": 1,
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
"player_files_path": "Players",
"players": 0,
"weights_file_path": "weights.yaml",
"meta_file_path": "meta.yaml",
"pre_roll": False,
"player_name": "",
"create_spoiler": 1,
"zip_roms": 0,
"zip_diffs": 2,
"zip_spoiler": 0,
"zip_multidata": 1,
"zip_format": 1,
"glitch_triforce_room": 1,
"race": 0,
"cpu_threads": 0,
"max_attempts": 0,
"take_first_working": False,
"keep_all_seeds": False,
"log_output_path": "Output Logs",
"log_level": None,
"plando_options": "bosses",
}
},
"server_options": {
"host": None,
"port": 38281,
"password": None,
"multidata": None,
"savefile": None,
"disable_save": False,
"loglevel": "info",
"server_password": None,
"disable_item_cheat": False,
"location_check_points": 1,
"hint_cost": 10,
"forfeit_mode": "goal",
"remaining_mode": "goal",
"auto_shutdown": 0,
"compatibility": 2,
"log_network": 0
},
"generator": {
"teams": 1,
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
"player_files_path": "Players",
"players": 0,
"weights_file_path": "weights.yaml",
"meta_file_path": "meta.yaml",
"spoiler": 2,
"glitch_triforce_room": 1,
"race": 0,
"plando_options": "bosses",
},
"minecraft_options": {
"forge_directory": "Minecraft Forge server",
"max_heap_size": "2G"
},
"oot_options": {
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
}
}
get_default_options.options = options
return get_default_options.options
blacklisted_options = {"multi_mystery_options.cpu_threads",
"multi_mystery_options.max_attempts",
"multi_mystery_options.take_first_working",
"multi_mystery_options.keep_all_seeds",
"multi_mystery_options.log_output_path",
"multi_mystery_options.log_level"}
return options
def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
@@ -242,11 +217,11 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
option_name = '.'.join(new_keys)
if key not in dest:
dest[key] = value
if filename.endswith("options.yaml") and option_name not in blacklisted_options:
if filename.endswith("options.yaml"):
logging.info(f"Warning: {filename} is missing {option_name}")
elif isinstance(value, dict):
if not isinstance(dest.get(key, None), dict):
if filename.endswith("options.yaml") and option_name not in blacklisted_options:
if filename.endswith("options.yaml"):
logging.info(f"Warning: {filename} has {option_name}, but it is not a dictionary. overwriting.")
dest[key] = value
else:
@@ -254,6 +229,7 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
return dest
@cache_argsless
def get_options() -> dict:
if not hasattr(get_options, "options"):
locations = ("options.yaml", "host.yaml",
@@ -309,7 +285,7 @@ def persistent_load() -> typing.Dict[dict]:
return storage
def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
def get_adjuster_settings(romfile: str, skip_questions: bool = False) -> typing.Tuple[str, bool]:
if hasattr(get_adjuster_settings, "adjuster_settings"):
adjuster_settings = getattr(get_adjuster_settings, "adjuster_settings")
else:
@@ -317,11 +293,11 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
if adjuster_settings:
import pprint
import Patch
from worlds.alttp.Rom import get_base_rom_path
adjuster_settings.rom = romfile
adjuster_settings.baserom = Patch.get_base_rom_path()
adjuster_settings.baserom = get_base_rom_path()
adjuster_settings.world = None
whitelist = {"disablemusic", "fastmenu", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes", "sprite"}
printed_options = {name: value for name, value in vars(adjuster_settings).items() if name in whitelist}
if hasattr(adjuster_settings, "sprite_pool"):
@@ -339,18 +315,20 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
adjust_wanted = getattr(get_adjuster_settings, "adjust_wanted")
elif persistent_load().get("adjuster", {}).get("never_adjust", False): # never adjust, per user request
return romfile, False
elif skip_questions:
return romfile, False
else:
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 or never: ")
if adjust_wanted and adjust_wanted.startswith("y"):
if hasattr(adjuster_settings, "sprite_pool"):
from Adjuster import AdjusterWorld
from LttPAdjuster import AdjusterWorld
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
adjusted = True
import Adjuster
_, romfile = Adjuster.adjust(adjuster_settings)
import LttPAdjuster
_, romfile = LttPAdjuster.adjust(adjuster_settings)
if hasattr(adjuster_settings, "world"):
delattr(adjuster_settings, "world")
@@ -368,6 +346,7 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
return romfile, False
@cache_argsless
def get_unique_identifier():
uuid = persistent_load().get("client", {}).get("uuid", None)
if uuid:
@@ -404,4 +383,10 @@ class RestrictedUnpickler(pickle.Unpickler):
def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()
return RestrictedUnpickler(io.BytesIO(s)).load()
class KeyedDefaultDict(collections.defaultdict):
def __missing__(self, key):
self[key] = value = self.default_factory(key)
return value

View File

@@ -2,11 +2,21 @@ import os
import multiprocessing
import logging
import ModuleUpdate
ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt"))
ModuleUpdate.update()
# in case app gets imported by something like gunicorn
import Utils
Utils.local_path.cached_path = os.path.dirname(__file__)
from WebHostLib import app as raw_app
from waitress import serve
from WebHostLib.models import db
from WebHostLib.autolauncher import autohost
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files
configpath = os.path.abspath("config.yaml")
@@ -26,7 +36,9 @@ if __name__ == "__main__":
multiprocessing.freeze_support()
multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
update_sprites_lttp()
app = get_app()
create_options_files()
if app.config["SELFLAUNCH"]:
autohost(app.config)
if app.config["SELFHOST"]: # using WSGI, you just want to run get_app()

View File

@@ -3,10 +3,12 @@ import uuid
import base64
import socket
import jinja2.exceptions
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_compress import Compress
from worlds.AutoWorld import AutoWorldRegister
from .models import *
@@ -74,24 +76,53 @@ def register_session():
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
# Player settings pages
@app.route('/games/<string:game>/player-settings')
def player_settings(game):
return render_template(f"player-settings.html", game=game)
# Game sub-pages
@app.route('/games/<string:game>/<string:page>')
def game_pages(game, page):
return render_template(f"/games/{game}/{page}.html")
# Game landing pages
@app.route('/games/<game>')
def game_page(game):
return render_template(f"/games/{game}/{game}.html")
# 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.__doc__ if world.__doc__ else "No description provided."
return render_template("games/games.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)
@app.route('/tutorial')
@app.route('/tutorial/')
def tutorial_landing():
return render_template("tutorialLanding.html")
@app.route('/player-settings')
def player_settings_simple():
return render_template("playerSettings.html")
@app.route('/weighted-settings')
def player_settings():
return render_template("weightedSettings.html")
@app.route('/faq/<string:lang>/')
def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/seed/<suuid:seed>')
@@ -128,7 +159,7 @@ def display_log(room: UUID):
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")
@app.route('/hosted/<suuid:room>', methods=['GET', 'POST'])
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def hostRoom(room: UUID):
room = Room.get(id=room)
if room is None:
@@ -145,6 +176,11 @@ def hostRoom(room: UUID):
return render_template("hostRoom.html", room=room)
@app.route('/hosted/<suuid:room>', methods=['GET', 'POST'])
def hostRoomRedirect(room: UUID):
return redirect(url_for("hostRoom", room=room))
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static/static'),
@@ -153,4 +189,5 @@ def favicon():
from WebHostLib.customserver import run_server_process
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
app.register_blueprint(api.api_endpoints)

View File

@@ -4,14 +4,15 @@ from uuid import UUID
from flask import Blueprint, abort
from ..models import Room
from .. import cache
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
from . import generate, user # trigger registration
# unsorted/misc endpoints
@api_endpoints.route('/room_status/<suuid:room>')
def room_info(room: UUID):
room = Room.get(id=room)
@@ -22,3 +23,18 @@ def room_info(room: UUID):
"last_port": room.last_port,
"last_activity": room.last_activity,
"timeout": room.timeout}
@api_endpoints.route('/datapackage')
@cache.cached()
def get_datapackge():
from worlds import network_data_package
return network_data_package
@api_endpoints.route('/datapackage_version')
@cache.cached()
def get_datapackge_versions():
from worlds import network_data_package, AutoWorldRegister
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
version_package["version"] = network_data_package["version"]
return version_package

View File

@@ -7,6 +7,7 @@ import concurrent.futures
import sys
import typing
import time
import os
from pony.orm import db_session, select, commit
@@ -15,10 +16,13 @@ from Utils import restricted_loads
class CommonLocker():
"""Uses a file lock to signal that something is already running"""
def __init__(self, lockname: str):
lock_folder = "file_locks"
def __init__(self, lockname: str, folder=None):
if folder:
self.lock_folder = folder
os.makedirs(self.lock_folder, exist_ok=True)
self.lockname = lockname
self.lockfile = f"./{self.lockname}.lck"
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
class AlreadyRunningException(Exception):
@@ -26,9 +30,6 @@ class AlreadyRunningException(Exception):
if sys.platform == 'win32':
import os
class Locker(CommonLocker):
def __enter__(self):
try:
@@ -92,10 +93,10 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
"sid": generation.id,
"owner": generation.owner},
handle_generation_success, handle_generation_failure)
except:
except Exception as e:
generation.state = STATE_ERROR
commit()
raise
logging.exception(e)
else:
generation.state = STATE_STARTED

View File

@@ -12,7 +12,7 @@ def allowed_file(filename):
return filename.endswith(('.txt', ".yaml", ".zip"))
from Mystery import roll_settings
from Generate import roll_settings
from Utils import parse_yaml

View File

@@ -104,6 +104,7 @@ class WebHostContext(Context):
def get_random_port():
return random.randint(49152, 65535)
def run_server_process(room_id, ponyconfig: dict):
# establish DB connection for multidata and multisave
db.bind(**ponyconfig)
@@ -144,7 +145,9 @@ def run_server_process(room_id, ponyconfig: dict):
await ctx.shutdown_task
logging.info("Shutting down")
asyncio.run(main())
from .autolauncher import Locker
with Locker(room_id):
asyncio.run(main())
from WebHostLib import LOGS_FOLDER

View File

@@ -1,8 +1,8 @@
from flask import send_file, Response
from flask import send_file, Response, render_template
from pony.orm import select
from Patch import update_patch_data
from WebHostLib import app, Slot, Room, Seed
from WebHostLib import app, Slot, Room, Seed, cache
import zipfile
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
@@ -45,10 +45,10 @@ def download_raw_patch(seed_id, player_id: int):
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](seed_id)}.apbp"
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
@app.route("/slot_file/<suuid:seed_id>/<int:player_id>")
def download_slot_file(seed_id, player_id: int):
seed = Seed.get(id=seed_id)
slot_data: Slot = select(patch for patch in seed.slots if
@app.route("/slot_file/<suuid:room_id>/<int:player_id>")
def download_slot_file(room_id, player_id: int):
room = Room.get(id=room_id)
slot_data: Slot = select(patch for patch in room.seed.slots if
patch.player_id == player_id).first()
if not slot_data:
@@ -57,12 +57,27 @@ def download_slot_file(seed_id, player_id: int):
import io
if slot_data.game == "Minecraft":
fname = f"AP_{app.jinja_env.filters['suuid'](seed_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
from worlds.minecraft import mc_update_output
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
return send_file(io.BytesIO(data), as_attachment=True, attachment_filename=fname)
elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist():
if name.endswith("info.json"):
fname = name.rsplit("/", 1)[0]+".zip"
elif slot_data.game == "Ocarina of Time":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
else:
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, attachment_filename=fname)
@app.route("/templates")
@cache.cached()
def list_yaml_templates():
files = []
from worlds.AutoWorld import AutoWorldRegister
for world_name, world in AutoWorldRegister.world_types.items():
if not world.hidden:
files.append(world_name)
return render_template("templates.html", files=files)

View File

@@ -9,7 +9,7 @@ from flask import request, flash, redirect, url_for, session, render_template
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from Main import get_seed, seeddigits
from Mystery import handle_name
from Generate import handle_name
import pickle
from .models import *
@@ -80,7 +80,6 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.create_diff = True
name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
@@ -92,8 +91,6 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
erargs.names = ",".join(erargs.name[i] for i in range(1, playercount + 1))
del (erargs.name)
ERmain(erargs, seed)
return upload_to_db(target.name, owner, sid, race)

45
WebHostLib/lttpsprites.py Normal file
View File

@@ -0,0 +1,45 @@
import os
import threading
import json
from Utils import local_path
from worlds.alttp.Rom import Sprite
def update_sprites_lttp():
from tkinter import Tk
from LttPAdjuster import get_image_for_sprite
from LttPAdjuster import BackgroundTaskProgress
from LttPAdjuster import update_sprites
# Target directories
input_dir = local_path("data", "sprites", "alttpr")
output_dir = local_path("WebHostLib", "static", "generated")
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
# update sprites through gui.py's functions
done = threading.Event()
top = Tk()
top.withdraw()
BackgroundTaskProgress(top, update_sprites, "Updating Sprites", lambda succesful, resultmessage: done.set())
while not done.isSet():
top.update()
spriteData = []
for file in os.listdir(input_dir):
sprite = Sprite(os.path.join(input_dir, file))
if not sprite.name:
print("Warning:", file, "has no name.")
sprite.name = file.split(".", 1)[0]
if sprite.valid:
with open(os.path.join(output_dir, "sprites", f"{os.path.splitext(file)[0]}.gif"), 'wb') as image:
image.write(get_image_for_sprite(sprite, True))
spriteData.append({"file": file, "author": sprite.author_name, "name": sprite.name})
else:
print(file, "dropped, as it has no valid sprite data.")
spriteData.sort(key=lambda entry: entry["name"])
with open(f'{output_dir}/spriteData.json', 'w') as file:
json.dump({"sprites": spriteData}, file, indent=1)
return spriteData

73
WebHostLib/options.py Normal file
View File

@@ -0,0 +1,73 @@
import os
from Utils import __version__
from jinja2 import Template
import yaml
import json
from worlds.AutoWorld import AutoWorldRegister
target_folder = os.path.join("WebHostLib", "static", "generated")
def create():
def dictify_range(option):
data = {option.range_start: 0, option.range_end: 0, "random": 0, "random-low": 0, "random-high": 0,
option.default: 50}
notes = {
option.range_start: "minimum value",
option.range_end: "maximum value"
}
return data, notes
for game_name, world in AutoWorldRegister.world_types.items():
res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
options=world.options, __version__=__version__, game=game_name, yaml_dump=yaml.dump,
dictify_range=dictify_range
)
with open(os.path.join(target_folder, game_name + ".yaml"), "w") as f:
f.write(res)
# Generate JSON files for player-settings pages
player_settings = {
"baseOptions": {
"description": "Generated by https://archipelago.gg/",
"name": "Player",
},
}
game_options = {}
for option_name, option in world.options.items():
if option.options:
this_option = {
"type": "select",
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
"defaultValue": None,
"options": []
}
for sub_option_name, sub_option_id in option.options.items():
this_option["options"].append({
"name": option.get_option_name(sub_option_id),
"value": sub_option_name,
})
if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name
game_options[option_name] = this_option
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
game_options[option_name] = {
"type": "range",
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
"defaultValue": option.default if hasattr(option, "default") else option.range_start,
"min": option.range_start,
"max": option.range_end,
}
player_settings["gameOptions"] = game_options
with open(os.path.join(target_folder, game_name + ".json"), "w") as f:
f.write(json.dumps(player_settings, indent=2, separators=(',', ': ')))

View File

@@ -1,6 +1,6 @@
flask>=2.0.0
flask>=2.0.1
pony>=0.7.14
waitress>=2.0.0
flask-caching>=1.10.1
Flask-Compress>=1.9.0
Flask-Compress>=1.10.1
Flask-Limiter>=1.4

View File

@@ -0,0 +1,50 @@
window.addEventListener('load', () => {
const tutorialWrapper = document.getElementById('faq-wrapper');
new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status === 404) {
reject("Sorry, the tutorial is not available in that language yet.");
return;
}
if (ajax.status !== 200) {
reject("Something went wrong while loading the tutorial.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/assets/faq/` +
`faq_${tutorialWrapper.getAttribute('data-lang')}.md`, true);
ajax.send();
}).then((results) => {
// Populate page with HTML generated from markdown
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
// Reset the id of all header divs to something nicer
const headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'));
const scrollTargetIndex = window.location.href.search(/#[A-z0-9-_]*$/);
for (let i=0; i < headers.length; i++){
const headerId = headers[i].innerText.replace(/[ ]/g,'-').toLowerCase()
headers[i].setAttribute('id', headerId);
headers[i].addEventListener('click', () =>
window.location.href = window.location.href.substring(0, scrollTargetIndex) + `#${headerId}`);
}
// Manually scroll the user to the appropriate header if anchor navigation is used
if (scrollTargetIndex > -1) {
try{
const scrollTarget = window.location.href.substring(scrollTargetIndex + 1);
document.getElementById(scrollTarget).scrollIntoView({ behavior: "smooth" });
} catch(error) {
console.error(error);
}
}
}).catch((error) => {
console.error(error);
tutorialWrapper.innerHTML =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
});
});

View File

@@ -0,0 +1,10 @@
# Frequently Asked Questions
## What is a randomizer?
Who's on first.
## What is a multi-world?
What's on second.
## What does multi-game mean?
I don't know's on third.

View File

@@ -0,0 +1,49 @@
window.addEventListener('load', () => {
// Reload tracker every 15 seconds
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item tracker
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
// Update only counters in the location-table
let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
for (let i = 0; i < counters.length; i++) {
counters[i].innerHTML = fakeCounters[i].innerHTML;
}
};
ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let i = 0; i < categories.length; i++) {
let hide_id = categories[i].id.split('-')[0];
if (hide_id == 'Total') {
continue;
}
categories[i].addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'-header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
});

View File

@@ -0,0 +1,206 @@
let gameName = null;
window.addEventListener('load', () => {
gameName = document.getElementById('player-settings').getAttribute('data-game');
// Update game name on page
document.getElementById('game-name').innerHTML = gameName;
Promise.all([fetchSettingData()]).then((results) => {
// Page setup
createDefaultSettings(results[0]);
buildUI(results[0]);
adjustHeaderWidth();
// Event listeners
document.getElementById('export-settings').addEventListener('click', () => exportSettings());
document.getElementById('generate-race').addEventListener('click', () => generateGame(true))
document.getElementById('generate-game').addEventListener('click', () => generateGame());
// Name input field
const playerSettings = JSON.parse(localStorage.getItem(gameName));
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
nameInput.value = playerSettings.name;
}).catch((error) => {
const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
})
});
const fetchSettingData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject(ajax.responseText);
return;
}
try{ resolve(JSON.parse(ajax.responseText)); }
catch(error){ reject(error); }
};
ajax.open('GET', `${window.location.origin}/static/generated/${gameName}.json`, true);
ajax.send();
});
const createDefaultSettings = (settingData) => {
if (!localStorage.getItem(gameName)) {
const newSettings = {
[gameName]: {},
};
for (let baseOption of Object.keys(settingData.baseOptions)){
newSettings[baseOption] = settingData.baseOptions[baseOption];
}
for (let gameOption of Object.keys(settingData.gameOptions)){
newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue;
}
localStorage.setItem(gameName, JSON.stringify(newSettings));
}
};
const buildUI = (settingData) => {
// Game Options
const leftGameOpts = {};
const rightGameOpts = {};
Object.keys(settingData.gameOptions).forEach((key, index) => {
if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; }
else { rightGameOpts[key] = settingData.gameOptions[key]; }
});
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
};
const buildOptionsTable = (settings, romOpts = false) => {
const currentSettings = JSON.parse(localStorage.getItem(gameName));
const table = document.createElement('table');
const tbody = document.createElement('tbody');
Object.keys(settings).forEach((setting) => {
const tr = document.createElement('tr');
// td Left
const tdl = document.createElement('td');
const label = document.createElement('label');
label.setAttribute('for', setting);
label.setAttribute('data-tooltip', settings[setting].description);
label.innerText = `${settings[setting].displayName}:`;
tdl.appendChild(label);
tr.appendChild(tdl);
// td Right
const tdr = document.createElement('td');
let element = null;
switch(settings[setting].type){
case 'select':
element = document.createElement('div');
element.classList.add('select-container');
let select = document.createElement('select');
select.setAttribute('id', setting);
select.setAttribute('data-key', setting);
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
settings[setting].options.forEach((opt) => {
const option = document.createElement('option');
option.setAttribute('value', opt.value);
option.innerText = opt.name;
if ((isNaN(currentSettings[gameName][setting]) &&
(parseInt(opt.value, 10) === parseInt(currentSettings[gameName][setting]))) ||
(opt.value === currentSettings[gameName][setting]))
{
option.selected = true;
}
select.appendChild(option);
});
select.addEventListener('change', (event) => updateGameSetting(event));
element.appendChild(select);
break;
case 'range':
element = document.createElement('div');
element.classList.add('range-container');
let range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('data-key', setting);
range.setAttribute('min', settings[setting].min);
range.setAttribute('max', settings[setting].max);
range.value = currentSettings[gameName][setting];
range.addEventListener('change', (event) => {
document.getElementById(`${setting}-value`).innerText = event.target.value;
updateGameSetting(event);
});
element.appendChild(range);
let rangeVal = document.createElement('span');
rangeVal.classList.add('range-value');
rangeVal.setAttribute('id', `${setting}-value`);
rangeVal.innerText = currentSettings[gameName][setting] ?? settings[setting].defaultValue;
element.appendChild(rangeVal);
break;
default:
console.error(`Unknown setting type: ${settings[setting].type}`);
console.error(setting);
return;
}
tdr.appendChild(element);
tr.appendChild(tdr);
tbody.appendChild(tr);
});
table.appendChild(tbody);
return table;
};
const updateBaseSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value);
localStorage.setItem(gameName, JSON.stringify(options));
};
const updateGameSetting = (event) => {
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);
localStorage.setItem(gameName, JSON.stringify(options));
};
const exportSettings = () => {
const settings = JSON.parse(localStorage.getItem(gameName));
if (!settings.name || settings.name.trim().length === 0) { settings.name = "noname"; }
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
/** Create an anchor and trigger a download of a text file. */
const download = (filename, text) => {
const downloadLink = document.createElement('a');
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
downloadLink.setAttribute('download', filename);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
const generateGame = (raceMode = false) => {
axios.post('/api/generate', {
weights: { player: localStorage.getItem(gameName) },
presetData: { player: localStorage.getItem(gameName) },
playerCount: 1,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = 'Something went wrong and your game could not be generated.';
if (error.response.data.text) {
userMessage.innerText += ' ' + error.response.data.text;
}
userMessage.classList.add('visible');
window.scrollTo(0, 0);
console.error(error);
});
};

View File

@@ -1,194 +0,0 @@
window.addEventListener('load', () => {
Promise.all([fetchSettingData(), fetchSpriteData()]).then((results) => {
// Page setup
createDefaultSettings(results[0]);
buildUI(results[0]);
adjustHeaderWidth();
// Event listeners
document.getElementById('export-settings').addEventListener('click', () => exportSettings());
document.getElementById('generate-race').addEventListener('click', () => generateGame(true))
document.getElementById('generate-game').addEventListener('click', () => generateGame());
// Name input field
const playerSettings = JSON.parse(localStorage.getItem('playerSettings'));
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateSetting(event));
nameInput.value = playerSettings.name;
// Sprite options
const spriteData = JSON.parse(results[1]);
const spriteSelect = document.getElementById('sprite');
spriteData.sprites.forEach((sprite) => {
if (sprite.name.trim().length === 0) { return; }
const option = document.createElement('option');
option.setAttribute('value', sprite.name.trim());
if (playerSettings.rom.sprite === sprite.name.trim()) { option.selected = true; }
option.innerText = sprite.name;
spriteSelect.appendChild(option);
});
}).catch((error) => {
console.error(error);
})
});
const fetchSettingData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject(ajax.responseText);
return;
}
try{ resolve(JSON.parse(ajax.responseText)); }
catch(error){ reject(error); }
};
ajax.open('GET', `${window.location.origin}/static/static/playerSettings.json`, true);
ajax.send();
});
const createDefaultSettings = (settingData) => {
if (!localStorage.getItem('playerSettings')) {
const newSettings = {};
for (let roSetting of Object.keys(settingData.readOnly)){
newSettings[roSetting] = settingData.readOnly[roSetting];
}
for (let generalOption of Object.keys(settingData.generalOptions)){
newSettings[generalOption] = settingData.generalOptions[generalOption];
}
for (let gameOption of Object.keys(settingData.gameOptions)){
newSettings[gameOption] = settingData.gameOptions[gameOption].defaultValue;
}
newSettings.rom = {};
for (let romOption of Object.keys(settingData.romOptions)){
newSettings.rom[romOption] = settingData.romOptions[romOption].defaultValue;
}
localStorage.setItem('playerSettings', JSON.stringify(newSettings));
}
};
const buildUI = (settingData) => {
// Game Options
const leftGameOpts = {};
const rightGameOpts = {};
Object.keys(settingData.gameOptions).forEach((key, index) => {
if (index < Object.keys(settingData.gameOptions).length / 2) { leftGameOpts[key] = settingData.gameOptions[key]; }
else { rightGameOpts[key] = settingData.gameOptions[key]; }
});
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
// ROM Options
const leftRomOpts = {};
const rightRomOpts = {};
Object.keys(settingData.romOptions).forEach((key, index) => {
if (index < Object.keys(settingData.romOptions).length / 2) { leftRomOpts[key] = settingData.romOptions[key]; }
else { rightRomOpts[key] = settingData.romOptions[key]; }
});
document.getElementById('rom-options-left').appendChild(buildOptionsTable(leftRomOpts, true));
document.getElementById('rom-options-right').appendChild(buildOptionsTable(rightRomOpts, true));
};
const buildOptionsTable = (settings, romOpts = false) => {
const currentSettings = JSON.parse(localStorage.getItem('playerSettings'));
const table = document.createElement('table');
const tbody = document.createElement('tbody');
Object.keys(settings).forEach((setting) => {
const tr = document.createElement('tr');
// td Left
const tdl = document.createElement('td');
const label = document.createElement('label');
label.setAttribute('for', setting);
label.setAttribute('data-tooltip', settings[setting].description);
label.innerText = `${settings[setting].friendlyName}:`;
tdl.appendChild(label);
tr.appendChild(tdl);
// td Right
const tdr = document.createElement('td');
const select = document.createElement('select');
select.setAttribute('id', setting);
select.setAttribute('data-key', setting);
if (romOpts) { select.setAttribute('data-romOpt', '1'); }
settings[setting].options.forEach((opt) => {
const option = document.createElement('option');
option.setAttribute('value', opt.value);
option.innerText = opt.name;
if ((isNaN(currentSettings[setting]) && (parseInt(opt.value, 10) === parseInt(currentSettings[setting]))) ||
(opt.value === currentSettings[setting])) {
option.selected = true;
}
select.appendChild(option);
});
select.addEventListener('change', (event) => updateSetting(event));
tdr.appendChild(select);
tr.appendChild(tdr);
tbody.appendChild(tr);
});
table.appendChild(tbody);
return table;
};
const updateSetting = (event) => {
const options = JSON.parse(localStorage.getItem('playerSettings'));
if (event.target.getAttribute('data-romOpt')) {
options.rom[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value, 10);
} else {
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value, 10);
}
localStorage.setItem('playerSettings', JSON.stringify(options));
};
const exportSettings = () => {
const settings = JSON.parse(localStorage.getItem('playerSettings'));
if (!settings.name || settings.name.trim().length === 0) { settings.name = "noname"; }
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
};
/** Create an anchor and trigger a download of a text file. */
const download = (filename, text) => {
const downloadLink = document.createElement('a');
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
downloadLink.setAttribute('download', filename);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
const generateGame = (raceMode = false) => {
axios.post('/api/generate', {
weights: { player: localStorage.getItem('playerSettings') },
presetData: { player: localStorage.getItem('playerSettings') },
playerCount: 1,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = 'Something went wrong and your game could not be generated.';
userMessage.classList.add('visible');
window.scrollTo(0, 0);
console.error(error);
});
};
const fetchSpriteData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject('Unable to fetch sprite data.');
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/static/spriteData.json`, true);
ajax.send();
});

View File

@@ -17,6 +17,47 @@ window.addEventListener('load', () => {
paging: false,
info: false,
dom: "t",
columnDefs: [
{
targets: 'hours',
render: function (data, type, row) {
if (type === "sort" || type === 'type') {
if (data === "None")
return -1;
return parseInt(data);
}
if (data === "None")
return data;
let hours = Math.floor(data / 3600);
let minutes = Math.floor((data - (hours * 3600)) / 60);
if (minutes < 10) {minutes = "0"+minutes;}
return hours+':'+minutes;
}
},
{
targets: 'number',
render: function (data, type, row) {
if (type === "sort" || type === 'type') {
return parseFloat(data);
}
return data;
}
},
{
targets: 'fraction',
render: function (data, type, row) {
let splitted = data.split("/", 1);
let current = splitted[0]
if (type === "sort" || type === 'type') {
return parseInt(current);
}
return data;
}
},
],
// DO NOT use the scrollX or scrollY options. They cause DataTables to split the thead from
// the tbody and render two separate tables.

View File

@@ -15,8 +15,8 @@ One Server Host exists per Factorio World in an Archipelago Multiworld, any numb
## Installation Procedures
### Dedicated Server Setup
You need a dedicated isolated Factorio installation that the FactorioClient can take control over, if you intend to both emit a world and play, you need to follow both this setup and the player setup.
This requires two Factorio installations. The easiest and cheapest way to do so is to either buy or register a Factorio on factorio.com, which allows you to download as many Factorio games as you want.
You need a dedicated isolated Factorio installation that the FactorioClient can take control over. If you intend to both host a world and play on the same device, you will need two separate Factorio installations; one for the FactorioClient to hook into and control, and one for you to play on.
The easiest and cheapest way to do so is to either buy or register a Factorio key on factorio.com, which allows you to download as many Factorio games as you want. If you own a steam copy already you can link your account on the website.
1. Download the latest Factorio from https://factorio.com/download for your system, for Windows the recommendation is "win64-manual".
2. Make sure the Factorio you play and the Factorio you use for hosting do not share paths. If you downloaded the "manual" version, this is already the case, otherwise, go into the hosting Factorio's folder and put the following text into its `config-path.cfg`:
@@ -24,28 +24,29 @@ This requires two Factorio installations. The easiest and cheapest way to do so
config-path=__PATH__executable__/../../config
use-system-read-write-data-directories=false
```
3. Navigate to where you installed Archipelago and open the host.yaml file as text. Find the entry `executable` under `factorio_options` and set it to point to your Factorio. If you put Factorio into your Archipelago folder, this would already match.
3. In this same folder if there are shortcuts named "mods" and "saves" delete these and replace with folders with the same names.
4. Navigate to where you installed ArchipelagoFactorioClient and open the host.yaml file as text. Find the entry `executable` under `factorio_options` and set it to point to your hosting Factorio.exe. If you put Factorio into your Archipelago folder, this would already match.<br>
ex.
```yaml
factorio_options:
executable: C:\\Program Files\\factorio\\bin\\x64\\factorio"
```
### Player Setup
- Manually install the AP mod for the correct world you want to join, then use Factorio's built-in multiplayer.
- Manually install the AP mod for the correct world you want to join, then use Factorio's built-in multiplayer. If you're connecting to a FactorioClient on the same system you will connect to localhost
## Joining a MultiWorld Game
1. Make a fresh world. Using any Factorio, create a savegame with options of your choice and save that world as Archipelago.
1. Install the generated Factorio AP Mod (would be in /Mods after step 2 of Setup)
2. Take that savegame and put it into your Archipelago folder
2. Run FactorioClient, it should launch a Factorio server, which you can control with /factorio <original factorio commands>,
3. Install the generated Factorio AP Mod
* It should start up, create a world and become ready for Factorio connections.
3. In FactorioClient, do /connect <Archipelago Server Address> to join that multiworld. You can find further commands with /help as well as !help once connected.
4. Run FactorioClient, it should launch a Factorio server, which you can control with `/factorio <original factorio commands>`,
* It should say it loaded the Archipelago mod and found a bridge file. If not, the most likely case is that the mod is not correctly installed or activated.
* / commands are run on your local client, ! commands are requests for the AP server
5. In FactorioClient, do `/connect <Archipelago Server Address>` to join that multiworld. You can find further commands with `/help` as well as `!help` once connected.
* Players should be able to connect to your Factorio Server and begin playing.
* / commands are run on your local client, ! commands are requests for the AP server
* Players should be able to connect to your Factorio Server and begin playing.
4. You can join yourself by connecting to address localhost, other people will need to connect to your IP and you may need to port forward for the Factorio Server for those connections.

View File

@@ -1,54 +1,12 @@
# Minecraft Randomizer Setup Guide
#Automatic Hosting Install
- download and install [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) and choose the `Minecraft Client` module
## Required Software
### Server Host
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.16.5.html)
- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
### Players
- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition)
## Installation Procedures
### Dedicated Server Setup
Only one person has to do this setup and host a dedicated server for everyone else playing to connect to.
1. Download the 1.16.5 **Minecraft Forge** installer from the link above, making sure to download the most recent recommended version.
2. Run the `forge-1.16.5-xx.x.x-installer.jar` file and choose **install server**.
- On this page you will also choose where to install the server to remember this directory it's important in the next step.
3. Navigate to where you installed the server and open `forge-1.16.5-xx.x.x.jar`
- Upon first launch of the server it will close and ask you to accept Minecraft's EULA. There will be a new file called `eula.txt` that contains a link to Minecraft's EULA, and a line that you need to change to `eula=true` to accept Minecraft's EULA.
- This will create the appropriate directories for you to place the files in the following step.
4. Place the `aprandomizer-x.x.x.jar` from the link above file into the `mods` folder of the above installation of your forge server.
- Once again run the server, it will load up and generate the required directory `APData` for when you are ready to play a game!
### Basic Player Setup
- Purchase and install Minecraft from the above link.
**You're Done**.
Players only need to have a Vanilla unmodified version of Minecraft to play!
### Advanced Player Setup
***This is not required to play a randomized minecraft game.***
however this recommended as it helps make the experience more enjoyable.
#### Recomended Mods
- [JourneyMap](https://www.curseforge.com/minecraft/mc-mods/journeymap) (Minimap)
1. Install and run Minecraft from the link above at least once.
2. Run the `forge-1.16.5-xx.x.x-installer.jar` file and choose **install client**.
- Start Minecraft forge at least once to create the directories needed for the next steps.
3. Navigate to your minecraft install directory and place desired mods `.jar` file the in the `mods` directory.
- The default install directories are as follows.
- Windows `%APPDATA%\.minecraft\mods`
- macOS `~/Library/Application Support/minecraft/mods`
- Linux `~/.minecraft/mods`
## Configuring your YAML file
### What is a YAML file and why do I need one?
@@ -60,10 +18,10 @@ can all have different options.
### Where do I get a YAML file?
A basic minecraft yaml will look like this.
```yaml
description: Template Name
description: Basic Minecraft Yaml
# Your name in-game. Spaces will be replaced with underscores and
# there is a 16 character limit
name: YourName
name: YourName
game: Minecraft
# Shared Options supported by all games:
@@ -71,44 +29,61 @@ accessibility: locations
progression_balancing: on
# Minecraft Specific Options
# Number of advancements required (out of 92 total) to spawn the
# Ender Dragon and complete the game.
advancement_goal:
few: 0 #30
normal: 1 #50
many: 0 #70
Minecraft:
# Number of advancements required (87 max) to spawn the Ender Dragon and complete the game.
advancement_goal: 50
# Modifies the level of items logically required for exploring
# dangerous areas and fighting bosses.
combat_difficulty:
easy: 0
normal: 1
hard: 0
# Number of dragon egg shards to collect (30 max) before the Ender Dragon will spawn.
egg_shards_required: 10
# Junk-fills certain RNG-reliant or tedious advancements with XP rewards.
include_hard_advancements:
on: 0
off: 1
# Number of egg shards available in the pool (30 max).
egg_shards_available: 15
# Junk-fills extremely difficult advancements;
# this is only How Did We Get Here? and Adventuring Time.
include_insane_advancements:
on: 0
off: 1
# Modifies the level of items logically required for
# exploring dangerous areas and fighting bosses.
combat_difficulty:
easy: 0
normal: 1
hard: 0
# Junk-fills certain RNG-reliant or tedious advancements.
include_hard_advancements:
on: 0
off: 1
# Junk-fills extremely difficult advancements;
# this is only How Did We Get Here? and Adventuring Time.
include_insane_advancements:
on: 0
off: 1
# Some advancements require defeating the Ender Dragon;
# this will junk-fill them so you won't have to finish to send some items.
include_postgame_advancements:
on: 0
off: 1
# Some advancements require defeating the Ender Dragon;
# this will junk-fill them, so you won't have to finish them to send some items.
include_postgame_advancements:
on: 0
off: 1
# Enables shuffling of villages, outposts, fortresses, bastions, and end cities.
shuffle_structures:
on: 0
off: 1
#enables shuffling of villages, outposts, fortresses, bastions, and end cities.
shuffle_structures:
on: 1
off: 0
# Adds structure compasses to the item pool,
# which point to the nearest indicated structure.
structure_compasses:
on: 0
off: 1
# Replaces a percentage of junk items with bee traps
# which spawn multiple angered bees around every player when received.
bee_traps:
0: 1
25: 0
50: 0
75: 0
100: 0
```
## Joining a MultiWorld Game
### Obtain your Minecraft data file
@@ -118,8 +93,7 @@ When you join a multiworld game, you will be asked to provide your YAML file to
is done, the host will provide you with either a link to download your data file, or with a zip file containing
everyone's data files. Your data file should have a `.apmc` extension.
Put your data file in your forge servers `APData` folder. Make sure to remove any previous data file that was in there
previously.
double click on your `.apmc` file to have the minecraft client auto-launch the installed forge server.
### Connect to the MultiServer
After having placed your data file in the `APData` folder, start the Forge server and make sure you have OP
@@ -134,3 +108,23 @@ When the console tells you that you have joined the room, you're ready to begin
on successfully joining a multiworld game! At this point any additional minecraft players may connect to your
forge server.
## Manual Installation Procedures
this is only required if you wish to set up a forge install yourself, its recommended to just use the Archipelago Installer.
###Required Software
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.16.5.html)
- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
**DO NOT INSTALL THIS ON YOUR CLIENT**
### Dedicated Server Setup
Only one person has to do this setup and host a dedicated server for everyone else playing to connect to.
1. Download the 1.16.5 **Minecraft Forge** installer from the link above, making sure to download the most recent recommended version.
2. Run the `forge-1.16.5-xx.x.x-installer.jar` file and choose **install server**.
- On this page you will also choose where to install the server to remember this directory it's important in the next step.
3. Navigate to where you installed the server and open `forge-1.16.5-xx.x.x.jar`
- Upon first launch of the server it will close and ask you to accept Minecraft's EULA. There will be a new file called `eula.txt` that contains a link to Minecraft's EULA, and a line that you need to change to `eula=true` to accept Minecraft's EULA.
- This will create the appropriate directories for you to place the files in the following step.
4. Place the `aprandomizer-x.x.x.jar` from the link above file into the `mods` folder of the above installation of your forge server.
- Once again run the server, it will load up and generate the required directory `APData` for when you are ready to play a game!

View File

@@ -0,0 +1,69 @@
# Risk of Rain 2 Setup Guide
## Install using r2modman
### Install r2modman
Head on over to the r2modman page on Thunderstore and follow the installation instructions.
https://thunderstore.io/package/ebkr/r2modman/
### Install Archipelago Mod using r2modman
You can install the Archipelago mod using r2modman in one of two ways.
One, you can use the Thunderstore website and click on the "Install with Mod Manager" link.
https://thunderstore.io/package/ArchipelagoMW/Archipelago/
You can also search for the "Archipelago" mod in the r2modman interface.
The mod manager should automatically install all necessary dependencies as well.
### Running the Modded Game
Click on the "Start modded" button in the top left in r2modman to start the game with the
Archipelago mod installed.
## Joining an Archipelago Session
There will be a menu button on the right side of the screen in the character select menu.
Click it in order to bring up the in lobby mod config.
From here you can expand the Archipelago sections and fill in the relevant info.
Keep password blank if there is no password on the server.
Simply check `Enable Archipelago?` and when you start the run it will automatically connect.
## Gameplay
The Risk of Rain 2 players send checks by causing items to spawn in-game. That means opening chests or killing bosses, generally.
An item check is only sent out after a certain number of items are picked up. This count is configurable in the player's YAML.
## YAML Settings
An example YAML would look like this:
```yaml
description: Ijwu-ror2
name: Ijwu
game:
Risk of Rain 2: 1
Risk of Rain 2:
total_locations: 15
total_revivals: 4
start_with_revive: true
item_pickup_step: 1
enable_lunar: true
```
| Name | Description | Allowed values |
| ---- | ----------- | -------------- |
| total_locations | The total number of location checks that will be attributed to the Risk of Rain player. This option is ALSO the total number of items in the item pool for the Risk of Rain player. | 10 - 50 |
| total_revivals | The total number of items in the Risk of Rain player's item pool (items other players pick up for them) replaced with `Dio's Best Friend`. | 0 - 5 |
| start_with_revive | Starts the player off with a `Dio's Best Friend`. Functionally equivalent to putting a `Dio's Best Friend` in your `starting_inventory`. | true/false |
| item_pickup_step | The number of item pickups which you are allowed to claim before they become an Archipelago location check. | 0 - 5 |
| enable_lunar | Allows for lunar items to be shuffled into the item pool on behalf of the Risk of Rain player. | true/false |
Using the example YAML above: the Risk of Rain 2 player will have 15 total items which they can pick up for other players. (total_locations = 15)
They will have 15 items waiting for them in the item pool which will be distributed out to the multiworld. (total_locations = 15)
They will complete a location check every second item. (item_pickup_step = 1)
They will have 4 of the items which other players can grant them replaced with `Dio's Best Friend`. (total_revivals = 4)
The player will also start with a `Dio's Best Friend`. (start_with_revive = true)
The player will have lunar items shuffled into the item pool on their behalf. (enable_lunar = true)

View File

@@ -139,5 +139,24 @@
]
}
]
},
{
"gameTitle": "Risk of Rain 2",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Risk of Rain 2 integration for Archipelago multiworld games.",
"files": [
{
"language": "English",
"filename": "ror2/setup_en.md",
"link": "ror2/setup/en",
"authors": [
"Ijwu"
]
}
]
}
]
}
]

View File

@@ -1,7 +1,7 @@
# A Link to the Past Randomizer Setup Guide
## Benötigte Software
- [MultiWorld Utilities](https://github.com/Berserker66/MultiWorld-Utilities/releases)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Included in the above Utilities)
- Hardware oder Software zum Laden und Abspielen von SNES Rom-Dateien
- Ein Emulator, der lua-scripts abspielen kann
@@ -15,7 +15,7 @@
### Windows
1. Lade die Multiworld Utilities herunter und führe die Installation aus. Sei sicher, dass du immer die
aktuellste Version installiert hast.**Die Datei befindet sich im "assets"-Kasten unter der jeweiligen Versionsinfo!**.
Für normale Multiworld-Spiele lädst du die `Setup.BerserkerMultiworld.exe` herunter.
Für normale Multiworld-Spiele lädst du die `Setup.Archipelago.exe` herunter.
- Für den Doorrandomizer muss die alternative doors-Variante geladen werden.
- Während der Installation fragt dich das Programm nach der japanischen 1.0 ROM-Datei. Wenn du die Software
bereits installiert hast und einfach nur updaten willst, wirst du nicht nochmal danach gefragt.

View File

@@ -7,7 +7,7 @@
</div>
## Required Software
- [MultiWorld Utilities](https://github.com/Berserker66/MultiWorld-Utilities/releases)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Included in the above Utilities)
- Hardware or software capable of loading and playing SNES ROM files
- An emulator capable of running Lua scripts
@@ -21,7 +21,7 @@
### Windows Setup
1. Download and install the MultiWorld Utilities from the link above, making sure to install the most recent version.
**The file is located in the assets section at the bottom of the version information**. If you intend to play normal
multiworld games, you want `Setup.BerserkerMultiWorld.exe`
multiworld games, you want `Setup.Archipelago.exe`
- If you intend to play the doors variant of multiworld, you will want to download the alternate doors file.
- During the installation process, you will be asked to browse for your Japanese 1.0 ROM file. If you have
installed this software before and are simply upgrading now, you will not be prompted to locate your
@@ -53,22 +53,6 @@ can all have different options.
The [Generate Game](/player-settings) page on the website allows you to configure your personal settings and
export a YAML file from them.
### Advanced YAML configuration
A more advanced version of the YAML file can be created using the [Weighted Settings](/weighted-settings) page,
which allows you to configure up to three presets. The Weighted Settings page has many options which are
primarily represented with sliders. This allows you to choose how likely certain options are to occur relative
to other options within a category.
For example, imagine the generator creates a bucket labeled "Map Shuffle", and places folded pieces of paper
into the bucket for each sub-option. Also imagine your chosen value for "On" is 20, and your value for "Off" is 40.
In this example, sixty pieces of paper are put into the bucket. Twenty for "On" and forty for "Off". When the
generator is deciding whether or not to turn on map shuffle for your game, it reaches into this bucket and pulls
out a piece of paper at random. In this example, you are much more likely to have map shuffle turned off.
If you never want an option to be chosen, simply set its value to zero. Remember that each setting must have at
lease one option set to a number greater than zero.
### Verifying your YAML file
If you would like to validate your YAML file to make sure it works, you may do so on the
[YAML Validator](/mysterycheck) page.
@@ -144,7 +128,7 @@ on successfully joining a multiworld game!
## Hosting a MultiWorld game
The recommended way to host a game is to use the hosting service provided on
[the website](https://berserkermulti.world/generate). The process is relatively simple:
[the website](/generate). The process is relatively simple:
1. Collect YAML files from your players.
2. Create a zip file containing your players' YAML files.

View File

@@ -7,7 +7,7 @@
</div>
## Software requerido
- [MultiWorld Utilities](https://github.com/Berserker66/MultiWorld-Utilities/releases)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Incluido en Multiworld Utilities)
- Hardware o software capaz de cargar y ejecutar archivos de ROM de SNES
- Un emulador capaz de ejecutar scripts Lua
@@ -20,7 +20,7 @@
### Instalación en Windows
1. Descarga e instala MultiWorld Utilities desde el enlace anterior, asegurando que instalamos la versión más reciente.
**El archivo esta localizado en la sección "assets" en la parte inferior de la información de versión**. Si tu intención es jugar la versión normal de multiworld, necesitarás el archivo `Setup.BerserkerMultiWorld.exe`
**El archivo esta localizado en la sección "assets" en la parte inferior de la información de versión**. Si tu intención es jugar la versión normal de multiworld, necesitarás el archivo `Setup.Archipelago.exe`
- Si estas interesado en jugar la variante que aleatoriza las puertas internas de las mazmorras, necesitaras bajar 'Setup.BerserkerMultiWorld.Doors.exe'
- Durante el proceso de instalación, se te pedirá donde esta situado tu archivo ROM japonés v1.0. Si ya habías instalado este software con anterioridad y simplemente estas actualizando, no se te pedirá la localización del archivo una segunda vez.
- Puede ser que el programa pida la instalación de Microsoft Visual C++. Si ya lo tienes en tu ordenador (posiblemente por que un juego de Steam ya lo haya instalado), el instalador no te pedirá su instalación.
@@ -136,7 +136,7 @@ por unirte satisfactoriamente a una partida de multiworld!
## Hospedando una partida de multiworld
La manera recomendad para hospedar una partida es usar el servicio proveído en
[el sitio web](https://berserkermulti.world/generate). El proceso es relativamente sencillo:
[el sitio web](/generate). El proceso es relativamente sencillo:
1. Recolecta los ficheros YAML de todos los jugadores que participen.
2. Crea un fichero ZIP conteniendo esos ficheros.

View File

@@ -7,7 +7,7 @@
</div>
## Logiciels requis
- [Utilitaires du MultiWorld](https://github.com/Berserker66/MultiWorld-Utilities/releases)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Inclus dans les utilitaires précédents)
- Une solution logicielle ou matérielle capable de charger et de lancer des fichiers ROM de SNES
- Un émulateur capable d'éxécuter des scripts Lua

View File

@@ -2,7 +2,7 @@
## Configuration
1. Plando features have to be enabled first, before they can be used (opt-in).
2. To do so, go to your installation directory (Windows default: `C:\ProgramData\BerserkerMultiWorld`),
2. To do so, go to your installation directory (Windows default: `C:\ProgramData\Archipelago`),
then open the host.yaml file with a text editor.
3. In it, you're looking for the option key `plando_options`. To enable all plando modules you can set the
value to
@@ -13,7 +13,7 @@
### Bosses
- This module is enabled by default and available to be used on
[https://archipelago.gg/generate](https://archipelago.gg/generate)
[https://archipelago.gg/generate](/generate)
- Plando versions of boss shuffles can be added like any other boss shuffle option in a yaml and weighted.
- Boss Plando works as a list of instructions from left to right, if any arenas are empty at the end,
it defaults to vanilla

View File

@@ -1,483 +0,0 @@
let spriteData = null;
window.addEventListener('load', () => {
const gameSettings = document.getElementById('weighted-settings');
Promise.all([fetchWeightedSettingsYaml(), fetchWeightedSettingsJson(), fetchSpriteData()]).then((results) => {
// Load YAML into object
const sourceData = jsyaml.safeLoad(results[0], { json: true });
const wsVersion = sourceData.ws_version;
delete sourceData.ws_version; // Do not include the settings version number in the export
// Check if settings exist in localStorage. If no settings are present, this is a first load (or reset to default)
// and the version number should be silently updated
if (!localStorage.getItem('weightedSettings1')) {
localStorage.setItem('wsVersion', wsVersion);
}
// Update localStorage with three settings objects. Preserve original objects if present.
for (let i=1; i<=3; i++) {
const localSettings = JSON.parse(localStorage.getItem(`weightedSettings${i}`));
const updatedObj = localSettings ? Object.assign(sourceData, localSettings) : sourceData;
localStorage.setItem(`weightedSettings${i}`, JSON.stringify(updatedObj));
}
// Build the entire UI
buildUI(JSON.parse(results[1]), JSON.parse(results[2]));
// Populate the UI and add event listeners
populateSettings();
document.getElementById('preset-number').addEventListener('change', populateSettings);
gameSettings.addEventListener('change', handleOptionChange);
gameSettings.addEventListener('keyup', handleOptionChange);
document.getElementById('export-button').addEventListener('click', exportSettings);
document.getElementById('reset-to-default').addEventListener('click', resetToDefaults);
adjustHeaderWidth();
if (localStorage.getItem('wsVersion') !== wsVersion) {
const userWarning = document.getElementById('user-warning');
const messageSpan = document.createElement('span');
messageSpan.innerHTML = "A new version of the weighted settings file is available. Click here to update!" +
"<br />Be aware this will also reset your presets, so you should export them now if you want to save them.";
userWarning.appendChild(messageSpan);
userWarning.style.display = 'block';
userWarning.addEventListener('click', resetToDefaults);
}
}).catch((error) => {
console.error(error);
gameSettings.innerHTML = `
<h2>Something went wrong while loading your game settings page.</h2>
<h2>${error}</h2>
<h2><a href="${window.location.origin}">Click here to return to safety!</a></h2>
`
});
document.getElementById('generate-game').addEventListener('click', () => generateGame());
document.getElementById('generate-race').addEventListener('click', () => generateGame(true));
});
const fetchWeightedSettingsYaml = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject("Unable to fetch source yaml file.");
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/static/weightedSettings.yaml` ,true);
ajax.send();
});
const fetchWeightedSettingsJson = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject('Unable to fetch JSON schema file');
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/static/weightedSettings.json`, true);
ajax.send();
});
const fetchSpriteData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject('Unable to fetch sprite data.');
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/static/spriteData.json`, true);
ajax.send();
});
const handleOptionChange = (event) => {
if(!event.target.matches('.setting')) { return; }
const presetNumber = document.getElementById('preset-number').value;
const settings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`))
const settingString = event.target.getAttribute('data-setting');
document.getElementById(settingString).innerText = event.target.value;
if(getSettingValue(settings, settingString) !== false){
const keys = settingString.split('.');
switch (keys.length) {
case 1:
settings[keys[0]] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value, 10);
break;
case 2:
settings[keys[0]][keys[1]] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value, 10);
break;
case 3:
settings[keys[0]][keys[1]][keys[2]] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value, 10);
break;
default:
console.warn(`Unknown setting string received: ${settingString}`)
return;
}
// Save the updated settings object bask to localStorage
localStorage.setItem(`weightedSettings${presetNumber}`, JSON.stringify(settings));
}else{
console.warn(`Unknown setting string received: ${settingString}`)
}
};
const populateSettings = () => {
buildSpriteOptions();
const presetNumber = document.getElementById('preset-number').value;
const settings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`))
const settingsInputs = Array.from(document.querySelectorAll('.setting'));
settingsInputs.forEach((input) => {
const settingString = input.getAttribute('data-setting');
const settingValue = getSettingValue(settings, settingString);
if(settingValue !== false){
input.value = settingValue;
document.getElementById(settingString).innerText = settingValue;
}
});
};
/**
* Returns the value of the settings object, or false if the settings object does not exist
* @param settings
* @param keyString
* @returns {string} | bool
*/
const getSettingValue = (settings, keyString) => {
const keys = keyString.split('.');
let currentVal = settings;
keys.forEach((key) => {
if(typeof(key) === 'string' && currentVal.hasOwnProperty(key)){
currentVal = currentVal[key];
}else{
currentVal = false;
}
});
return currentVal;
};
const exportSettings = () => {
const presetNumber = document.getElementById('preset-number').value;
const settings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`));
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${settings.description}.yaml`, yamlText);
};
const resetToDefaults = () => {
[1, 2, 3].forEach((presetNumber) => localStorage.removeItem(`weightedSettings${presetNumber}`));
location.reload();
};
/** Create an anchor and trigger a download of a text file. */
const download = (filename, text) => {
const downloadLink = document.createElement('a');
downloadLink.setAttribute('href','data:text/yaml;charset=utf-8,'+ encodeURIComponent(text))
downloadLink.setAttribute('download', filename);
downloadLink.style.display = 'none';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
const buildUI = (settings, spriteData) => {
const settingsWrapper = document.getElementById('settings-wrapper');
const settingTypes = {
gameOptions: 'Game Options',
romOptions: 'ROM Options',
}
Object.keys(settingTypes).forEach((settingTypeKey) => {
const sectionHeader = document.createElement('h2');
sectionHeader.innerText = settingTypes[settingTypeKey];
settingsWrapper.appendChild(sectionHeader);
Object.values(settings[settingTypeKey]).forEach((setting) => {
if (typeof(setting.inputType) === 'undefined' || !setting.inputType){
console.error(setting);
throw new Error('Setting with no inputType specified.');
}
switch(setting.inputType){
case 'text':
// Currently, all text input is handled manually because there is very little of it
return;
case 'range':
buildRangeSettings(settingsWrapper, setting);
return;
default:
console.error(setting);
throw new Error('Unhandled inputType specified.');
}
});
});
// Build sprite options
const spriteOptionsHeader = document.createElement('h2');
spriteOptionsHeader.innerText = 'Sprite Options';
settingsWrapper.appendChild(spriteOptionsHeader);
const spriteOptionsWrapper = document.createElement('div');
spriteOptionsWrapper.setAttribute('id', 'sprite-options-wrapper');
spriteOptionsWrapper.className = 'setting-wrapper';
settingsWrapper.appendChild(spriteOptionsWrapper);
// Append sprite picker
settingsWrapper.appendChild(buildSpritePicker(spriteData));
};
const buildSpriteOptions = () => {
const spriteOptionsWrapper = document.getElementById('sprite-options-wrapper');
// Clear the contents of the wrapper div
while(spriteOptionsWrapper.firstChild){
spriteOptionsWrapper.removeChild(spriteOptionsWrapper.lastChild);
}
const spriteOptionsTitle = document.createElement('span');
spriteOptionsTitle.className = 'title-span';
spriteOptionsTitle.innerText = 'Alternate Sprites';
spriteOptionsWrapper.appendChild(spriteOptionsTitle);
const spriteOptionsDescription = document.createElement('span');
spriteOptionsDescription.className = 'description-span';
spriteOptionsDescription.innerHTML = 'Choose an alternate sprite to play the game with. Additional randomization ' +
'options are documented in the ' +
'<a href="https://github.com/Berserker66/MultiWorld-Utilities/blob/main/playerSettings.yaml#L374">settings file</a>.';
spriteOptionsWrapper.appendChild(spriteOptionsDescription);
const spriteOptionsTable = document.createElement('table');
spriteOptionsTable.setAttribute('id', 'sprite-options-table');
spriteOptionsTable.className = 'option-set';
const tbody = document.createElement('tbody');
tbody.setAttribute('id', 'sprites-tbody');
const currentPreset = document.getElementById('preset-number').value;
const playerSettings = JSON.parse(localStorage.getItem(`weightedSettings${currentPreset}`));
// Manually add a row for random sprites
addSpriteRow(tbody, playerSettings, 'random');
// Add a row for each sprite currently present in the player's settings
Object.keys(playerSettings.rom.sprite).forEach((spriteName) => {
if(['random'].indexOf(spriteName) > -1) return;
addSpriteRow(tbody, playerSettings, spriteName)
});
spriteOptionsTable.appendChild(tbody);
spriteOptionsWrapper.appendChild(spriteOptionsTable);
};
const buildRangeSettings = (parentElement, settings) => {
// Ensure we are operating on a range-specific setting
if(typeof(settings.inputType) === 'undefined' || settings.inputType !== 'range'){
throw new Error('Invalid input type provided to buildRangeSettings func.');
}
const settingWrapper = document.createElement('div');
settingWrapper.className = 'setting-wrapper';
if(typeof(settings.friendlyName) !== 'undefined' && settings.friendlyName){
const sectionTitle = document.createElement('span');
sectionTitle.className = 'title-span';
sectionTitle.innerText = settings.friendlyName;
settingWrapper.appendChild(sectionTitle);
}
if(settings.description){
const description = document.createElement('span');
description.className = 'description-span';
description.innerText = settings.description;
settingWrapper.appendChild(description);
}
// Create table
const optionSetTable = document.createElement('table');
optionSetTable.className = 'option-set';
// Create table body
const tbody = document.createElement('tbody');
Object.keys(settings.subOptions).forEach((setting) => {
// Overwrite setting key name with real object
setting = settings.subOptions[setting];
const settingId = (Math.random() * 1000000).toString();
// Create rows for each option
const optionRow = document.createElement('tr');
// Option name td
const optionName = document.createElement('td');
optionName.className = 'option-name';
const optionLabel = document.createElement('label');
optionLabel.setAttribute('for', settingId);
optionLabel.setAttribute('data-tooltip', setting.description);
optionLabel.innerText = setting.friendlyName;
optionName.appendChild(optionLabel);
optionRow.appendChild(optionName);
// Option value td
const optionValue = document.createElement('td');
optionValue.className = 'option-value';
const input = document.createElement('input');
input.className = 'setting';
input.setAttribute('id', settingId);
input.setAttribute('type', 'range');
input.setAttribute('min', '0');
input.setAttribute('max', '100');
input.setAttribute('data-setting', setting.keyString);
input.value = setting.defaultValue;
optionValue.appendChild(input);
const valueDisplay = document.createElement('span');
valueDisplay.setAttribute('id', setting.keyString);
valueDisplay.innerText = setting.defaultValue;
optionValue.appendChild(valueDisplay);
optionRow.appendChild(optionValue);
tbody.appendChild(optionRow);
});
optionSetTable.appendChild(tbody);
settingWrapper.appendChild(optionSetTable);
parentElement.appendChild(settingWrapper);
};
const addSpriteRow = (tbody, playerSettings, spriteName) => {
const rowId = (Math.random() * 1000000).toString();
const optionId = (Math.random() * 1000000).toString();
const tr = document.createElement('tr');
tr.setAttribute('id', rowId);
// Option Name
const optionName = document.createElement('td');
optionName.className = 'option-name';
const label = document.createElement('label');
label.htmlFor = optionId;
label.innerText = spriteName;
optionName.appendChild(label);
if(['random', 'random_sprite_on_event'].indexOf(spriteName) === -1) {
const deleteButton = document.createElement('span');
deleteButton.setAttribute('data-sprite', spriteName);
deleteButton.setAttribute('data-row-id', rowId);
deleteButton.innerText = ' (❌)';
deleteButton.className = 'delete-button';
optionName.appendChild(deleteButton);
deleteButton.addEventListener('click', removeSpriteOption);
}
tr.appendChild(optionName);
// Option Value
const optionValue = document.createElement('td');
optionValue.className = 'option-value';
const input = document.createElement('input');
input.className = 'setting';
input.setAttribute('id', optionId);
input.setAttribute('type', 'range');
input.setAttribute('min', '0');
input.setAttribute('max', '100');
input.setAttribute('data-setting', `rom.sprite.${spriteName}`);
input.value = "50";
optionValue.appendChild(input);
// Value display
const valueDisplay = document.createElement('span');
valueDisplay.setAttribute('id', `rom.sprite.${spriteName}`);
valueDisplay.innerText = playerSettings.rom.sprite.hasOwnProperty(spriteName) ?
playerSettings.rom.sprite[spriteName] : '0';
optionValue.appendChild(valueDisplay);
tr.appendChild(optionValue);
tbody.appendChild(tr);
};
const addSpriteOption = (event) => {
const presetNumber = document.getElementById('preset-number').value;
const playerSettings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`));
const spriteName = event.target.getAttribute('data-sprite');
if (Object.keys(playerSettings.rom.sprite).indexOf(spriteName) !== -1) {
// Do not add the same sprite twice
return;
}
// Add option to playerSettings object
playerSettings.rom.sprite[event.target.getAttribute('data-sprite')] = 50;
localStorage.setItem(`weightedSettings${presetNumber}`, JSON.stringify(playerSettings));
// Add <tr> to #sprite-options-table
const tbody = document.getElementById('sprites-tbody');
addSpriteRow(tbody, playerSettings, spriteName);
};
const removeSpriteOption = (event) => {
const presetNumber = document.getElementById('preset-number').value;
const playerSettings = JSON.parse(localStorage.getItem(`weightedSettings${presetNumber}`));
const spriteName = event.target.getAttribute('data-sprite');
// Remove option from playerSettings object
delete playerSettings.rom.sprite[spriteName];
localStorage.setItem(`weightedSettings${presetNumber}`, JSON.stringify(playerSettings));
// Remove <tr> from #sprite-options-table
const tr = document.getElementById(event.target.getAttribute('data-row-id'));
tr.parentNode.removeChild(tr);
};
const buildSpritePicker = (spriteData) => {
const spritePicker = document.createElement('div');
spritePicker.setAttribute('id', 'sprite-picker');
// Build description
const description = document.createElement('span');
description.innerText = 'To add a sprite to your playable list, click the one you want below.';
spritePicker.appendChild(description);
const sprites = document.createElement('div');
sprites.setAttribute('id', 'sprite-picker-sprites');
spriteData.sprites.forEach((sprite) => {
const spriteImg = document.createElement('img');
let spriteGifFile = sprite.file.split('.');
spriteGifFile.pop();
spriteGifFile = spriteGifFile.join('.') + '.gif';
spriteImg.setAttribute('src', `static/static/sprites/${spriteGifFile}`);
spriteImg.setAttribute('data-sprite', sprite.file.split('.')[0]);
spriteImg.setAttribute('alt', sprite.name);
// Wrap the image in a span to allow for tooltip presence
const imgWrapper = document.createElement('span');
imgWrapper.className = 'sprite-img-wrapper';
imgWrapper.setAttribute('data-tooltip', `${sprite.name}${sprite.author ? `, by ${sprite.author}` : ''}`);
imgWrapper.appendChild(spriteImg);
imgWrapper.setAttribute('data-sprite', sprite.name);
sprites.appendChild(imgWrapper);
imgWrapper.addEventListener('click', addSpriteOption);
});
spritePicker.appendChild(sprites);
return spritePicker;
};
const generateGame = (raceMode = false) => {
const presetNumber = document.getElementById('preset-number').value;
axios.post('/api/generate', {
weights: { player: localStorage.getItem(`weightedSettings${presetNumber}`) },
presetData: { player: localStorage.getItem(`weightedSettings${presetNumber}`) },
playerCount: 1,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = 'Something went wrong and your game could not be generated.';
userMessage.classList.add('visible');
window.scrollTo(0, 0);
console.error(error);
});
};

View File

@@ -1,705 +0,0 @@
{
"readOnly": {
"description": "Generated by MultiWorld website",
"triforce_pieces_mode": "available",
"triforce_pieces_available": 30,
"triforce_pieces_required": 20,
"shuffle_prizes": "none",
"timer": "none",
"glitch_boots": "on",
"key_drop_shuffle": "off",
"experimental": "off",
"debug": "off"
},
"generalOptions": {
"name": "PlayerName"
},
"gameOptions": {
"goals": {
"type": "select",
"friendlyName": "Goal",
"description": "Choose the condition for winning the game",
"defaultValue": "ganon",
"options": [
{
"name": "Kill Ganon",
"value": "ganon"
},
{
"name": "Fast Ganon (Pyramid Always Open)",
"value": "crystals"
},
{
"name": "All Bosses",
"value": "bosses"
},
{
"name": "Master Sword Pedestal",
"value": "pedestal"
},
{
"name": "Master Sword Pedestal + Ganon",
"value": "ganon_pedestal"
},
{
"name": "Triforce Hunt",
"value": "triforce_hunt"
},
{
"name": "Triforce Hunt + Ganon",
"value": "ganon_triforce_hunt"
},
{
"name": "Ice Rod Hunt",
"value": "ice_rod_hunt"
}
]
},
"mode": {
"type": "select",
"friendlyName": "World State",
"description": "Choose the state of the game world",
"defaultValue": "standard",
"options": [
{
"name": "Standard",
"value": "standard"
},
{
"name": "Open",
"value": "open"
},
{
"name": "Inverted",
"value": "inverted"
}
]
},
"accessibility": {
"type": "select",
"friendlyName": "Accessibility",
"description": "Choose how much of the world will be available",
"defaultValue": "locations",
"options": [
{
"name": "Locations Guaranteed",
"value": "locations"
},
{
"name": "Items Guaranteed",
"value": "items"
},
{
"name": "Beatable Only",
"value": "none"
}
]
},
"progressive": {
"type": "select",
"friendlyName": "Progressive Items",
"description": "Turn progressive items on or off, or randomize them",
"defaultValue": "on",
"options": [
{
"name": "All Progressive",
"value": "on"
},
{
"name": "None Progressive",
"value": "off"
},
{
"name": "Randomize Each",
"value": "random"
}
]
},
"tower_open": {
"type": "select",
"friendlyName": "Ganon's Tower Access",
"description": "Choose how many crystals are required to open Ganon's Tower",
"defaultValue": 7,
"options": [
{
"name": "7 Crystals",
"value": 7
},
{
"name": "6 Crystals",
"value": 6
},
{
"name": "5 Crystals",
"value": 5
},
{
"name": "4 Crystals",
"value": 4
},
{
"name": "3 Crystals",
"value": 3
},
{
"name": "2 Crystals",
"value": 2
},
{
"name": "1 Crystals",
"value": 1
},
{
"name": "0 Crystals",
"value": 0
},
{
"name": "Random",
"value": "random"
}
]
},
"ganon_open": {
"type": "select",
"friendlyName": "Ganon Vulnerable",
"description": "Choose how many crystals are required to kill Ganon",
"defaultValue": 7,
"options": [
{
"name": "7 Crystals",
"value": 7
},
{
"name": "6 Crystals",
"value": 6
},
{
"name": "5 Crystals",
"value": 5
},
{
"name": "4 Crystals",
"value": 4
},
{
"name": "3 Crystals",
"value": 3
},
{
"name": "2 Crystals",
"value": 2
},
{
"name": "1 Crystals",
"value": 1
},
{
"name": "0 Crystals",
"value": 0
},
{
"name": "Random",
"value": "random"
}
]
},
"retro": {
"type": "select",
"friendlyName": "Retro Mode",
"description": "Choose if you want to play in retro mode",
"defaultValue": "off",
"options": [
{
"name": "Disabled",
"value": "off"
},
{
"name": "Enabled",
"value": "on"
}
]
},
"hints": {
"type": "select",
"friendlyName": "Hints",
"description": "Choose to enable or disable tile hints",
"defaultValue": "on",
"options": [
{
"name": "Enabled",
"value": "on"
},
{
"name": "Disabled",
"value": "off"
}
]
},
"weapons": {
"type": "select",
"friendlyName": "Sword Locations",
"description": "Choose where you will find your swords",
"defaultValue": "assured",
"options": [
{
"name": "Assured",
"value": "assured"
},
{
"name": "Vanilla",
"value": "vanilla"
},
{
"name": "Swordless",
"value": "swordless"
},
{
"name": "Randomized",
"value": "randomized"
}
]
},
"glitches_required":{
"type": "select",
"friendlyName": "Glitches Required",
"description": "Choose which glitches will be considered in-logic",
"defaultValue": "none",
"options": [
{
"name": "None",
"value": "none"
},
{
"name": "Minor Glitches",
"value": "minor_glitches"
},
{
"name": "Overworld Glitches",
"value": "overworld_glitches"
},
{
"name": "No Logic",
"value": "no_logic"
}
]
},
"dark_room_logic": {
"type": "select",
"friendlyName": "Dark Room Logic",
"description": "Choose your logical access to dark rooms",
"defaultValue": "lamp",
"options": [
{
"name": "Lamp Required",
"value": "lamp"
},
{
"name": "Torches Lightable",
"value": "torches"
},
{
"name": "Always In-Logic",
"value": "none"
}
]
},
"dungeon_items": {
"type": "select",
"friendlyName": "Dungeon Item Shuffle",
"description": "Choose which dungeon items you want shuffled",
"defaultValue": "none",
"options": [
{
"name": "None",
"value": "none"
},
{
"name": "Map & Compass",
"value": "mc"
},
{
"name": "Small Keys Only",
"value": "s"
},
{
"name": "Big Keys Only",
"value": "b"
},
{
"name": "Small and Big Keys",
"value": "sb"
},
{
"name": "Full Keysanity",
"value": "mscb"
},
{
"name": "Universal Small Keys",
"value": "u"
}
]
},
"entrance_shuffle": {
"type": "select",
"friendlyName": "Entrance Shuffle",
"description": "Shuffles the game map. Not recommended for beginners",
"defaultValue": "none",
"options": [
{
"name": "None",
"value": "none"
},
{
"name": "Only Dungeons, Simple",
"value": "dungeonssimple"
},
{
"name": "Only Dungeons, Full",
"value": "dungeonsfull"
},
{
"name": "Simple",
"value": "simple"
},
{
"name": "Restricted",
"value": "restricted"
},
{
"name": "Full",
"value": "full"
},
{
"name": "Crossed",
"value": "crossed"
},
{
"name": "Insanity",
"value": "insanity"
}
]
},
"item_pool": {
"type": "select",
"friendlyName": "Item Pool",
"description": "Changes the available upgrade items (1/2 Magic, hearts, sword upgrades, etc)",
"defaultValue": "normal",
"options": [
{
"name": "Easy",
"value": "easy"
},
{
"name": "Normal",
"value": "normal"
},
{
"name": "Hard",
"value": "hard"
},
{
"name": "Expert",
"value": "expert"
}
]
},
"item_functionality": {
"type": "select",
"friendlyName": "Item Functionality",
"description": "Changes the abilities of your items",
"defaultValue": "normal",
"options": [
{
"name": "Easy",
"value": "easy"
},
{
"name": "Normal",
"value": "normal"
},
{
"name": "Hard",
"value": "hard"
},
{
"name": "Expert",
"value": "expert"
}
]
},
"enemy_shuffle": {
"type": "select",
"friendlyName": "Enemy Shuffle",
"description": "Randomize the enemies which appear throughout the game",
"defaultValue": "off",
"options": [
{
"name": "Disabled",
"value": "off"
},
{
"name": "Enabled",
"value": "on"
}
]
},
"boss_shuffle": {
"type": "select",
"friendlyName": "Boss Shuffle",
"description": "Shuffle the bosses within dungeons",
"defaultValue": "none",
"options": [
{
"name": "Disabled",
"value": "none"
},
{
"name": "Simple",
"value": "simple"
},
{
"name": "Full",
"value": "full"
},
{
"name": "Singularity",
"value": "singularity"
},
{
"name": "Random",
"value": "random"
}
]
},
"shop_shuffle": {
"type": "select",
"friendlyName": "Shop Shuffle",
"description": "Shuffles the content and prices of shops throughout Hyrule",
"defaultValue": "none",
"options": [
{
"name": "None",
"value": "none"
},
{
"name": "Inventory",
"value": "f"
},
{
"name": "Prices",
"value": "p"
},
{
"name": "Capacity Upgrades",
"value": "u"
},
{
"name": "Inventory and Prices",
"value": "fp"
},
{
"name": "Inventory, Prices, and Upgrades",
"value": "fpu"
}
]
}
},
"romOptions": {
"disablemusic": {
"type": "select",
"friendlyName": "Game Music",
"description": "Choose to enable or disable in-game music",
"defaultValue": "off",
"options": [
{
"name": "Enabled",
"value": "off"
},
{
"name": "Disabled",
"value": "on"
}
]
},
"quickswap": {
"type": "select",
"friendlyName": "Item Quick-Swap",
"description": "Enable or disable quick-swap using the L+R buttons",
"defaultValue": "on",
"options": [
{
"name": "Enabled",
"value": "on"
},
{
"name": "Disabled",
"value": "off"
}
]
},
"menuspeed": {
"type": "select",
"friendlyName": "Menu Speed",
"description": "Changes the animation speed of the in-game menu",
"defaultValue": "normal",
"options": [
{
"name": "Normal",
"value": "normal"
},
{
"name": "Instant",
"value": "instant"
},
{
"name": "Double",
"value": "double"
},
{
"name": "Triple",
"value": "triple"
},
{
"name": "Quadruple",
"value": "quadruple"
},
{
"name": "Half-Speed",
"value": "half"
}
]
},
"heartbeep": {
"type": "select",
"friendlyName": "Heart-Beep Speed",
"description": "Change the frequency of the heart beep alert when you are at low health",
"defaultValue": "normal",
"options": [
{
"name": "Double Speed",
"value": "double"
},
{
"name": "Normal",
"value": "normal"
},
{
"name": "Half-Speed",
"value": "half"
},
{
"name": "Quarter-Speed",
"value": "quarter"
},
{
"name": "Disabled",
"value": "off"
}
]
},
"heartcolor": {
"type": "select",
"friendlyName": "Heart Color",
"description": "Change the color of your hearts in-game",
"defaultValue": "red",
"options": [
{
"name": "Red",
"value": "red"
},
{
"name": "Blue",
"value": "blue"
},
{
"name": "Green",
"value": "green"
},
{
"name": "Yellow",
"value": "yellow"
},
{
"name": "Random",
"value": "random"
}
]
},
"ow_palettes": {
"type": "select",
"friendlyName": "Overworld Palette",
"description": "Change the colors of the overworld",
"defaultValue": "default",
"options": [
{
"name": "Vanilla",
"value": "default"
},
{
"name": "Randomized",
"value": "random"
}
]
},
"uw_palettes": {
"type": "select",
"friendlyName": "Underworld Palette",
"description": "Change the colors of the underworld",
"defaultValue": "default",
"options": [
{
"name": "Vanilla",
"value": "default"
},
{
"name": "Randomized",
"value": "random"
}
]
},
"hud_palettes": {
"type": "select",
"friendlyName": "HUD Palette",
"description": "Change the colors of the user-interface",
"defaultValue": "default",
"options": [
{
"name": "Vanilla",
"value": "default"
},
{
"name": "Randomized",
"value": "random"
}
]
},
"sword_palettes": {
"type": "select",
"friendlyName": "Sword Palette",
"description": "Change the colors of the swords, within reason",
"defaultValue": "default",
"options": [
{
"name": "Vanilla",
"value": "default"
},
{
"name": "Randomized",
"value": "random"
}
]
},
"sprite": {
"type": "select",
"friendlyName": "Sprite",
"description": "Choose a sprite to play as!",
"defaultValue": "link",
"options": [
{
"name": "Random",
"value": "random"
}
]
}
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 B

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