Compare commits

...

221 Commits
0.1.2 ... 0.1.5

Author SHA1 Message Date
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
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
109eb5b9dc start of split 2021-05-13 01:34:59 +02:00
485 changed files with 8290 additions and 8575 deletions

3
.gitignore vendored
View File

@@ -38,6 +38,7 @@ success.txt
output/ output/
Output Logs/ Output Logs/
/factorio/ /factorio/
/WebHostLib/static/generated
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
@@ -144,3 +145,5 @@ dmypy.json
# Cython debug symbols # Cython debug symbols
cython_debug/ cython_debug/
Archipelago.zip

View File

@@ -6,7 +6,7 @@ import logging
import json import json
import functools import functools
from collections import OrderedDict, Counter, deque from collections import OrderedDict, Counter, deque
from typing import * from typing import List, Dict, Optional, Set, Iterable, Union, Any
import secrets import secrets
import random import random
@@ -14,15 +14,17 @@ import random
class MultiWorld(): class MultiWorld():
debug_types = False debug_types = False
player_names: Dict[int, List[str]] player_names: Dict[int, List[str]]
_region_cache: dict _region_cache: Dict[int, Dict[str, Region]]
difficulty_requirements: dict difficulty_requirements: dict
required_medallions: dict required_medallions: dict
dark_room_logic: Dict[int, str] dark_room_logic: Dict[int, str]
restrict_dungeon_item_on_boss: Dict[int, bool] restrict_dungeon_item_on_boss: Dict[int, bool]
plando_texts: List[Dict[str, str]] plando_texts: List[Dict[str, str]]
plando_items: List[PlandoItem] plando_items: List
plando_connections: List[PlandoConnection] plando_connections: List
er_seeds: Dict[int, str] er_seeds: Dict[int, str]
worlds: Dict[int, Any]
is_race: bool = False
class AttributeProxy(): class AttributeProxy():
def __init__(self, rule): def __init__(self, rule):
@@ -32,8 +34,6 @@ class MultiWorld():
return self.rule(player) return self.rule(player)
def __init__(self, players: int): def __init__(self, players: int):
# TODO: move per-player settings into new classes per game-type instead of clumping it all together here
self.random = random.Random() # world-local random state is saved for multiple generations running concurrently self.random = random.Random() # world-local random state is saved for multiple generations running concurrently
self.players = players self.players = players
self.teams = 1 self.teams = 1
@@ -69,7 +69,6 @@ class MultiWorld():
self.fix_palaceofdarkness_exit = self.AttributeProxy(lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple']) self.fix_palaceofdarkness_exit = self.AttributeProxy(lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
self.fix_trock_exit = self.AttributeProxy(lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple']) self.fix_trock_exit = self.AttributeProxy(lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
self.NOTCURSED = self.AttributeProxy(lambda player: not self.CURSED[player]) self.NOTCURSED = self.AttributeProxy(lambda player: not self.CURSED[player])
self.remote_items = self.AttributeProxy(lambda player: self.game[player] != "A Link to the Past")
for player in range(1, players + 1): for player in range(1, players + 1):
def set_player_attr(attr, val): def set_player_attr(attr, val):
@@ -113,8 +112,6 @@ class MultiWorld():
set_player_attr('bush_shuffle', False) set_player_attr('bush_shuffle', False)
set_player_attr('beemizer', 0) set_player_attr('beemizer', 0)
set_player_attr('escape_assist', []) set_player_attr('escape_assist', [])
set_player_attr('crystals_needed_for_ganon', 7)
set_player_attr('crystals_needed_for_gt', 7)
set_player_attr('open_pyramid', False) set_player_attr('open_pyramid', False)
set_player_attr('treasure_hunt_icon', 'Triforce Piece') set_player_attr('treasure_hunt_icon', 'Triforce Piece')
set_player_attr('treasure_hunt_count', 0) set_player_attr('treasure_hunt_count', 0)
@@ -131,7 +128,6 @@ class MultiWorld():
set_player_attr('triforce_pieces_available', 30) set_player_attr('triforce_pieces_available', 30)
set_player_attr('triforce_pieces_required', 20) set_player_attr('triforce_pieces_required', 20)
set_player_attr('shop_shuffle', 'off') set_player_attr('shop_shuffle', 'off')
set_player_attr('shop_shuffle_slots', 0)
set_player_attr('shuffle_prizes', "g") set_player_attr('shuffle_prizes', "g")
set_player_attr('sprite_pool', []) set_player_attr('sprite_pool', [])
set_player_attr('dark_room_logic', "lamp") set_player_attr('dark_room_logic', "lamp")
@@ -141,39 +137,29 @@ class MultiWorld():
set_player_attr('plando_connections', []) set_player_attr('plando_connections', [])
set_player_attr('game', "A Link to the Past") set_player_attr('game', "A Link to the Past")
set_player_attr('completion_condition', lambda state: True) set_player_attr('completion_condition', lambda state: True)
import Options
for hk_option in Options.hollow_knight_options:
set_player_attr(hk_option, False)
self.custom_data = {} self.custom_data = {}
for player in range(1, players+1): self.worlds = {}
def set_options(self, args):
from worlds import AutoWorld
for player in self.player_ids:
self.custom_data[player] = {} self.custom_data[player] = {}
# self.worlds = [] world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
# for i in range(players): for option in world_type.options:
# self.worlds.append(worlds.alttp.ALTTPWorld({}, i)) setattr(self, option, getattr(args, option, {}))
self.worlds[player] = world_type(self, player)
def secure(self): def secure(self):
self.random = secrets.SystemRandom() self.random = secrets.SystemRandom()
self.is_race = True
@property @functools.cached_property
def player_ids(self): def player_ids(self):
yield from range(1, self.players + 1) return tuple(range(1, self.players + 1))
@property
def alttp_player_ids(self):
yield from (player for player in range(1, self.players + 1) if self.game[player] == "A Link to the Past")
@property
def hk_player_ids(self):
yield from (player for player in range(1, self.players + 1) if self.game[player] == "Hollow Knight")
@property
def factorio_player_ids(self):
yield from (player for player in range(1, self.players + 1) if self.game[player] == "Factorio")
@property
def minecraft_player_ids(self):
yield from (player for player in range(1, self.players + 1) if self.game[player] == "Minecraft")
@functools.lru_cache()
def get_game_players(self, game_name: str):
return tuple(player for player in self.player_ids if self.game[player] == game_name)
def get_name_string_for_object(self, obj) -> str: def get_name_string_for_object(self, obj) -> str:
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_names(obj.player)})' return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_names(obj.player)})'
@@ -238,53 +224,12 @@ class MultiWorld():
def get_all_state(self, keys=False) -> CollectionState: def get_all_state(self, keys=False) -> CollectionState:
ret = CollectionState(self) ret = CollectionState(self)
def soft_collect(item):
if item.game == "A Link to the Past" and item.name.startswith('Progressive '):
# ALttP items
if 'Sword' in item.name:
if ret.has('Golden Sword', item.player):
pass
elif ret.has('Tempered Sword', item.player) and self.difficulty_requirements[
item.player].progressive_sword_limit >= 4:
ret.prog_items['Golden Sword', item.player] += 1
elif ret.has('Master Sword', item.player) and self.difficulty_requirements[
item.player].progressive_sword_limit >= 3:
ret.prog_items['Tempered Sword', item.player] += 1
elif ret.has('Fighter Sword', item.player) and self.difficulty_requirements[item.player].progressive_sword_limit >= 2:
ret.prog_items['Master Sword', item.player] += 1
elif self.difficulty_requirements[item.player].progressive_sword_limit >= 1:
ret.prog_items['Fighter Sword', item.player] += 1
elif 'Glove' in item.name:
if ret.has('Titans Mitts', item.player):
pass
elif ret.has('Power Glove', item.player):
ret.prog_items['Titans Mitts', item.player] += 1
else:
ret.prog_items['Power Glove', item.player] += 1
elif 'Shield' in item.name:
if ret.has('Mirror Shield', item.player):
pass
elif ret.has('Red Shield', item.player) and self.difficulty_requirements[item.player].progressive_shield_limit >= 3:
ret.prog_items['Mirror Shield', item.player] += 1
elif ret.has('Blue Shield', item.player) and self.difficulty_requirements[item.player].progressive_shield_limit >= 2:
ret.prog_items['Red Shield', item.player] += 1
elif self.difficulty_requirements[item.player].progressive_shield_limit >= 1:
ret.prog_items['Blue Shield', item.player] += 1
elif 'Bow' in item.name:
if ret.has('Silver', item.player):
pass
elif ret.has('Bow', item.player) and self.difficulty_requirements[item.player].progressive_bow_limit >= 2:
ret.prog_items['Silver Bow', item.player] += 1
elif self.difficulty_requirements[item.player].progressive_bow_limit >= 1:
ret.prog_items['Bow', item.player] += 1
elif item.advancement or item.smallkey or item.bigkey:
ret.prog_items[item.name, item.player] += 1
for item in self.itempool: for item in self.itempool:
soft_collect(item) self.worlds[item.player].collect(ret, item)
if keys: if keys:
for p in self.alttp_player_ids: for p in self.get_game_players("A Link to the Past"):
world = self.worlds[p]
from worlds.alttp.Items import ItemFactory from worlds.alttp.Items import ItemFactory
for item in ItemFactory( for item in ItemFactory(
['Small Key (Hyrule Castle)', 'Big Key (Eastern Palace)', 'Big Key (Desert Palace)', ['Small Key (Hyrule Castle)', 'Big Key (Eastern Palace)', 'Big Key (Desert Palace)',
@@ -299,7 +244,7 @@ class MultiWorld():
'Small Key (Misery Mire)'] * 3 + ['Small Key (Turtle Rock)'] * 4 + [ 'Small Key (Misery Mire)'] * 3 + ['Small Key (Turtle Rock)'] * 4 + [
'Small Key (Ganons Tower)'] * 4, 'Small Key (Ganons Tower)'] * 4,
p): p):
soft_collect(item) world.collect(ret, item)
ret.sweep_for_events() ret.sweep_for_events()
return ret return ret
@@ -314,6 +259,8 @@ class MultiWorld():
return next(location for location in self.get_locations() if return next(location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player) location.item and location.item.name == item and location.item.player == player)
def create_item(self, item_name: str, player: int) -> Item:
return self.worlds[player].create_item(item_name)
def push_precollected(self, item: Item): def push_precollected(self, item: Item):
item.world = self item.world = self
@@ -605,31 +552,25 @@ class CollectionState(object):
def has(self, item, player: int, count: int = 1): def has(self, item, player: int, count: int = 1):
return self.prog_items[item, player] >= count return self.prog_items[item, player] >= count
def has_essence(self, player: int, count: int): def has_all(self, items: Set[str], player:int):
return self.prog_items["Dream_Nail", player] return all(self.prog_items[item, player] for item in items)
# return self.prog_items["Essence", player] >= count
def has_grubs(self, player: int, count: int): def has_any(self, items: Set[str], player:int):
from worlds.hk import Items as HKItems return any(self.prog_items[item, player] for item in items)
found = 0
for item_name in HKItems.lookup_type_to_names["Grub"]: def has_group(self, item_name_group: str, player: int, count: int = 1):
found: int = 0
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player] found += self.prog_items[item_name, player]
if found >= count: if found >= count:
return True return True
return False return False
def has_flames(self, player: int, count: int): def count_group(self, item_name_group: str, player: int):
from worlds.hk import Items as HKItems found: int = 0
found = 0 for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
for item_name in HKItems.lookup_type_to_names["Flame"]:
found += self.prog_items[item_name, player] found += self.prog_items[item_name, player]
if found >= count: return found
return True
return False
def has_key(self, item, player, count: int = 1): def has_key(self, item, player, count: int = 1):
if self.world.logic[player] == 'nologic': if self.world.logic[player] == 'nologic':
@@ -663,25 +604,9 @@ class CollectionState(object):
def can_lift_rocks(self, player: int): def can_lift_rocks(self, player: int):
return self.has('Power Glove', player) or self.has('Titans Mitts', player) return self.has('Power Glove', player) or self.has('Titans Mitts', player)
def has_bottle(self, player: int) -> bool:
return self.has_bottles(1, player)
def bottle_count(self, player: int) -> int: def bottle_count(self, player: int) -> int:
found: int = 0 return min(self.world.difficulty_requirements[player].progressive_bottle_limit,
for bottlename in item_name_groups["Bottles"]: self.count_group("Bottles", player))
found += self.prog_items[bottlename, player]
return min(self.world.difficulty_requirements[player].progressive_bottle_limit, found)
def has_bottles(self, bottles: int, player: int) -> bool:
"""Version of bottle_count that allows fast abort"""
if bottles > self.world.difficulty_requirements[player].progressive_bottle_limit:
return False
found: int = 0
for bottlename in item_name_groups["Bottles"]:
found += self.prog_items[bottlename, player]
if found >= bottles:
return True
return False
def has_hearts(self, player: int, count: int) -> int: def has_hearts(self, player: int, count: int) -> int:
# Warning: This only considers items that are marked as advancement items # Warning: This only considers items that are marked as advancement items
@@ -730,7 +655,7 @@ class CollectionState(object):
def can_get_good_bee(self, player: int) -> bool: def can_get_good_bee(self, player: int) -> bool:
cave = self.world.get_region('Good Bee Cave', player) cave = self.world.get_region('Good Bee Cave', player)
return ( return (
self.has_bottle(player) and self.has_group("Bottles", player) and
self.has('Bug Catching Net', player) and self.has('Bug Catching Net', player) and
(self.has('Pegasus Boots', player) or (self.has_sword(player) and self.has('Quake', player))) and (self.has('Pegasus Boots', player) or (self.has_sword(player) and self.has('Quake', player))) and
cave.can_reach(self) and cave.can_reach(self) and
@@ -813,147 +738,16 @@ class CollectionState(object):
rules.append(self.has('Moon Pearl', player)) rules.append(self.has('Moon Pearl', player))
return all(rules) return all(rules)
# Minecraft logic functions def can_bomb_clip(self, region: Region, player: int) -> bool:
def has_iron_ingots(self, player: int): return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player)
return self.has('Progressive Tools', player) and self.has('Ingot Crafting', player)
def has_gold_ingots(self, player: int):
return self.has('Ingot Crafting', player) and (self.has('Progressive Tools', player, 2) or self.can_reach('The Nether', 'Region', player))
def has_diamond_pickaxe(self, player: int):
return self.has('Progressive Tools', player, 3) and self.has_iron_ingots(player)
def craft_crossbow(self, player: int):
return self.has('Archery', player) and self.has_iron_ingots(player)
def has_bottle_mc(self, player: int):
return self.has('Bottles', player) and self.has('Ingot Crafting', player)
def can_enchant(self, player: int):
return self.has('Enchanting', player) and self.has_diamond_pickaxe(player) # mine obsidian and lapis
def can_use_anvil(self, player: int):
return self.has('Enchanting', player) and self.has('Resource Blocks', player) and self.has_iron_ingots(player)
def fortress_loot(self, player: int): # saddles, blaze rods, wither skulls
return self.can_reach('Nether Fortress', 'Region', player) and self.basic_combat(player)
def can_brew_potions(self, player: int):
return self.fortress_loot(player) and self.has('Brewing', player) and self.has_bottle_mc(player)
def can_piglin_trade(self, player: int):
return self.has_gold_ingots(player) and (self.can_reach('The Nether', 'Region', player) or self.can_reach('Bastion Remnant', 'Region', player))
def enter_stronghold(self, player: int):
return self.fortress_loot(player) and self.has('Brewing', player) and self.has('3 Ender Pearls', player)
# Difficulty-dependent functions
def combat_difficulty(self, player: int):
return self.world.combat_difficulty[player].get_option_name()
def can_adventure(self, player: int):
if self.combat_difficulty(player) == 'easy':
return self.has('Progressive Weapons', player, 2) and self.has_iron_ingots(player)
elif self.combat_difficulty(player) == 'hard':
return True
return self.has('Progressive Weapons', player) and (self.has('Ingot Crafting', player) or self.has('Campfire', player))
def basic_combat(self, player: int):
if self.combat_difficulty(player) == 'easy':
return self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player) and \
self.has('Shield', player) and self.has_iron_ingots(player)
elif self.combat_difficulty(player) == 'hard':
return True
return self.has('Progressive Weapons', player) and (self.has('Progressive Armor', player) or self.has('Shield', player)) and self.has_iron_ingots(player)
def complete_raid(self, player: int):
reach_regions = self.can_reach('Village', 'Region', player) and self.can_reach('Pillager Outpost', 'Region', player)
if self.combat_difficulty(player) == 'easy':
return reach_regions and \
self.has('Progressive Weapons', player, 3) and self.has('Progressive Armor', player, 2) and \
self.has('Shield', player) and self.has('Archery', player) and \
self.has('Progressive Tools', player, 2) and self.has_iron_ingots(player)
elif self.combat_difficulty(player) == 'hard': # might be too hard?
return reach_regions and self.has('Progressive Weapons', player, 2) and self.has_iron_ingots(player) and \
(self.has('Progressive Armor', player) or self.has('Shield', player))
return reach_regions and self.has('Progressive Weapons', player, 2) and self.has_iron_ingots(player) and \
self.has('Progressive Armor', player) and self.has('Shield', player)
def can_kill_wither(self, player: int):
normal_kill = self.has("Progressive Weapons", player, 3) and self.has("Progressive Armor", player, 2) and self.can_brew_potions(player) and self.can_enchant(player)
if self.combat_difficulty(player) == 'easy':
return self.fortress_loot(player) and normal_kill and self.has('Archery', player)
elif self.combat_difficulty(player) == 'hard': # cheese kill using bedrock ceilings
return self.fortress_loot(player) and (normal_kill or self.can_reach('The Nether', 'Region', player) or self.can_reach('The End', 'Region', player))
return self.fortress_loot(player) and normal_kill
def can_kill_ender_dragon(self, player: int):
if self.combat_difficulty(player) == 'easy':
return self.has("Progressive Weapons", player, 3) and self.has("Progressive Armor", player, 2) and self.has('Archery', player) and \
self.can_brew_potions(player) and self.can_enchant(player)
if self.combat_difficulty(player) == 'hard':
return (self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player)) or \
(self.has('Progressive Weapons', player, 1) and self.has('Bed', player))
return self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player) and self.has('Archery', player)
def collect(self, item: Item, event: bool = False, location: Location = None) -> bool: def collect(self, item: Item, event: bool = False, location: Location = None) -> bool:
if location: if location:
self.locations_checked.add(location) self.locations_checked.add(location)
changed = False
# TODO: create a mapping for progressive items in each game and use that changed = self.world.worlds[item.player].collect(self, item)
if item.game == "A Link to the Past":
if item.name.startswith('Progressive '):
if 'Sword' in item.name:
if self.has('Golden Sword', item.player):
pass
elif self.has('Tempered Sword', item.player) and self.world.difficulty_requirements[
item.player].progressive_sword_limit >= 4:
self.prog_items['Golden Sword', item.player] += 1
changed = True
elif self.has('Master Sword', item.player) and self.world.difficulty_requirements[item.player].progressive_sword_limit >= 3:
self.prog_items['Tempered Sword', item.player] += 1
changed = True
elif self.has('Fighter Sword', item.player) and self.world.difficulty_requirements[item.player].progressive_sword_limit >= 2:
self.prog_items['Master Sword', item.player] += 1
changed = True
elif self.world.difficulty_requirements[item.player].progressive_sword_limit >= 1:
self.prog_items['Fighter Sword', item.player] += 1
changed = True
elif 'Glove' in item.name:
if self.has('Titans Mitts', item.player):
pass
elif self.has('Power Glove', item.player):
self.prog_items['Titans Mitts', item.player] += 1
changed = True
else:
self.prog_items['Power Glove', item.player] += 1
changed = True
elif 'Shield' in item.name:
if self.has('Mirror Shield', item.player):
pass
elif self.has('Red Shield', item.player) and self.world.difficulty_requirements[item.player].progressive_shield_limit >= 3:
self.prog_items['Mirror Shield', item.player] += 1
changed = True
elif self.has('Blue Shield', item.player) and self.world.difficulty_requirements[item.player].progressive_shield_limit >= 2:
self.prog_items['Red Shield', item.player] += 1
changed = True
elif self.world.difficulty_requirements[item.player].progressive_shield_limit >= 1:
self.prog_items['Blue Shield', item.player] += 1
changed = True
elif 'Bow' in item.name:
if self.has('Silver Bow', item.player):
pass
elif self.has('Bow', item.player):
self.prog_items['Silver Bow', item.player] += 1
changed = True
else:
self.prog_items['Bow', item.player] += 1
changed = True
if not changed and event:
if not changed and (event or item.advancement):
self.prog_items[item.name, item.player] += 1 self.prog_items[item.name, item.player] += 1
changed = True changed = True
@@ -1014,7 +808,8 @@ class CollectionState(object):
self.stale[item.player] = True self.stale[item.player] = True
@unique @unique
class RegionType(Enum): class RegionType(int, Enum):
Generic = 0
LightWorld = 1 LightWorld = 1
DarkWorld = 2 DarkWorld = 2
Cave = 3 # Also includes Houses Cave = 3 # Also includes Houses
@@ -1028,7 +823,7 @@ class RegionType(Enum):
class Region(object): class Region(object):
def __init__(self, name: str, type, hint, player: int): def __init__(self, name: str, type, hint, player: int, world: Optional[MultiWorld] = None):
self.name = name self.name = name
self.type = type self.type = type
self.entrances = [] self.entrances = []
@@ -1036,15 +831,14 @@ class Region(object):
self.locations = [] self.locations = []
self.dungeon = None self.dungeon = None
self.shop = None self.shop = None
self.world = None self.world = world
self.is_light_world = False # will be set after making connections. self.is_light_world = False # will be set after making connections.
self.is_dark_world = False self.is_dark_world = False
self.spot_type = 'Region' self.spot_type = 'Region'
self.hint_text = hint self.hint_text = hint
self.recursion_count = 0
self.player = player self.player = player
def can_reach(self, state): def can_reach(self, state: CollectionState):
if state.stale[self.player]: if state.stale[self.player]:
state.update_reachable_regions(self.player) state.update_reachable_regions(self.player)
return self in state.reachable_regions[self.player] return self in state.reachable_regions[self.player]
@@ -1073,6 +867,7 @@ class Region(object):
class Entrance(object): class Entrance(object):
spot_type = 'Entrance'
def __init__(self, player: int, name: str = '', parent=None): def __init__(self, player: int, name: str = '', parent=None):
self.name = name self.name = name
@@ -1080,9 +875,6 @@ class Entrance(object):
self.connected_region = None self.connected_region = None
self.target = None self.target = None
self.addresses = None self.addresses = None
self.spot_type = 'Entrance'
self.recursion_count = 0
self.vanilla = None
self.access_rule = lambda state: True self.access_rule = lambda state: True
self.player = player self.player = player
self.hide_path = False self.hide_path = False
@@ -1095,11 +887,10 @@ class Entrance(object):
return False return False
def connect(self, region, addresses=None, target=None, vanilla=None): def connect(self, region, addresses=None, target=None):
self.connected_region = region self.connected_region = region
self.target = target self.target = target
self.addresses = addresses self.addresses = addresses
self.vanilla = vanilla
region.entrances.append(self) region.entrances.append(self)
def __repr__(self): def __repr__(self):
@@ -1172,17 +963,16 @@ class Location():
spot_type = 'Location' spot_type = 'Location'
game: str = "Generic" game: str = "Generic"
crystal: bool = False crystal: bool = False
always_allow = staticmethod(lambda item, state: False)
access_rule = staticmethod(lambda state: True)
item_rule = staticmethod(lambda item: True)
def __init__(self, player: int, name: str = '', address:int = None, parent=None): def __init__(self, player: int, name: str = '', address:int = None, parent=None):
self.name = name self.name: str = name
self.address = address self.address: Optional[int] = address
self.parent_region: Region = parent self.parent_region: Region = parent
self.recursion_count = 0 self.player: int = player
self.player = player self.item: Optional[Item] = None
self.item = None
self.always_allow = lambda item, state: False
self.access_rule = lambda state: True
self.item_rule = lambda item: True
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool: def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return self.always_allow(state, item) or (self.parent_region.can_fill(item) and self.item_rule(item) and (not check_access or self.can_reach(state))) return self.always_allow(state, item) or (self.parent_region.can_fill(item) and self.item_rule(item) and (not check_access or self.can_reach(state)))
@@ -1214,20 +1004,28 @@ class Location():
def __lt__(self, other): def __lt__(self, other):
return (self.player, self.name) < (other.player, other.name) return (self.player, self.name) < (other.player, other.name)
@property
def native_item(self) -> bool:
"""Returns True if the item in this location matches game."""
return self.item and self.item.game == self.game
@property @property
def hint_text(self): def hint_text(self):
return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " ")) return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
class Item(): class Item():
location: Optional[Location] = None location: Optional[Location] = None
world: Optional[MultiWorld] = None world: Optional[MultiWorld] = None
game: str = "Generic" game: str = "Generic"
type: str = None type: str = None
pedestal_credit_text = "and the Unknown Item" never_exclude = False # change manually to ensure that a specific nonprogression item never goes on an excluded location
sickkid_credit_text = None pedestal_credit_text: str = "and the Unknown Item"
magicshop_credit_text = None sickkid_credit_text: Optional[str] = None
zora_credit_text = None magicshop_credit_text: Optional[str] = None
fluteboy_credit_text = None zora_credit_text: Optional[str] = None
fluteboy_credit_text: Optional[str] = None
code: Optional[str] = None # an item with ID None is called an Event, and does not get written to multidata
def __init__(self, name: str, advancement: bool, code: Optional[int], player: int): def __init__(self, name: str, advancement: bool, code: Optional[int], player: int):
self.name = name self.name = name
@@ -1237,11 +1035,11 @@ class Item():
@property @property
def hint_text(self): def hint_text(self):
return getattr(self, "_hint_text", self.name) return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
@property @property
def pedestal_hint_text(self): def pedestal_hint_text(self):
return getattr(self, "_pedestal_hint_text", self.name) return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " "))
def __eq__(self, other): def __eq__(self, other):
return self.name == other.name and self.player == other.player return self.name == other.name and self.player == other.player
@@ -1331,7 +1129,7 @@ class Spoiler(object):
def parse_data(self): def parse_data(self):
self.medallions = OrderedDict() self.medallions = OrderedDict()
for player in self.world.alttp_player_ids: for player in self.world.get_game_players("A Link to the Past"):
self.medallions[f'Misery Mire ({self.world.get_player_names(player)})'] = self.world.required_medallions[player][0] self.medallions[f'Misery Mire ({self.world.get_player_names(player)})'] = self.world.required_medallions[player][0]
self.medallions[f'Turtle Rock ({self.world.get_player_names(player)})'] = self.world.required_medallions[player][1] self.medallions[f'Turtle Rock ({self.world.get_player_names(player)})'] = self.world.required_medallions[player][1]
@@ -1387,7 +1185,7 @@ class Spoiler(object):
shopdata['item_{}'.format(index)] += ", {} - {}".format(item['replacement'], item['replacement_price']) if item['replacement_price'] else item['replacement'] shopdata['item_{}'.format(index)] += ", {} - {}".format(item['replacement'], item['replacement_price']) if item['replacement_price'] else item['replacement']
self.shops.append(shopdata) self.shops.append(shopdata)
for player in self.world.alttp_player_ids: for player in self.world.get_game_players("A Link to the Past"):
self.bosses[str(player)] = OrderedDict() self.bosses[str(player)] = OrderedDict()
self.bosses[str(player)]["Eastern Palace"] = self.world.get_dungeon("Eastern Palace", player).boss.name self.bosses[str(player)]["Eastern Palace"] = self.world.get_dungeon("Eastern Palace", player).boss.name
self.bosses[str(player)]["Desert Palace"] = self.world.get_dungeon("Desert Palace", player).boss.name self.bosses[str(player)]["Desert Palace"] = self.world.get_dungeon("Desert Palace", player).boss.name
@@ -1423,8 +1221,6 @@ class Spoiler(object):
'shuffle': self.world.shuffle, 'shuffle': self.world.shuffle,
'item_pool': self.world.difficulty, 'item_pool': self.world.difficulty,
'item_functionality': self.world.item_functionality, 'item_functionality': self.world.item_functionality,
'gt_crystals': self.world.crystals_needed_for_gt,
'ganon_crystals': self.world.crystals_needed_for_ganon,
'open_pyramid': self.world.open_pyramid, 'open_pyramid': self.world.open_pyramid,
'accessibility': self.world.accessibility, 'accessibility': self.world.accessibility,
'hints': self.world.hints, 'hints': self.world.hints,
@@ -1448,7 +1244,6 @@ class Spoiler(object):
'triforce_pieces_available': self.world.triforce_pieces_available, 'triforce_pieces_available': self.world.triforce_pieces_available,
'triforce_pieces_required': self.world.triforce_pieces_required, 'triforce_pieces_required': self.world.triforce_pieces_required,
'shop_shuffle': self.world.shop_shuffle, 'shop_shuffle': self.world.shop_shuffle,
'shop_shuffle_slots': self.world.shop_shuffle_slots,
'shuffle_prizes': self.world.shuffle_prizes, 'shuffle_prizes': self.world.shuffle_prizes,
'sprite_pool': self.world.sprite_pool, 'sprite_pool': self.world.sprite_pool,
'restrict_dungeon_item_on_boss': self.world.restrict_dungeon_item_on_boss, 'restrict_dungeon_item_on_boss': self.world.restrict_dungeon_item_on_boss,
@@ -1498,26 +1293,17 @@ class Spoiler(object):
outfile.write('Progression Balanced: %s\n' % ( outfile.write('Progression Balanced: %s\n' % (
'Yes' if self.metadata['progression_balancing'][player] else 'No')) 'Yes' if self.metadata['progression_balancing'][player] else 'No'))
outfile.write('Accessibility: %s\n' % self.metadata['accessibility'][player]) outfile.write('Accessibility: %s\n' % self.metadata['accessibility'][player])
if player in self.world.hk_player_ids: options = self.world.worlds[player].options
for hk_option in Options.hollow_knight_options: if options:
res = getattr(self.world, hk_option)[player] for f_option in options:
outfile.write(f'{hk_option+":":33}{res}\n')
elif player in self.world.factorio_player_ids:
for f_option in Options.factorio_options:
res = getattr(self.world, f_option)[player] res = getattr(self.world, f_option)[player]
outfile.write(f'{f_option+":":33}{bool_to_text(res) if type(res) == Options.Toggle else res.get_option_name()}\n') outfile.write(f'{f_option+":":33}{bool_to_text(res) if type(res) == Options.Toggle else res.get_option_name()}\n')
elif player in self.world.minecraft_player_ids: if player in self.world.get_game_players("A Link to the Past"):
for mc_option in Options.minecraft_options:
res = getattr(self.world, mc_option)[player]
outfile.write(f'{mc_option+":":33}{bool_to_text(res) if type(res) == Options.Toggle else res.get_option_name()}\n')
elif player in self.world.alttp_player_ids:
for team in range(self.world.teams): for team in range(self.world.teams):
outfile.write('%s%s\n' % ( outfile.write('%s%s\n' % (
f"Hash - {self.world.player_names[player][team]} (Team {team + 1}): " if f"Hash - {self.world.player_names[player][team]} (Team {team + 1}): " if
(player in self.world.alttp_player_ids and self.world.teams > 1) else 'Hash: ', (player in self.world.get_game_players("A Link to the Past") and self.world.teams > 1) else 'Hash: ',
self.hashes[player, team])) self.hashes[player, team]))
outfile.write('Logic: %s\n' % self.metadata['logic'][player]) outfile.write('Logic: %s\n' % self.metadata['logic'][player])
@@ -1541,8 +1327,6 @@ class Spoiler(object):
outfile.write('Entrance Shuffle: %s\n' % self.metadata['shuffle'][player]) outfile.write('Entrance Shuffle: %s\n' % self.metadata['shuffle'][player])
if self.metadata['shuffle'][player] != "vanilla": if self.metadata['shuffle'][player] != "vanilla":
outfile.write('Entrance Shuffle Seed %s\n' % self.metadata['er_seeds'][player]) outfile.write('Entrance Shuffle Seed %s\n' % self.metadata['er_seeds'][player])
outfile.write('Crystals required for GT: %s\n' % self.metadata['gt_crystals'][player])
outfile.write('Crystals required for Ganon: %s\n' % self.metadata['ganon_crystals'][player])
outfile.write('Pyramid hole pre-opened: %s\n' % ( outfile.write('Pyramid hole pre-opened: %s\n' % (
'Yes' if self.metadata['open_pyramid'][player] else 'No')) 'Yes' if self.metadata['open_pyramid'][player] else 'No'))
@@ -1565,8 +1349,6 @@ class Spoiler(object):
"f" in self.metadata["shop_shuffle"][player])) "f" in self.metadata["shop_shuffle"][player]))
outfile.write('Custom Potion Shop: %s\n' % outfile.write('Custom Potion Shop: %s\n' %
bool_to_text("w" in self.metadata["shop_shuffle"][player])) bool_to_text("w" in self.metadata["shop_shuffle"][player]))
outfile.write('Shop Slots: %s\n' %
self.metadata["shop_shuffle_slots"][player])
outfile.write('Boss shuffle: %s\n' % self.metadata['boss_shuffle'][player]) outfile.write('Boss shuffle: %s\n' % self.metadata['boss_shuffle'][player])
outfile.write( outfile.write(
'Enemy shuffle: %s\n' % bool_to_text(self.metadata['enemy_shuffle'][player])) 'Enemy shuffle: %s\n' % bool_to_text(self.metadata['enemy_shuffle'][player]))
@@ -1594,6 +1376,13 @@ class Spoiler(object):
outfile.write('\n\nMedallions:\n') outfile.write('\n\nMedallions:\n')
for dungeon, medallion in self.medallions.items(): for dungeon, medallion in self.medallions.items():
outfile.write(f'\n{dungeon}: {medallion}') outfile.write(f'\n{dungeon}: {medallion}')
factorio_players = self.world.get_game_players("Factorio")
if factorio_players:
outfile.write('\n\nRecipes:\n')
for player in factorio_players:
name = self.world.get_player_names(player)
for recipe in self.world.worlds[player].custom_recipes.values():
outfile.write(f"\n{recipe.name} ({name}): {recipe.ingredients} -> {recipe.products}")
if self.startinventory: if self.startinventory:
outfile.write('\n\nStarting Inventory:\n\n') outfile.write('\n\nStarting Inventory:\n\n')
@@ -1606,7 +1395,7 @@ class Spoiler(object):
outfile.write('\n\nShops:\n\n') outfile.write('\n\nShops:\n\n')
outfile.write('\n'.join("{} [{}]\n {}".format(shop['location'], shop['type'], "\n ".join(item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if item)) for shop in self.shops)) outfile.write('\n'.join("{} [{}]\n {}".format(shop['location'], shop['type'], "\n ".join(item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if item)) for shop in self.shops))
for player in self.world.alttp_player_ids: for player in self.world.get_game_players("A Link to the Past"):
if self.world.boss_shuffle[player] != 'none': if self.world.boss_shuffle[player] != 'none':
bossmap = self.bosses[str(player)] if self.world.players > 1 else self.bosses bossmap = self.bosses[str(player)] if self.world.players > 1 else self.bosses
outfile.write(f'\n\nBosses{(f" ({self.world.get_player_names(player)})" if self.world.players > 1 else "")}:\n') outfile.write(f'\n\nBosses{(f" ({self.world.get_player_names(player)})" if self.world.players > 1 else "")}:\n')
@@ -1630,6 +1419,3 @@ class Spoiler(object):
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines))) path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
outfile.write('\n'.join(path_listings)) outfile.write('\n'.join(path_listings))
from worlds.alttp.Items import item_name_groups
from worlds.generic import PlandoItem, PlandoConnection

View File

@@ -4,20 +4,13 @@ import typing
import asyncio import asyncio
import urllib.parse import urllib.parse
import prompt_toolkit
import websockets import websockets
from prompt_toolkit.patch_stdout import patch_stdout
import Utils import Utils
from MultiServer import CommandProcessor from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, color, ClientStatus from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, color, ClientStatus
from Utils import Version from Utils import Version
from worlds import network_data_package, AutoWorldRegister
# 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
logger = logging.getLogger("Client") logger = logging.getLogger("Client")
@@ -47,19 +40,16 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_received(self) -> bool: def _cmd_received(self) -> bool:
"""List all received items""" """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): for index, item in enumerate(self.ctx.items_received, 1):
logging.info('%s from %s (%s) (%d/%d in list)' % ( self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
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)))
return True return True
def _cmd_missing(self) -> bool: def _cmd_missing(self) -> bool:
"""List all missing location checks, from your local game state""" """List all missing location checks, from your local game state"""
count = 0 count = 0
checked_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].item_name_to_id.items():
if location_id < 0: if location_id < 0:
continue continue
if location_id not in self.ctx.locations_checked: if location_id not in self.ctx.locations_checked:
@@ -97,7 +87,9 @@ class CommonContext():
starting_reconnect_delay = 5 starting_reconnect_delay = 5
current_reconnect_delay = starting_reconnect_delay current_reconnect_delay = starting_reconnect_delay
command_processor = ClientCommandProcessor command_processor = ClientCommandProcessor
def __init__(self, server_address, password, found_items: bool): game: None
def __init__(self, server_address, password):
# server state # server state
self.server_address = server_address self.server_address = server_address
self.password = password self.password = password
@@ -108,7 +100,6 @@ class CommonContext():
# own state # own state
self.finished_game = False self.finished_game = False
self.ready = False self.ready = False
self.found_items = found_items
self.team = None self.team = None
self.slot = None self.slot = None
self.auth = None self.auth = None
@@ -213,8 +204,6 @@ class CommonContext():
logger.info(args["text"]) logger.info(args["text"])
def on_print_json(self, args: dict): 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"])) logger.info(self.jsontotextparser(args["data"]))
@@ -334,9 +323,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.error('Invalid password') logger.error('Invalid password')
ctx.password = None ctx.password = None
await ctx.server_auth(True) await ctx.server_auth(True)
else: elif errors:
raise Exception("Unknown connection errors: " + str(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': elif cmd == 'Connected':
ctx.team = args["team"] ctx.team = args["team"]
@@ -395,8 +385,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
elif cmd == 'PrintJSON': elif cmd == 'PrintJSON':
ctx.on_print_json(args) ctx.on_print_json(args)
elif cmd == 'InvalidArguments': elif cmd == 'InvalidPacket':
logger.warning(f"Invalid Arguments: {args['text']}") logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
else: else:
logger.debug(f"unknown command {cmd}") logger.debug(f"unknown command {cmd}")

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

@@ -5,7 +5,8 @@ import json
import string import string
import copy import copy
import sys import sys
from concurrent.futures import ThreadPoolExecutor import subprocess
import factorio_rcon
import colorama import colorama
import asyncio import asyncio
@@ -19,27 +20,118 @@ from NetUtils import RawJSONtoTextParser, NetworkItem, ClientStatus, JSONtoTextP
from worlds.factorio.Technologies import lookup_id_to_name from worlds.factorio.Technologies import lookup_id_to_name
rcon_port = 24242 os.makedirs("logs", exist_ok=True)
rcon_password = ''.join(random.choice(string.ascii_letters) for x in range(32))
save_name = "Archipelago"
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO) # Log to file in gui case
options = Utils.get_options() if getattr(sys, "frozen", False) and not "--nogui" in sys.argv:
executable = options["factorio_options"]["executable"] logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
bin_dir = os.path.dirname(executable) filename=os.path.join("logs", "FactorioClient.txt"), filemode="w")
if not os.path.isdir(bin_dir): else:
raise FileNotFoundError(bin_dir) logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
if not os.path.exists(executable): logging.getLogger().addHandler(logging.FileHandler(os.path.join("logs", "FactorioClient.txt"), "w"))
if os.path.exists(executable + ".exe"):
executable = executable + ".exe"
else:
raise FileNotFoundError(executable)
server_args = (save_name, "--rcon-port", rcon_port, "--rcon-password", rcon_password, *sys.argv[1:]) gui_enabled = Utils.is_frozen() or "--nogui" not in sys.argv
thread_pool = ThreadPoolExecutor(10) def get_kivy_app():
os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1"
os.environ["KIVY_NO_ARGS"] = "1"
from kivy.app import App
from kivy.base import ExceptionHandler, ExceptionManager, Config
from kivy.uix.gridlayout import GridLayout
from kivy.uix.textinput import TextInput
from kivy.uix.recycleview import RecycleView
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
from kivy.lang import Builder
class FactorioManager(App):
def __init__(self, ctx):
super(FactorioManager, self).__init__()
self.ctx = ctx
self.commandprocessor = ctx.command_processor(ctx)
self.icon = r"data/icon.png"
def build(self):
self.grid = GridLayout()
self.grid.cols = 1
self.tabs = TabbedPanel()
self.tabs.default_tab_text = "All"
self.title = "Archipelago Factorio Client"
pairs = [
("Client", "Archipelago"),
("FactorioServer", "Factorio Server Log"),
("FactorioWatcher", "Bridge Data Log"),
]
self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name) for logger_name, name in pairs))
for logger_name, display_name in pairs:
bridge_logger = logging.getLogger(logger_name)
panel = TabbedPanelItem(text=display_name)
panel.content = UILog(bridge_logger)
self.tabs.add_widget(panel)
self.grid.add_widget(self.tabs)
textinput = TextInput(size_hint_y=None, height=30, multiline=False)
textinput.bind(on_text_validate=self.on_message)
self.grid.add_widget(textinput)
self.commandprocessor("/help")
return self.grid
def on_stop(self):
self.ctx.exit_event.set()
def on_message(self, textinput: TextInput):
try:
input_text = textinput.text.strip()
textinput.text = ""
if self.ctx.input_requests > 0:
self.ctx.input_requests -= 1
self.ctx.input_queue.put_nowait(input_text)
elif input_text:
self.commandprocessor(input_text)
except Exception as e:
logger.exception(e)
def on_address(self, text: str):
print(text)
class LogtoUI(logging.Handler):
def __init__(self, on_log):
super(LogtoUI, self).__init__(logging.DEBUG)
self.on_log = on_log
def handle(self, record: logging.LogRecord) -> None:
self.on_log(record)
class UILog(RecycleView):
cols = 1
def __init__(self, *loggers_to_handle, **kwargs):
super(UILog, self).__init__(**kwargs)
self.data = []
for logger in loggers_to_handle:
logger.addHandler(LogtoUI(self.on_log))
def on_log(self, record: logging.LogRecord) -> None:
self.data.append({"text": record.getMessage()})
class E(ExceptionHandler):
def handle_exception(self, inst):
logger.exception(inst)
return ExceptionManager.RAISE
ExceptionManager.add_handler(E())
Config.set("input", "mouse", "mouse,disable_multitouch")
Builder.load_file(Utils.local_path("data", "client.kv"))
return FactorioManager
class FactorioCommandProcessor(ClientCommandProcessor): class FactorioCommandProcessor(ClientCommandProcessor):
ctx: FactorioContext ctx: FactorioContext
@@ -56,15 +148,19 @@ class FactorioCommandProcessor(ClientCommandProcessor):
def _cmd_connect(self, address: str = "") -> bool: def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server""" """Connect to a MultiWorld Server"""
if not self.ctx.auth: if not self.ctx.auth:
self.output("Cannot connect to a server with unknown own identity, bridge to Factorio first.") if self.ctx.rcon_client:
get_info(self.ctx, self.ctx.rcon_client) # retrieve current auth code
else:
self.output("Cannot connect to a server with unknown own identity, bridge to Factorio first.")
return super(FactorioCommandProcessor, self)._cmd_connect(address) return super(FactorioCommandProcessor, self)._cmd_connect(address)
class FactorioContext(CommonContext): class FactorioContext(CommonContext):
command_processor = FactorioCommandProcessor command_processor = FactorioCommandProcessor
game = "Factorio"
def __init__(self, *args, **kwargs): def __init__(self, server_address, password):
super(FactorioContext, self).__init__(*args, **kwargs) super(FactorioContext, self).__init__(server_address, password)
self.send_index = 0 self.send_index = 0
self.rcon_client = None self.rcon_client = None
self.awaiting_bridge = False self.awaiting_bridge = False
@@ -76,7 +172,7 @@ class FactorioContext(CommonContext):
await super(FactorioContext, self).server_auth(password_requested) await super(FactorioContext, self).server_auth(password_requested)
await self.send_msgs([{"cmd": 'Connect', 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'], 'tags': ['AP'],
'uuid': Utils.get_unique_identifier(), 'game': "Factorio" 'uuid': Utils.get_unique_identifier(), 'game': "Factorio"
}]) }])
@@ -89,8 +185,6 @@ class FactorioContext(CommonContext):
f"{cleaned_text}\")") f"{cleaned_text}\")")
def on_print_json(self, args: dict): 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.
text = self.raw_json_text_parser(copy.deepcopy(args["data"])) text = self.raw_json_text_parser(copy.deepcopy(args["data"]))
logger.info(text) logger.info(text)
if self.rcon_client: if self.rcon_client:
@@ -99,54 +193,50 @@ class FactorioContext(CommonContext):
self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] " self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] "
f"{cleaned_text}\")") f"{cleaned_text}\")")
@property
def savegame_name(self) -> str:
return f"AP_{self.seed_name}_{self.auth}.zip"
async def game_watcher(ctx: FactorioContext, bridge_file: str):
async def game_watcher(ctx: FactorioContext):
bridge_logger = logging.getLogger("FactorioWatcher") bridge_logger = logging.getLogger("FactorioWatcher")
from worlds.factorio.Technologies import lookup_id_to_name from worlds.factorio.Technologies import lookup_id_to_name
bridge_counter = 0
try: try:
while not ctx.exit_event.is_set(): while not ctx.exit_event.is_set():
if os.path.exists(bridge_file): if ctx.awaiting_bridge and ctx.rcon_client:
bridge_logger.info("Found Factorio Bridge file.") ctx.awaiting_bridge = False
while not ctx.exit_event.is_set(): data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
if ctx.awaiting_bridge: if data["slot_name"] != ctx.auth:
ctx.awaiting_bridge = False logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
with open(bridge_file) as f: elif data["seed_name"] != ctx.seed_name:
data = json.load(f) logger.warning(
research_data = data["research_done"] f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data} else:
victory = data["victory"] data = data["info"]
ctx.auth = data["slot_name"] research_data = data["research_done"]
ctx.seed_name = data["seed_name"] research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
if not ctx.finished_game and victory: if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True 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]}")
ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
await asyncio.sleep(1)
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]}")
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)
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
logging.error("Aborted Factorio Server Bridge") logging.error("Aborted Factorio Server Bridge")
def stream_factorio_output(pipe, queue): def stream_factorio_output(pipe, queue, process):
def queuer(): def queuer():
while 1: while process.poll() is None:
text = pipe.readline().strip() text = pipe.readline().strip()
if text: if text:
queue.put_nowait(text) queue.put_nowait(text)
@@ -155,25 +245,32 @@ def stream_factorio_output(pipe, queue):
thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True) thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True)
thread.start() thread.start()
return thread
async def factorio_server_watcher(ctx: FactorioContext): async def factorio_server_watcher(ctx: FactorioContext):
import subprocess savegame_name = os.path.abspath(ctx.savegame_name)
import factorio_rcon if not os.path.exists(savegame_name):
factorio_server_logger = logging.getLogger("FactorioServer") logger.info(f"Creating savegame {savegame_name}")
factorio_process = subprocess.Popen((executable, "--start-server", *(str(elem) for elem in server_args)), 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, stderr=subprocess.PIPE,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
encoding="utf-8") encoding="utf-8")
factorio_server_logger.info("Started Factorio Server") factorio_server_logger.info("Started Factorio Server")
factorio_queue = Queue() factorio_queue = Queue()
stream_factorio_output(factorio_process.stdout, factorio_queue) stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
stream_factorio_output(factorio_process.stderr, factorio_queue) stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
script_folder = None
progression_watcher = None
try: try:
while not ctx.exit_event.is_set(): 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(): while not factorio_queue.empty():
msg = factorio_queue.get() msg = factorio_queue.get()
factorio_server_logger.info(msg) factorio_server_logger.info(msg)
@@ -182,16 +279,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
# trigger lua interface confirmation # 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("/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 ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
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}")
progression_watcher = asyncio.create_task(
game_watcher(ctx, bridge_file), name="FactorioProgressionWatcher")
if not ctx.awaiting_bridge and "Archipelago Bridge File written for game tick " in msg:
ctx.awaiting_bridge = True ctx.awaiting_bridge = True
if ctx.rcon_client: if ctx.rcon_client:
while ctx.send_index < len(ctx.items_received): while ctx.send_index < len(ctx.items_received):
@@ -203,47 +291,105 @@ async def factorio_server_watcher(ctx: FactorioContext):
else: else:
item_name = lookup_id_to_name[item_id] item_name = lookup_id_to_name[item_id]
factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.") 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}') ctx.rcon_client.send_command(f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}')
ctx.send_index += 1 ctx.send_index += 1
await asyncio.sleep(1) await asyncio.sleep(0.1)
except Exception as e: except Exception as e:
logging.exception(e) logging.exception(e)
logging.error("Aborted Factorio Server Bridge") logging.error("Aborted Factorio Server Bridge")
ctx.rcon_client = None
ctx.exit_event.set()
finally: finally:
factorio_process.terminate() factorio_process.terminate()
if progression_watcher: factorio_process.wait(5)
await progression_watcher
async def main(): def get_info(ctx, rcon_client):
ctx = FactorioContext(None, None, True) info = json.loads(rcon_client.send_command("/ap-rcon-info"))
# testing shortcuts ctx.auth = info["slot_name"]
# ctx.server_address = "localhost" ctx.seed_name = info["seed_name"]
# ctx.auth = "Nauvis"
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") async def factorio_spinup_server(ctx: FactorioContext):
await asyncio.sleep(3) savegame_name = os.path.abspath("Archipelago.zip")
input_task = asyncio.create_task(console_loop(ctx), name="Input") 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 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)
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 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
ui_app = get_kivy_app()(ctx)
ui_task = asyncio.create_task(ui_app.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") 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() await ctx.exit_event.wait()
ctx.server_address = None ctx.server_address = None
ctx.snes_reconnect_address = None
await asyncio.gather(input_task, factorio_server_task) await progression_watcher
await factorio_server_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() await ctx.server.socket.close()
if ctx.server_task is not None: if ctx.server_task is not None:
await ctx.server_task await ctx.server_task
await factorio_server_task
while ctx.input_requests > 0: while ctx.input_requests > 0:
ctx.input_queue.put_nowait(None) ctx.input_queue.put_nowait(None)
ctx.input_requests -= 1 ctx.input_requests -= 1
await input_task if ui_task:
await ui_task
if input_task:
input_task.cancel()
class FactorioJSONtoTextParser(JSONtoTextParser): class FactorioJSONtoTextParser(JSONtoTextParser):
@@ -261,8 +407,40 @@ class FactorioJSONtoTextParser(JSONtoTextParser):
if __name__ == '__main__': 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() 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"]
bin_dir = os.path.dirname(executable)
if not os.path.exists(bin_dir):
raise FileNotFoundError(f"Path {bin_dir} does not exist or could not be accessed.")
if not os.path.isdir(bin_dir):
raise FileNotFoundError(f"Path {bin_dir} is not a directory.")
if not os.path.exists(executable):
if os.path.exists(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 = asyncio.get_event_loop()
loop.run_until_complete(main()) loop.run_until_complete(main(args))
loop.close() loop.close()
colorama.deinit() colorama.deinit()

View File

@@ -1,167 +0,0 @@
import os
import logging
import sys
os.makedirs("logs", exist_ok=True)
if getattr(sys, "frozen", False):
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
filename=os.path.join("logs", "FactorioClient.txt"), filemode="w")
else:
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
logging.getLogger().addHandler(logging.FileHandler(os.path.join("logs", "FactorioClient.txt"), "w"))
os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1"
os.environ["KIVY_NO_ARGS"] = "1"
import asyncio
from CommonClient import server_loop, logger
from FactorioClient import FactorioContext, factorio_server_watcher
async def main():
ctx = FactorioContext(None, None, True)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
ui_app = FactorioManager(ctx)
ui_task = asyncio.create_task(ui_app.async_run(), name="UI")
await ctx.exit_event.wait() # wait for signal to exit application
ui_app.stop()
ctx.server_address = None
ctx.snes_reconnect_address = None
# allow tasks to quit
await ui_task
await factorio_server_task
await ctx.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
while ctx.input_requests > 0: # clear queue for shutdown
ctx.input_queue.put_nowait(None)
ctx.input_requests -= 1
from kivy.app import App
from kivy.uix.label import Label
from kivy.base import ExceptionHandler, ExceptionManager, Config
from kivy.uix.gridlayout import GridLayout
from kivy.uix.textinput import TextInput
from kivy.uix.recycleview import RecycleView
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
from kivy.lang import Builder
class FactorioManager(App):
def __init__(self, ctx):
super(FactorioManager, self).__init__()
self.ctx = ctx
self.commandprocessor = ctx.command_processor(ctx)
self.icon = "data/icon.png"
def build(self):
self.grid = GridLayout()
self.grid.cols = 1
self.tabs = TabbedPanel()
self.tabs.default_tab_text = "All"
self.title = "Archipelago Factorio Client"
pairs = [
("Client", "Archipelago"),
("FactorioServer", "Factorio Server Log"),
("FactorioWatcher", "Bridge File Log"),
]
self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name) for logger_name, name in pairs))
for logger_name, display_name in pairs:
bridge_logger = logging.getLogger(logger_name)
panel = TabbedPanelItem(text=display_name)
panel.content = UILog(bridge_logger)
self.tabs.add_widget(panel)
self.grid.add_widget(self.tabs)
textinput = TextInput(size_hint_y=None, height=30, multiline=False)
textinput.bind(on_text_validate=self.on_message)
self.grid.add_widget(textinput)
self.commandprocessor("/help")
return self.grid
def on_stop(self):
self.ctx.exit_event.set()
def on_message(self, textinput: TextInput):
try:
input_text = textinput.text.strip()
textinput.text = ""
if self.ctx.input_requests > 0:
self.ctx.input_requests -= 1
self.ctx.input_queue.put_nowait(input_text)
elif input_text:
self.commandprocessor(input_text)
except Exception as e:
logger.exception(e)
class LogtoUI(logging.Handler):
def __init__(self, on_log):
super(LogtoUI, self).__init__(logging.DEBUG)
self.on_log = on_log
def handle(self, record: logging.LogRecord) -> None:
self.on_log(record)
class UILog(RecycleView):
cols = 1
def __init__(self, *loggers_to_handle, **kwargs):
super(UILog, self).__init__(**kwargs)
self.data = []
for logger in loggers_to_handle:
logger.addHandler(LogtoUI(self.on_log))
def on_log(self, record: logging.LogRecord) -> None:
self.data.append({"text": record.getMessage()})
class E(ExceptionHandler):
def handle_exception(self, inst):
logger.exception(inst)
return ExceptionManager.RAISE
ExceptionManager.add_handler(E())
Config.set("input", "mouse", "mouse,disable_multitouch")
Builder.load_string('''
<TabbedPanel>
tab_width: 200
<Row@Label>:
canvas.before:
Color:
rgba: 0.2, 0.2, 0.2, 1
Rectangle:
size: self.size
pos: self.pos
text_size: self.width, None
size_hint_y: None
height: self.texture_size[1]
font_size: dp(20)
<UILog>:
viewclass: 'Row'
scroll_y: 0
effect_cls: "ScrollEffect"
RecycleBoxLayout:
default_size: None, dp(20)
default_size_hint: 1, None
size_hint_y: None
height: self.minimum_height
orientation: 'vertical'
spacing: dp(3)
''')
if __name__ == '__main__':
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()

156
Fill.py
View File

@@ -3,16 +3,17 @@ import typing
import collections import collections
import itertools import itertools
from BaseClasses import CollectionState, PlandoItem, Location from BaseClasses import CollectionState, Location, MultiWorld
from worlds.alttp.Items import ItemFactory from worlds.alttp.Items import ItemFactory
from worlds.alttp.Regions import key_drop_data from worlds.alttp.Regions import key_drop_data
from worlds.generic import PlandoItem
class FillError(RuntimeError): class FillError(RuntimeError):
pass 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): lock=False):
def sweep_from_pool(): def sweep_from_pool():
new_state = base_state.copy() new_state = base_state.copy()
@@ -68,7 +69,7 @@ def fill_restrictive(world, base_state: CollectionState, locations, itempool, si
itempool.extend(unplaced_items) itempool.extend(unplaced_items)
def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None): def distribute_items_restrictive(world: MultiWorld, gftower_trash=False, fill_locations=None):
# If not passed in, then get a shuffled list of locations to fill in # If not passed in, then get a shuffled list of locations to fill in
if not fill_locations: if not fill_locations:
fill_locations = world.get_unfilled_locations() fill_locations = world.get_unfilled_locations()
@@ -77,12 +78,15 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
# get items to distribute # get items to distribute
world.random.shuffle(world.itempool) world.random.shuffle(world.itempool)
progitempool = [] progitempool = []
nonexcludeditempool = []
localrestitempool = {player: [] for player in range(1, world.players + 1)} localrestitempool = {player: [] for player in range(1, world.players + 1)}
restitempool = [] restitempool = []
for item in world.itempool: for item in world.itempool:
if item.advancement: if item.advancement:
progitempool.append(item) 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]: elif item.name in world.local_items[item.player]:
localrestitempool[item.player].append(item) localrestitempool[item.player].append(item)
else: else:
@@ -91,9 +95,9 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
standard_keyshuffle_players = set() standard_keyshuffle_players = set()
# fill in gtower locations with trash first # fill in gtower locations with trash first
for player in world.alttp_player_ids: for player in world.get_game_players("A Link to the Past"):
if not gftower_trash or not world.ganonstower_vanilla[player] or \ if not gftower_trash or not world.ganonstower_vanilla[player] or \
world.logic[player] in {'owglitches', "nologic"}: world.logic[player] in {'owglitches', 'hybridglitches', "nologic"}:
gtower_trash_count = 0 gtower_trash_count = 0
elif 'triforcehunt' in world.goal[player] and ('local' in world.goal[player] or world.players == 1): 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, gtower_trash_count = world.random.randint(world.crystals_needed_for_gt[player] * 2,
@@ -136,6 +140,10 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
world.random.shuffle(fill_locations) world.random.shuffle(fill_locations)
fill_restrictive(world, world.state, fill_locations, progitempool) 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 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} local_locations = {player: [] for player in world.player_ids}
for location in fill_locations: for location in fill_locations:
@@ -167,14 +175,14 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
logging.warning(f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {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)) placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations): for item, location in zip(item_pool, fill_locations):
world.push_item(location, item, False) world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:] return item_pool[placing:], fill_locations[placing:]
def flood_items(world): def flood_items(world: MultiWorld):
# get items to distribute # get items to distribute
world.random.shuffle(world.itempool) world.random.shuffle(world.itempool)
itempool = world.itempool itempool = world.itempool
@@ -234,7 +242,7 @@ def flood_items(world):
break 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]} balanceable_players = {player for player in range(1, world.players + 1) if world.progression_balancing[player]}
if not balanceable_players: if not balanceable_players:
logging.info('Skipping multiworld progression balancing.') logging.info('Skipping multiworld progression balancing.')
@@ -347,7 +355,8 @@ def balance_multiworld_progression(world):
if world.has_beaten_game(state): if world.has_beaten_game(state):
break break
elif not sphere_locations: 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): def swap_location_item(location_1: Location, location_2: Location, check_locked=True):
@@ -363,73 +372,76 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked=
location_1.event, location_2.event = location_2.event, location_1.event location_1.event, location_2.event = location_2.event, location_1.event
def distribute_planned(world): def distribute_planned(world: MultiWorld):
world_name_lookup = world.world_name_lookup world_name_lookup = world.world_name_lookup
for player in world.player_ids: for player in world.player_ids:
placement: PlandoItem try:
for placement in world.plando_items[player]: placement: PlandoItem
if placement.location in key_drop_data: for placement in world.plando_items[player]:
placement.warn( if placement.location in key_drop_data:
f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.") placement.warn(
continue f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
item = ItemFactory(placement.item, player) continue
target_world: int = placement.world item = ItemFactory(placement.item, player)
if target_world is False or world.players == 1: target_world: int = placement.world
target_world = player # in own world if target_world is False or world.players == 1:
elif target_world is True: # in any other world target_world = player # in own world
unfilled = list(location for location in world.get_unfilled_locations_for_players( elif target_world is True: # in any other world
placement.location, unfilled = list(location for location in world.get_unfilled_locations_for_players(
set(world.player_ids) - {player}) if location.item_rule(item) 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}", if not unfilled:
FillError) 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 continue
target_world = world.random.choice(unfilled).player if location.can_fill(world.state, item, False):
world.push_item(location, item, collect=False)
elif target_world is None: # any random world location.event = True # flag location to be checked during fill
unfilled = list(location for location in world.get_unfilled_locations_for_players( location.locked = True
placement.location, logging.debug(f"Plando placed {item} at {location}")
set(world.player_ids)) if location.item_rule(item) else:
) placement.failed(f"Can't place {item} at {location} due to fill condition not met.")
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
continue continue
target_world = world.random.choice(unfilled).player if placement.from_pool: # Should happen AFTER the item is placed, in case it was allowed to skip failed placement.
try:
elif type(target_world) == int: # target world by player id world.itempool.remove(item)
if target_world not in range(1, world.players + 1): except ValueError:
placement.failed( placement.warn(f"Could not remove {item} from pool as it's already missing from it.")
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})", except Exception as e:
ValueError) raise Exception(f"Error running plando for player {player} ({world.player_names[player]})") from e
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.")

View File

@@ -9,163 +9,152 @@ from collections import Counter
import string import string
import ModuleUpdate import ModuleUpdate
from worlds.generic import PlandoItem, PlandoConnection
ModuleUpdate.update() ModuleUpdate.update()
from Utils import parse_yaml 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 worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain from Main import main as ERmain
from Main import get_seed, seeddigits from Main import get_seed, seeddigits
import Options import Options
from worlds import lookup_any_item_name_to_id
from worlds.alttp.Items import item_name_groups, item_table from worlds.alttp.Items import item_name_groups, item_table
from worlds.alttp import Bosses from worlds.alttp import Bosses
from worlds.alttp.Text import TextTable from worlds.alttp.Text import TextTable
from worlds.alttp.Regions import location_table, key_drop_data from worlds.alttp.Regions import location_table, key_drop_data
from worlds.AutoWorld import AutoWorldRegister
categories = set(AutoWorldRegister.world_types)
def mystery_argparse(): def mystery_argparse():
parser = argparse.ArgumentParser(add_help=False) options = get_options()
parser.add_argument('--multi', default=1, type=lambda value: min(max(int(value), 1), 255)) defaults = options["generator"]
multiargs, _ = parser.parse_known_args()
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
parser.add_argument('--weights', 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') 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', parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
action='store_true') 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('--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('--multi', default=defaults["players"], 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('--teams', default=1, type=lambda value: max(int(value), 1))
parser.add_argument('--create_spoiler', action='store_true') parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
parser.add_argument('--skip_playthrough', action='store_true') parser.add_argument('--rom', default=options["lttp_options"]["rom_file"], help="Path to the 1.0 JP LttP Baserom.")
parser.add_argument('--pre_roll', action='store_true') parser.add_argument('--enemizercli', default=defaults["enemizer_path"])
parser.add_argument('--rom') parser.add_argument('--outputpath', default=options["general_options"]["output_path"])
parser.add_argument('--enemizercli') parser.add_argument('--race', action='store_true', default=defaults["race"])
parser.add_argument('--outputpath') parser.add_argument('--meta_file_path', default=defaults["meta_file_path"])
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('--log_output_path', help='Path to store output log')
parser.add_argument('--loglevel', default='info', help='Sets log level') parser.add_argument('--log_level', 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), 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)') help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
parser.add_argument('--plando', default="bosses", parser.add_argument('--plando', default=defaults["plando_options"],
help='List of options that can be set manually. Can be combined, for example "bosses, items"') 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 = 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(",")} args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
return args return args, options
def get_seed_name(random): def get_seed_name(random):
return f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits) return f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None, callback=ERmain): def main(args=None, callback=ERmain):
if not args: if not args:
args = mystery_argparse() args, options = mystery_argparse()
seed = get_seed(args.seed) seed = get_seed(args.seed)
random.seed(seed) random.seed(seed)
seed_name = args.seed_name if args.seed_name else get_seed_name(random) seed_name = get_seed_name(random)
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed}")
if args.race: if args.race:
random.seed() # reset to time-based random source random.seed() # reset to time-based random source
weights_cache = {} weights_cache = {}
if args.weights: if args.weights_file_path and os.path.exists(args.weights_file_path):
try: try:
weights_cache[args.weights] = get_weights(args.weights) weights_cache[args.weights_file_path] = read_weights_yaml(args.weights_file_path)
except Exception as e: except Exception as e:
raise ValueError(f"File {args.weights} is destroyed. Please fix your yaml.") from e raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
print(f"Weights: {args.weights} >> " print(f"Weights: {args.weights_file_path} >> "
f"{get_choice('description', weights_cache[args.weights], 'No description specified')}") f"{get_choice('description', weights_cache[args.weights_file_path], 'No description specified')}")
if args.meta:
if args.meta_file_path and os.path.exists(args.meta_file_path):
try: try:
weights_cache[args.meta] = get_weights(args.meta) weights_cache[args.meta_file_path] = read_weights_yaml(args.meta_file_path)
except Exception as e: except Exception as e:
raise ValueError(f"File {args.meta} is destroyed. Please fix your yaml.") from e raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta] meta_weights = weights_cache[args.meta_file_path]
print(f"Meta: {args.meta} >> {get_choice('meta_description', meta_weights, 'No description specified')}") print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights, 'No description specified')}")
if args.samesettings: if args.samesettings:
raise Exception("Cannot mix --samesettings with --meta") raise Exception("Cannot mix --samesettings with --meta")
else:
for player in range(1, args.multi + 1): meta_weights = None
path = getattr(args, f'p{player}') player_id = 1
if path: 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: try:
if path not in weights_cache: weights_cache[fname] = read_weights_yaml(path)
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: except Exception as e:
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from 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 = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed erargs.seed = seed
erargs.name = {x: "" for x in range(1, args.multi + 1)} # only so it can be overwrittin in mystery 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_spoiler = args.spoiler > 0
erargs.create_diff = args.create_diff erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
erargs.glitch_triforce = args.glitch_triforce
erargs.race = args.race erargs.race = args.race
erargs.skip_playthrough = args.skip_playthrough erargs.skip_playthrough = args.spoiler < 2
erargs.outputname = seed_name erargs.outputname = seed_name
erargs.outputpath = args.outputpath erargs.outputpath = args.outputpath
erargs.teams = args.teams erargs.teams = args.teams
# set up logger # set up logger
if args.loglevel: if args.log_level:
erargs.loglevel = args.loglevel erargs.loglevel = args.log_level
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[ loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
erargs.loglevel] erargs.loglevel]
if args.log_output_path: 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) os.makedirs(args.log_output_path, exist_ok=True)
logging.basicConfig(format='%(message)s', level=loglevel, logging.basicConfig(format='%(message)s', level=loglevel, force=True,
filename=os.path.join(args.log_output_path, f"{seed}.log")) filename=os.path.join(args.log_output_path, f"{seed}.log"))
else: else:
logging.basicConfig(format='%(message)s', level=loglevel) logging.basicConfig(format='%(message)s', level=loglevel, force=True)
if args.rom:
erargs.rom = args.rom
if args.enemizercli: erargs.rom = args.rom
erargs.enemizercli = args.enemizercli erargs.enemizercli = args.enemizercli
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None) settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
for k, v in weights_cache.items()} for k, v in weights_cache.items()}
player_path_cache = {} player_path_cache = {}
for player in range(1, args.multi + 1): 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 player_path_cache[player] = player_files.get(player, args.weights_file_path)
if args.meta: if meta_weights:
for player, path in player_path_cache.items(): for player, path in player_path_cache.items():
weights_cache[path].setdefault("meta_ignore", []) weights_cache[path].setdefault("meta_ignore", [])
meta_weights = weights_cache[args.meta]
for key in meta_weights: for key in meta_weights:
option = get_choice(key, meta_weights) option = get_choice(key, meta_weights)
if option is not None: if option is not None:
@@ -184,31 +173,6 @@ def main(args=None, callback=ERmain):
try: try:
settings = settings_cache[path] if settings_cache[path] else \ settings = settings_cache[path] if settings_cache[path] else \
roll_settings(weights_cache[path], args.plando) 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(): for k, v in vars(settings).items():
if v is not None: if v is not None:
try: try:
@@ -219,7 +183,7 @@ def main(args=None, callback=ERmain):
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
else: else:
raise RuntimeError(f'No weights specified for player {player}') 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 if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}" erargs.name[player] = f"Player{player}"
elif not erargs.name[player]: # if name was not specified, generate it from filename 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] = os.path.splitext(os.path.split(path)[-1])[0]
@@ -248,13 +212,13 @@ def main(args=None, callback=ERmain):
logging.debug(f"No player settings defined for option '{option}'") logging.debug(f"No player settings defined for option '{option}'")
if args.outputpath: if args.outputpath:
os.makedirs(args.outputpath, exist_ok=True) 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: with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
yaml.dump(important, f) yaml.dump(important, f)
callback(erargs, seed) callback(erargs, seed)
def get_weights(path): def read_weights_yaml(path):
try: try:
if urllib.parse.urlparse(path).scheme: if urllib.parse.urlparse(path).scheme:
yaml = str(urllib.request.urlopen(path).read(), "utf-8") yaml = str(urllib.request.urlopen(path).read(), "utf-8")
@@ -342,19 +306,6 @@ goals = {
'ice_rod_hunt': 'icerodhunt', '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: def roll_percentage(percentage: typing.Union[int, float]) -> bool:
"""Roll a percentage chance. """Roll a percentage chance.
@@ -382,13 +333,12 @@ def roll_linked_options(weights: dict) -> dict:
try: try:
if roll_percentage(option_set["percentage"]): if roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.") logging.debug(f"Linked option {option_set['name']} triggered.")
if "options" in option_set: new_options = option_set["options"]
weights = update_weights(weights, option_set["options"], "Linked", option_set["name"]) for category_name, category_options in new_options.items():
if "rom_options" in option_set: currently_targeted_weights = weights
rom_weights = weights.get("rom", dict()) if category_name:
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Linked Rom", currently_targeted_weights = currently_targeted_weights[category_name]
option_set["name"]) update_weights(currently_targeted_weights, category_options, "Linked", option_set["name"])
weights["rom"] = rom_weights
else: else:
logging.debug(f"linked option {option_set['name']} skipped.") logging.debug(f"linked option {option_set['name']} skipped.")
except Exception as e: except Exception as e:
@@ -402,35 +352,32 @@ def roll_triggers(weights: dict) -> dict:
weights["_Generator_Version"] = "Archipelago" # Some means for triggers to know if the seed is on main or doors. 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"]): for i, option_set in enumerate(weights["triggers"]):
try: 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) key = get_choice("option_name", option_set)
if key not in weights: if key not in currently_targeted_weights:
logging.warning(f'Specified option name {option_set["option_name"]} did not ' logging.warning(f'Specified option name {option_set["option_name"]} did not '
f'match with a root option. ' f'match with a root option. '
f'This is probably in error.') f'This is probably in error.')
trigger_result = get_choice("option_result", option_set) trigger_result = get_choice("option_result", option_set)
result = get_choice(key, weights) 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)): if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
if "options" in option_set: for category_name, category_options in option_set["options"].items():
weights = update_weights(weights, option_set["options"], "Triggered", option_set["option_name"]) 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"])
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: except Exception as e:
raise ValueError(f"Your trigger number {i+1} is destroyed. " raise ValueError(f"Your trigger number {i + 1} is destroyed. "
f"Please fix your triggers.") from e f"Please fix your triggers.") from e
return weights return weights
def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str: 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: if boss_shuffle in boss_shuffle_options:
return boss_shuffle_options[boss_shuffle] return boss_shuffle_options[boss_shuffle]
elif "bosses" in plando_options: elif "bosses" in plando_options:
@@ -438,10 +385,6 @@ def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str
remainder_shuffle = "none" # vanilla remainder_shuffle = "none" # vanilla
bosses = [] bosses = []
for boss in options: 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: if boss in boss_shuffle_options:
remainder_shuffle = boss_shuffle_options[boss] remainder_shuffle = boss_shuffle_options[boss]
elif "-" in boss: elif "-" in boss:
@@ -470,68 +413,58 @@ def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))): 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: if "linked_options" in weights:
weights = roll_linked_options(weights) weights = roll_linked_options(weights)
if "triggers" in weights: if "triggers" in weights:
weights = roll_triggers(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 = argparse.Namespace()
ret.name = get_choice('name', weights) ret.name = get_choice('name', weights)
ret.accessibility = get_choice('accessibility', weights) ret.accessibility = get_choice('accessibility', weights)
ret.progression_balancing = get_choice('progression_balancing', weights, True) ret.progression_balancing = get_choice('progression_balancing', weights, True)
ret.game = get_choice("game", weights, "A Link to the Past") 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() ret.local_items = set()
for item_name in weights.get('local_items', []): for item_name in game_weights.get('local_items', []):
items = item_name_groups.get(item_name, {item_name}) items = world_type.item_name_groups.get(item_name, {item_name})
for item in items: for item in items:
if item in lookup_any_item_name_to_id: if item in world_type.item_names:
ret.local_items.add(item) ret.local_items.add(item)
else: else:
raise Exception(f"Could not force item {item} to be world-local, as it was not recognized.") raise Exception(f"Could not force item {item} to be world-local, as it was not recognized.")
ret.non_local_items = set() ret.non_local_items = set()
for item_name in weights.get('non_local_items', []): for item_name in game_weights.get('non_local_items', []):
items = item_name_groups.get(item_name, {item_name}) items = world_type.item_name_groups.get(item_name, {item_name})
for item in items: for item in items:
if item in lookup_any_item_name_to_id: if item in world_type.item_names:
ret.non_local_items.add(item) ret.non_local_items.add(item)
else: else:
raise Exception(f"Could not force item {item} to be world-non-local, as it was not recognized.") raise Exception(f"Could not force item {item} to be world-non-local, as it was not recognized.")
inventoryweights = weights.get('startinventory', {}) inventoryweights = game_weights.get('start_inventory', {})
startitems = [] startitems = []
for item in inventoryweights.keys(): for item in inventoryweights.keys():
itemvalue = get_choice(item, inventoryweights) itemvalue = get_choice(item, inventoryweights)
@@ -541,40 +474,41 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
elif itemvalue: elif itemvalue:
startitems.append(item) startitems.append(item)
ret.startinventory = startitems ret.startinventory = startitems
ret.start_hints = set(weights.get('start_hints', [])) 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 == "A Link to the Past": if ret.game in AutoWorldRegister.world_types:
roll_alttp_settings(ret, weights, plando_options) for option_name, option in AutoWorldRegister.world_types[ret.game].options.items():
elif ret.game == "Hollow Knight": if option_name in game_weights:
for option_name, option in Options.hollow_knight_options.items(): try:
setattr(ret, option_name, option.from_any(get_choice(option_name, weights, True))) if issubclass(option, Options.OptionDict):
elif ret.game == "Factorio": setattr(ret, option_name, option.from_any(game_weights[option_name]))
for option_name, option in Options.factorio_options.items(): else:
if option_name in weights: setattr(ret, option_name, option.from_any(get_choice(option_name, game_weights)))
if issubclass(option, Options.OptionDict): # get_choice should probably land in the Option class except Exception as e:
setattr(ret, option_name, option.from_any(weights[option_name])) raise Exception(f"Error generating option {option_name} in {ret.game}") from e
else:
setattr(ret, option_name, option.from_any(get_choice(option_name, weights)))
else: else:
setattr(ret, option_name, option(option.default)) setattr(ret, option_name, option(option.default))
elif ret.game == "Minecraft": if ret.game == "Minecraft":
for option_name, option in Options.minecraft_options.items(): # bad hardcoded behavior to make this work for now
if option_name in weights: ret.plando_connections = []
setattr(ret, option_name, option.from_any(get_choice(option_name, weights))) if "connections" in plando_options:
else: options = game_weights.get("plando_connections", [])
setattr(ret, option_name, option(option.default)) for placement in options:
# bad hardcoded behavior to make this work for now if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections = [] ret.plando_connections.append(PlandoConnection(
if "connections" in plando_options: get_choice("entrance", placement),
options = weights.get("plando_connections", []) get_choice("exit", placement),
for placement in options: get_choice("direction", placement, "both")
if roll_percentage(get_choice("percentage", placement, 100)): ))
ret.plando_connections.append(PlandoConnection( elif ret.game == "A Link to the Past":
get_choice("entrance", placement), roll_alttp_settings(ret, game_weights, plando_options)
get_choice("exit", placement),
get_choice("direction", placement, "both")
))
else: else:
raise Exception(f"Unsupported game {ret.game}") raise Exception(f"Unsupported game {ret.game}")
return ret return ret
@@ -582,11 +516,11 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options): def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
glitches_required = get_choice('glitches_required', weights) glitches_required = get_choice('glitches_required', weights)
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'minor_glitches']: if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
logging.warning("Only NMG, OWG and No Logic supported") logging.warning("Only NMG, OWG, HMG and No Logic supported")
glitches_required = 'none' glitches_required = 'none'
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches', ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
'minor_glitches': 'minorglitches'}[ 'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
glitches_required] glitches_required]
ret.dark_room_logic = get_choice("dark_room_logic", weights, "lamp") ret.dark_room_logic = get_choice("dark_room_logic", weights, "lamp")
@@ -623,23 +557,15 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
goal = get_choice('goals', weights, 'ganon') 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] ret.goal = goals[goal]
# TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when # TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when
# fast ganon + ganon at hole # fast ganon + ganon at hole
ret.open_pyramid = get_choice('open_pyramid', weights, 'goal') 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') 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 = LttPOptions.TriforcePieces.from_any(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 # sum a percentage to required
if extra_pieces == 'percentage': if extra_pieces == 'percentage':
@@ -647,7 +573,8 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0)) ret.triforce_pieces_available = int(round(ret.triforce_pieces_required * percentage, 0))
# vanilla mode (specify how many pieces are) # vanilla mode (specify how many pieces are)
elif extra_pieces == 'available': elif extra_pieces == 'available':
ret.triforce_pieces_available = int(get_choice('triforce_pieces_available', weights, 30)) ret.triforce_pieces_available = LttPOptions.TriforcePieces.from_any(
get_choice('triforce_pieces_available', weights, 30))
# required pieces + fixed extra # required pieces + fixed extra
elif extra_pieces == 'extra': elif extra_pieces == 'extra':
extra_pieces = max(0, int(get_choice('triforce_pieces_extra', weights, 10))) extra_pieces = max(0, int(get_choice('triforce_pieces_extra', weights, 10)))
@@ -655,11 +582,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
# change minimum to required pieces to avoid problems # 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.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, '') ret.shop_shuffle = get_choice('shop_shuffle', weights, '')
if not ret.shop_shuffle: if not ret.shop_shuffle:
@@ -681,7 +603,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.enemy_shuffle = bool(get_choice('enemy_shuffle', weights, False)) ret.enemy_shuffle = bool(get_choice('enemy_shuffle', weights, False))
ret.killable_thieves = get_choice('killable_thieves', weights, False) ret.killable_thieves = get_choice('killable_thieves', weights, False)
ret.tile_shuffle = get_choice('tile_shuffle', weights, False) ret.tile_shuffle = get_choice('tile_shuffle', weights, False)
ret.bush_shuffle = get_choice('bush_shuffle', weights, False) ret.bush_shuffle = get_choice('bush_shuffle', weights, False)
@@ -793,49 +714,42 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
get_choice("direction", placement, "both") get_choice("direction", placement, "both")
)) ))
if 'rom' in weights: ret.sprite_pool = weights.get('sprite_pool', [])
romweights = weights['rom'] ret.sprite = get_choice('sprite', weights, "Link")
if 'random_sprite_on_event' in weights:
randomoneventweights = weights['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
ret.sprite_pool = romweights['sprite_pool'] if 'sprite_pool' in romweights else [] if (not ret.sprite_pool or get_choice('use_weighted_sprite_pool', randomoneventweights, False)) \
ret.sprite = get_choice('sprite', romweights, "Link") and 'sprite' in weights: # Use sprite as a weighted sprite pool, if a sprite pool is not already defined.
if 'random_sprite_on_event' in romweights: for key, value in weights['sprite'].items():
randomoneventweights = romweights['random_sprite_on_event'] if key.startswith('random'):
if get_choice('enabled', randomoneventweights, False): ret.sprite_pool += ['random'] * int(value)
ret.sprite = 'randomon' else:
ret.sprite += '-hit' if get_choice('on_hit', randomoneventweights, True) else '' ret.sprite_pool += [key] * int(value)
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)) \ ret.disablemusic = get_choice('disablemusic', weights, False)
and 'sprite' in romweights: # Use sprite as a weighted sprite pool, if a sprite pool is not already defined. ret.triforcehud = get_choice('triforcehud', weights, 'hide_goal')
for key, value in romweights['sprite'].items(): ret.quickswap = get_choice('quickswap', weights, True)
if key.startswith('random'): ret.fastmenu = get_choice('menuspeed', weights, "normal")
ret.sprite_pool += ['random'] * int(value) ret.reduceflashing = get_choice('reduceflashing', weights, False)
else: ret.heartcolor = get_choice('heartcolor', weights, "red")
ret.sprite_pool += [key] * int(value) ret.heartbeep = convert_to_on_off(get_choice('heartbeep', weights, "normal"))
ret.ow_palettes = get_choice('ow_palettes', weights, "default")
ret.disablemusic = get_choice('disablemusic', romweights, False) ret.uw_palettes = get_choice('uw_palettes', weights, "default")
ret.triforcehud = get_choice('triforcehud', romweights, 'hide_goal') ret.hud_palettes = get_choice('hud_palettes', weights, "default")
ret.quickswap = get_choice('quickswap', romweights, True) ret.sword_palettes = get_choice('sword_palettes', weights, "default")
ret.fastmenu = get_choice('menuspeed', romweights, "normal") ret.shield_palettes = get_choice('shield_palettes', weights, "default")
ret.reduceflashing = get_choice('reduceflashing', romweights, False) ret.link_palettes = get_choice('link_palettes', weights, "default")
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__': if __name__ == '__main__':

1926
Gui.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,192 +0,0 @@
import queue
import threading
import tkinter as tk
from Utils import local_path
def set_icon(window):
logo = tk.PhotoImage(file=local_path('data', 'icon.png'))
window.tk.call('wm', 'iconphoto', window._w, logo)
# 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 #!/usr/bin/env python3
import argparse import argparse
import json
import os import os
import logging import logging
import queue
import random
import shutil
import textwrap import textwrap
import sys import sys
import threading
import time import time
from tkinter import Tk import tkinter as tk
from argparse import Namespace
from Gui import update_sprites from concurrent.futures import as_completed, ThreadPoolExecutor
from GuiUtils import BackgroundTaskProgress 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 worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings
from Utils import output_path from Utils import output_path, local_path, open_file
class AdjusterWorld(object): class AdjusterWorld(object):
def __init__(self, sprite_pool): def __init__(self, sprite_pool):
import random import random
self.sprite_pool = {1: sprite_pool} self.sprite_pool = {1: sprite_pool}
self.rom_seeds = {1: random} self.slot_seeds = {1: random}
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
@@ -155,8 +164,6 @@ def adjust(args):
def adjustGUI(): def adjustGUI():
from tkinter import Tk, LEFT, BOTTOM, TOP, \ from tkinter import Tk, LEFT, BOTTOM, TOP, \
StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk
from Gui import get_rom_options_frame, get_rom_frame
from GuiUtils import set_icon
from argparse import Namespace from argparse import Namespace
from Main import __version__ as MWVersion from Main import __version__ as MWVersion
adjustWindow = Tk() adjustWindow = Tk()
@@ -239,5 +246,859 @@ def run_sprite_update():
print("Done updating sprites") 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", "*")])
import Patch
try:
Patch.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.disableMusicVar = IntVar()
disableMusicCheckbutton = Checkbutton(romOptionsFrame, text="Disable music", variable=vars.disableMusicVar)
disableMusicCheckbutton.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)
fastMenuFrame = Frame(romOptionsFrame)
fastMenuFrame.grid(row=1, column=1, sticky=E)
fastMenuLabel = Label(fastMenuFrame, text='Menu speed')
fastMenuLabel.pack(side=LEFT)
vars.fastMenuVar = StringVar()
vars.fastMenuVar.set('normal')
fastMenuOptionMenu = OptionMenu(fastMenuFrame, vars.fastMenuVar, 'normal', 'instant', 'double', 'triple', 'quadruple', 'half')
fastMenuOptionMenu.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', 'random', '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', 'random', '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', 'random', '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', 'random', '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', 'random', '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__': if __name__ == '__main__':
main() main()

View File

@@ -57,8 +57,10 @@ class LttPCommandProcessor(ClientCommandProcessor):
class Context(CommonContext): class Context(CommonContext):
command_processor = LttPCommandProcessor command_processor = LttPCommandProcessor
def __init__(self, snes_address, server_address, password, found_items): game = "A Link to the Past"
super(Context, self).__init__(server_address, password, found_items)
def __init__(self, snes_address, server_address, password):
super(Context, self).__init__(server_address, password)
# snes stuff # snes stuff
self.snes_address = snes_address self.snes_address = snes_address
@@ -68,7 +70,6 @@ class Context(CommonContext):
self.snes_reconnect_address = None self.snes_reconnect_address = None
self.snes_recv_queue = asyncio.Queue() self.snes_recv_queue = asyncio.Queue()
self.snes_request_lock = asyncio.Lock() self.snes_request_lock = asyncio.Lock()
self.is_sd2snes = False
self.snes_write_buffer = [] self.snes_write_buffer = []
self.awaiting_rom = False self.awaiting_rom = False
@@ -97,7 +98,7 @@ class Context(CommonContext):
self.auth = self.rom self.auth = self.rom
auth = base64.b64encode(self.rom).decode() auth = base64.b64encode(self.rom).decode()
await self.send_msgs([{"cmd": 'Connect', 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), 'tags': get_tags(self),
'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past" 'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past"
}]) }])
@@ -138,8 +139,6 @@ SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte
SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes 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_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10), location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
@@ -410,26 +409,30 @@ class SNESState(enum.IntEnum):
SNES_ATTACHED = 3 SNES_ATTACHED = 3
def launch_qusb2snes(ctx: Context): def launch_sni(ctx: Context):
qusb2snes_path = Utils.get_options()["lttp_options"]["qusb2snes"] sni_path = Utils.get_options()["lttp_options"]["sni"]
if not os.path.isfile(qusb2snes_path): if not os.path.isdir(sni_path):
qusb2snes_path = Utils.local_path(qusb2snes_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): if os.path.isfile(sni_path):
logger.info(f"Attempting to start {qusb2snes_path}") logger.info(f"Attempting to start {sni_path}")
import subprocess import subprocess
subprocess.Popen(qusb2snes_path, cwd=os.path.dirname(qusb2snes_path)) subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else: else:
logger.info( logger.info(
f"Attempt to start (Q)Usb2Snes was aborted as path {qusb2snes_path} was not found, " f"Attempt to start SNI was aborted as path {sni_path} was not found, "
f"please start it yourself if it is not running") f"please start it yourself if it is not running")
async def _snes_connect(ctx: Context, address: str): async def _snes_connect(ctx: Context, address: str):
address = f"ws://{address}" if "://" not in address else address address = f"ws://{address}" if "://" not in address else address
logger.info("Connecting to QUsb2snes at %s ..." % address) logger.info("Connecting to SNI at %s ..." % address)
seen_problems = set() seen_problems = set()
succesful = False succesful = False
while not succesful: while not succesful:
@@ -441,11 +444,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 # only tell the user about new problems, otherwise silently lay in wait for a working connection
if problem not in seen_problems: if problem not in seen_problems:
seen_problems.add(problem) seen_problems.add(problem)
logger.error(f"Error connecting to QUsb2snes ({problem})") logger.error(f"Error connecting to SNI ({problem})")
if len(seen_problems) == 1: if len(seen_problems) == 1:
# this is the first problem. Let's try launching QUsb2snes if it isn't already running # this is the first problem. Let's try launching SNI if it isn't already running
launch_qusb2snes(ctx) launch_sni(ctx)
await asyncio.sleep(1) await asyncio.sleep(1)
else: else:
@@ -464,7 +467,7 @@ async def get_snes_devices(ctx: Context):
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
if not devices: if not devices:
logger.info('No SNES device found. Ensure QUsb2Snes is running and connect it to the multibridge.') logger.info('No SNES device found. Please connect a SNES device to SNI.')
while not devices: while not devices:
await asyncio.sleep(1) await asyncio.sleep(1)
await socket.send(dumps(DeviceList_Request)) await socket.send(dumps(DeviceList_Request))
@@ -512,17 +515,6 @@ async def snes_connect(ctx: Context, address):
await ctx.snes_socket.send(dumps(Attach_Request)) await ctx.snes_socket.send(dumps(Attach_Request))
ctx.snes_state = SNESState.SNES_ATTACHED ctx.snes_state = SNESState.SNES_ATTACHED
ctx.snes_attached_device = (devices.index(device), device) 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 ctx.snes_reconnect_address = address
recv_task = asyncio.create_task(snes_recv_loop(ctx)) recv_task = asyncio.create_task(snes_recv_loop(ctx))
SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay
@@ -616,8 +608,7 @@ async def snes_read(ctx: Context, address, size):
logger.error('Error reading %s, requested %d bytes, received %d' % (hex(address), size, len(data))) logger.error('Error reading %s, requested %d bytes, received %d' % (hex(address), size, len(data)))
if len(data): if len(data):
logger.error(str(data)) logger.error(str(data))
logger.warning('Unable to connect to SNES Device because QUsb2Snes broke temporarily.' logger.warning('Communication Failure with SNI')
'Try un-selecting and re-selecting the SNES Device.')
if ctx.snes_socket is not None and not ctx.snes_socket.closed: if ctx.snes_socket is not None and not ctx.snes_socket.closed:
await ctx.snes_socket.close() await ctx.snes_socket.close()
return None return None
@@ -636,45 +627,16 @@ async def snes_write(ctx: Context, write_list):
return False return False
PutAddress_Request = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} PutAddress_Request = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
try:
if ctx.is_sd2snes:
cmd = b'\x00\xE2\x20\x48\xEB\x48'
for address, data in write_list: for address, data in write_list:
if (address < WRAM_START) or ((address + len(data)) > (WRAM_START + WRAM_SIZE)): PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
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:
if ctx.snes_socket is not None: if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request)) await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(cmd) await ctx.snes_socket.send(data)
else: else:
logger.warning(f"Could not send data to SNES: {cmd}") logger.warning(f"Could not send data to SNES: {data}")
except websockets.ConnectionClosed: except websockets.ConnectionClosed:
return False 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
return True return True
finally: finally:
@@ -704,9 +666,6 @@ def get_tags(ctx: Context):
return tags return tags
async def track_locations(ctx: Context, roomid, roomdata): async def track_locations(ctx: Context, roomid, roomdata):
new_locations = [] new_locations = []
@@ -718,7 +677,7 @@ async def track_locations(ctx: Context, roomid, roomdata):
try: try:
if roomid in location_shop_ids: 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): for cnt, b in enumerate(misc_data):
if int(b) and (Shops.SHOP_ID_START + cnt) not in ctx.locations_checked: if int(b) and (Shops.SHOP_ID_START + cnt) not in ctx.locations_checked:
new_check(Shops.SHOP_ID_START + cnt) new_check(Shops.SHOP_ID_START + cnt)
@@ -857,10 +816,11 @@ async def game_watcher(ctx: Context):
if recv_index < len(ctx.items_received) and recv_item == 0: if recv_index < len(ctx.items_received) and recv_item == 0:
item = ctx.items_received[recv_index] item = ctx.items_received[recv_index]
recv_index += 1
logging.info('Received %s from %s (%s) (%d/%d in list)' % ( 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'), 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))) ctx.location_name_getter(item.location), recv_index, len(ctx.items_received)))
recv_index += 1
snes_buffered_write(ctx, RECV_PROGRESS_ADDR, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) 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_ADDR, bytes([item.item]))
snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR, bytes([item.player if item.player != ctx.slot else 0])) snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR, bytes([item.player if item.player != ctx.slot else 0]))
@@ -891,7 +851,7 @@ async def main():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('diff_file', default="", type=str, nargs="?", parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a Archipelago Binary Patch file') 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('--connect', default=None, help='Address of the multiworld host.')
parser.add_argument('--password', default=None, help='Password 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('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
@@ -914,14 +874,12 @@ async def main():
logging.exception(e) logging.exception(e)
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile)) asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
port = None ctx = Context(args.snes, args.connect, args.password)
ctx = Context(args.snes, args.connect, args.password, args.founditems)
input_task = asyncio.create_task(console_loop(ctx), name="Input") input_task = asyncio.create_task(console_loop(ctx), name="Input")
if ctx.server_task is None: if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
asyncio.create_task(snes_connect(ctx, ctx.snes_address))
watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher") watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher")
await ctx.exit_event.wait() await ctx.exit_event.wait()

621
Main.py
View File

@@ -1,4 +1,3 @@
import copy
from itertools import zip_longest from itertools import zip_longest
import logging import logging
import os import os
@@ -7,29 +6,21 @@ import time
import zlib import zlib
import concurrent.futures import concurrent.futures
import pickle import pickle
import tempfile
import zipfile
from typing import Dict, Tuple from typing import Dict, Tuple
from BaseClasses import MultiWorld, CollectionState, Region, Item from BaseClasses import MultiWorld, CollectionState, Region, Item
from worlds.alttp.Items import ItemFactory, item_name_groups from worlds.alttp.Items import item_name_groups
from worlds.alttp.Regions import create_regions, mark_light_world_regions, \ from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
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.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 fill_dungeons, fill_dungeons_restrictive
from worlds.alttp.Dungeons import create_dungeons, fill_dungeons, fill_dungeons_restrictive
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
from worlds.alttp.Shops import create_shops, ShopSlotFill, SHOP_ID_START, total_shop_slots, FillDisabledShopSlots from worlds.alttp.Shops import ShopSlotFill, SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from worlds.alttp.ItemPool import generate_itempool, difficulties, fill_prizes from worlds.alttp.ItemPool import difficulties, fill_prizes
from Utils import output_path, parse_player_names, get_options, __version__, _version_tuple from Utils import output_path, parse_player_names, get_options, __version__, version_tuple
from worlds.hk import gen_hollow from worlds.generic.Rules import locality_rules, exclusion_rules
from worlds.hk import create_regions as hk_create_regions from worlds import AutoWorld
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 import Patch
seeddigits = 20 seeddigits = 20
@@ -79,7 +70,7 @@ def main(args, seed=None):
world.progressive = args.progressive.copy() world.progressive = args.progressive.copy()
world.goal = args.goal.copy() world.goal = args.goal.copy()
world.local_items = args.local_items.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.algorithm = args.algorithm
world.shuffleganon = args.shuffleganon world.shuffleganon = args.shuffleganon
world.custom = args.custom world.custom = args.custom
@@ -94,12 +85,6 @@ def main(args, seed=None):
world.compassshuffle = args.compassshuffle.copy() world.compassshuffle = args.compassshuffle.copy()
world.keyshuffle = args.keyshuffle.copy() world.keyshuffle = args.keyshuffle.copy()
world.bigkeyshuffle = args.bigkeyshuffle.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.open_pyramid = args.open_pyramid.copy()
world.boss_shuffle = args.shufflebosses.copy() world.boss_shuffle = args.shufflebosses.copy()
world.enemy_shuffle = args.enemy_shuffle.copy() world.enemy_shuffle = args.enemy_shuffle.copy()
@@ -121,7 +106,6 @@ def main(args, seed=None):
world.triforce_pieces_available = args.triforce_pieces_available.copy() world.triforce_pieces_available = args.triforce_pieces_available.copy()
world.triforce_pieces_required = args.triforce_pieces_required.copy() world.triforce_pieces_required = args.triforce_pieces_required.copy()
world.shop_shuffle = args.shop_shuffle.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.progression_balancing = args.progression_balancing.copy()
world.shuffle_prizes = args.shuffle_prizes.copy() world.shuffle_prizes = args.shuffle_prizes.copy()
world.sprite_pool = args.sprite_pool.copy() world.sprite_pool = args.sprite_pool.copy()
@@ -133,18 +117,16 @@ def main(args, seed=None):
world.restrict_dungeon_item_on_boss = args.restrict_dungeon_item_on_boss.copy() world.restrict_dungeon_item_on_boss = args.restrict_dungeon_item_on_boss.copy()
world.required_medallions = args.required_medallions.copy() world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy() world.game = args.game.copy()
import Options world.set_options(args)
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.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option. world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
world.rom_seeds = {player: random.Random(world.random.randint(0, 999999999)) for player in range(1, world.players + 1)} world.slot_seeds = {player: random.Random(world.random.getrandbits(64)) for player in
range(1, world.players + 1)}
for player in range(1, world.players+1): AutoWorld.call_all(world, "generate_early")
# system for sharing ER layouts
for player in world.get_game_players("A Link to the Past"):
world.er_seeds[player] = str(world.random.randint(0, 2 ** 64)) world.er_seeds[player] = str(world.random.randint(0, 2 ** 64))
if "-" in world.shuffle[player]: if "-" in world.shuffle[player]:
@@ -153,8 +135,8 @@ def main(args, seed=None):
if shuffle == "vanilla": if shuffle == "vanilla":
world.er_seeds[player] = "vanilla" world.er_seeds[player] = "vanilla"
elif seed.startswith("group-") or args.race: 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, (
world.er_seeds[player] = get_same_seed(world, (shuffle, seed, world.retro[player], world.mode[player], world.logic[player])) shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
else: # not a race or group seed, use set seed as is. else: # not a race or group seed, use set seed as is.
world.er_seeds[player] = seed world.er_seeds[player] = seed
elif world.shuffle[player] == "vanilla": elif world.shuffle[player] == "vanilla":
@@ -162,6 +144,11 @@ def main(args, seed=None):
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed) logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
logger.info("Found World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} Items | {len(cls.location_names):3} Locations")
parsed_names = parse_player_names(args.names, world.players, args.teams) parsed_names = parse_player_names(args.names, world.players, args.teams)
world.teams = len(parsed_names) world.teams = len(parsed_names)
for i, team in enumerate(parsed_names, 1): for i, team in enumerate(parsed_names, 1):
@@ -171,111 +158,58 @@ def main(args, seed=None):
world.player_names[player].append(name) world.player_names[player].append(name)
logger.info('') logger.info('')
for player in world.alttp_player_ids: for player in world.get_game_players("A Link to the Past"):
world.difficulty_requirements[player] = difficulties[world.difficulty[player]] world.difficulty_requirements[player] = difficulties[world.difficulty[player]]
for player in world.player_ids: for player in world.player_ids:
for item_name in args.startinventory[player]: for item_name in args.startinventory[player]:
item = Item(item_name, True, lookup_any_item_name_to_id[item_name], player) world.push_precollected(world.create_item(item_name, player))
item.game = world.game[player]
world.push_precollected(item)
for player in world.player_ids: 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. # dungeon items can't be in non-local if the appropriate dungeon item shuffle setting is not set.
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]: if not world.mapshuffle[player]:
world.local_items[player].add('Triforce Piece') world.non_local_items[player] -= item_name_groups['Maps']
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']
# This could probably use a more elegant solution.
elif world.keyshuffle[player] == True and world.mode[player] == "Standard":
world.local_items[player].add("Small Key (Hyrule Castle)")
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']
# items can't be both local and non-local, prefer local # items can't be both local and non-local, prefer local
world.non_local_items[player] -= world.local_items[player] 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. logger.info('Creating World.')
if not world.mapshuffle[player]: AutoWorld.call_all(world, "create_regions")
world.non_local_items[player] -= item_name_groups['Maps']
if not world.compassshuffle[player]: logger.info('Creating Items.')
world.non_local_items[player] -= item_name_groups['Compasses'] AutoWorld.call_all(world, "create_items")
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('Calculating Access Rules.') logger.info('Calculating Access Rules.')
if world.players > 1: if world.players > 1:
for player in world.player_ids: for player in world.player_ids:
locality_rules(world, player) locality_rules(world, player)
for player in world.alttp_player_ids: AutoWorld.call_all(world, "set_rules")
set_rules(world, player)
for player in world.hk_player_ids: for player in world.player_ids:
gen_hollow(world, player) exclusion_rules(world, player, args.excluded_locations[player])
for player in world.factorio_player_ids: AutoWorld.call_all(world, "generate_basic")
gen_factorio(world, player)
for player in world.minecraft_player_ids:
gen_minecraft(world, player)
logger.info("Running Item Plando") logger.info("Running Item Plando")
@@ -319,7 +253,7 @@ def main(args, seed=None):
outfilebase = 'AP_' + world.seed_name outfilebase = 'AP_' + world.seed_name
rom_names = [] rom_names = []
def _gen_rom(team: int, player: int): def _gen_rom(team: int, player: int, output_directory:str):
use_enemizer = (world.boss_shuffle[player] != 'none' or world.enemy_shuffle[player] 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.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
or world.shufflepots[player] or world.bush_shuffle[player] or world.shufflepots[player] or world.bush_shuffle[player]
@@ -330,20 +264,20 @@ def main(args, seed=None):
patch_rom(world, rom, player, team, use_enemizer) patch_rom(world, rom, player, team, use_enemizer)
if use_enemizer: if use_enemizer:
patch_enemizer(world, team, player, rom, args.enemizercli) patch_enemizer(world, team, player, rom, args.enemizercli, output_directory)
if args.race: if args.race:
patch_race_rom(rom, world, player) patch_race_rom(rom, world, player)
world.spoiler.hashes[(player, team)] = get_hash_string(rom.hash) world.spoiler.hashes[(player, team)] = get_hash_string(rom.hash)
palettes_options={} palettes_options = {}
palettes_options['dungeon']=args.uw_palettes[player] palettes_options['dungeon'] = args.uw_palettes[player]
palettes_options['overworld']=args.ow_palettes[player] palettes_options['overworld'] = args.ow_palettes[player]
palettes_options['hud']=args.hud_palettes[player] palettes_options['hud'] = args.hud_palettes[player]
palettes_options['sword']=args.sword_palettes[player] palettes_options['sword'] = args.sword_palettes[player]
palettes_options['shield']=args.shield_palettes[player] palettes_options['shield'] = args.shield_palettes[player]
palettes_options['link']=args.link_palettes[player] palettes_options['link'] = args.link_palettes[player]
apply_rom_settings(rom, args.heartbeep[player], args.heartcolor[player], args.quickswap[player], apply_rom_settings(rom, args.heartbeep[player], args.heartcolor[player], args.quickswap[player],
args.fastmenu[player], args.disablemusic[player], args.sprite[player], args.fastmenu[player], args.disablemusic[player], args.sprite[player],
@@ -359,8 +293,8 @@ def main(args, seed=None):
world.bigkeyshuffle[player]].count(True) == 1: world.bigkeyshuffle[player]].count(True) == 1:
mcsb_name = '-mapshuffle' if world.mapshuffle[player] else \ mcsb_name = '-mapshuffle' if world.mapshuffle[player] else \
'-compassshuffle' if world.compassshuffle[player] else \ '-compassshuffle' if world.compassshuffle[player] else \
'-universal_keys' if world.keyshuffle[player] == "universal" else \ '-universal_keys' if world.keyshuffle[player] == "universal" else \
'-keyshuffle' if world.keyshuffle[player] else '-bigkeyshuffle' '-keyshuffle' if world.keyshuffle[player] else '-bigkeyshuffle'
elif any([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player], elif any([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
world.bigkeyshuffle[player]]): world.bigkeyshuffle[player]]):
mcsb_name = '-%s%s%s%sshuffle' % ( mcsb_name = '-%s%s%s%sshuffle' % (
@@ -372,222 +306,221 @@ def main(args, seed=None):
outfilepname += f"_{world.player_names[player][team].replace(' ', '_')}" \ outfilepname += f"_{world.player_names[player][team].replace(' ', '_')}" \
if world.player_names[player][team] != 'Player%d' % player else '' if world.player_names[player][team] != 'Player%d' % player else ''
outfilestuffs = { outfilestuffs = {
"logic": world.logic[player], # 0 "logic": world.logic[player], # 0
"difficulty": world.difficulty[player], # 1 "difficulty": world.difficulty[player], # 1
"item_functionality": world.item_functionality[player], # 2 "item_functionality": world.item_functionality[player], # 2
"mode": world.mode[player], # 3 "mode": world.mode[player], # 3
"goal": world.goal[player], # 4 "goal": world.goal[player], # 4
"timer": str(world.timer[player]), # 5 "timer": str(world.timer[player]), # 5
"shuffle": world.shuffle[player], # 6 "shuffle": world.shuffle[player], # 6
"algorithm": world.algorithm, # 7 "algorithm": world.algorithm, # 7
"mscb": mcsb_name, # 8 "mscb": mcsb_name, # 8
"retro": world.retro[player], # 9 "retro": world.retro[player], # 9
"progressive": world.progressive, # A "progressive": world.progressive, # A
"hints": 'True' if world.hints[player] else 'False' # B "hints": 'True' if world.hints[player] else 'False' # B
} }
# 0 1 2 3 4 5 6 7 8 9 A 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' % ( 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 # 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-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-retro
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -prog_random # _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -prog_random
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -nohints # _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -nohints
outfilestuffs["logic"], # 0 outfilestuffs["logic"], # 0
outfilestuffs["difficulty"], # 1 outfilestuffs["difficulty"], # 1
outfilestuffs["item_functionality"], # 2 outfilestuffs["item_functionality"], # 2
outfilestuffs["mode"], # 3 outfilestuffs["mode"], # 3
outfilestuffs["goal"], # 4 outfilestuffs["goal"], # 4
"" if outfilestuffs["timer"] in ['False', 'none', 'display'] else "-" + outfilestuffs["timer"], # 5 "" if outfilestuffs["timer"] in ['False', 'none', 'display'] else "-" + outfilestuffs["timer"], # 5
outfilestuffs["shuffle"], # 6 outfilestuffs["shuffle"], # 6
outfilestuffs["algorithm"], # 7 outfilestuffs["algorithm"], # 7
outfilestuffs["mscb"], # 8 outfilestuffs["mscb"], # 8
"-retro" if outfilestuffs["retro"] == "True" else "", # 9 "-retro" if outfilestuffs["retro"] == "True" else "", # 9
"-prog_" + outfilestuffs["progressive"] if outfilestuffs["progressive"] in ['off', 'random'] else "", # A "-prog_" + outfilestuffs["progressive"] if outfilestuffs["progressive"] in ['off', 'random'] else "", # A
"-nohints" if not outfilestuffs["hints"] == "True" else "") # B "-nohints" if not outfilestuffs["hints"] == "True" else "") # B
) if not args.outputname else '' ) if not args.outputname else ''
rompath = output_path(f'{outfilebase}{outfilepname}{outfilesuffix}.sfc') rompath = os.path.join(output_directory, f'{outfilebase}{outfilepname}{outfilesuffix}.sfc')
rom.write_to_file(rompath, hide_enemizer=True) 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])
Patch.create_patch_file(rompath, player=player, player_name = world.player_names[player][team]) os.unlink(rompath)
return player, team, bytes(rom.name) return player, team, bytes(rom.name)
pool = concurrent.futures.ThreadPoolExecutor() pool = concurrent.futures.ThreadPoolExecutor()
check_accessibility_task = pool.submit(world.fulfills_accessibility) output = tempfile.TemporaryDirectory()
with output as temp_dir:
check_accessibility_task = pool.submit(world.fulfills_accessibility)
rom_futures = []
output_file_futures = []
for team in range(world.teams):
for player in world.get_game_players("A Link to the Past"):
rom_futures.append(pool.submit(_gen_rom, team, player, temp_dir))
for player in world.player_ids:
output_file_futures.append(pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir))
rom_futures = [] def get_entrance_to_region(region: Region):
mod_futures = [] for entrance in region.entrances:
for team in range(world.teams): if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
for player in world.alttp_player_ids: return entrance
rom_futures.append(pool.submit(_gen_rom, team, player)) for entrance in region.entrances: # BFS might be better here, trying DFS for now.
for player in world.factorio_player_ids: return get_entrance_to_region(entrance.parent_region)
mod_futures.append(pool.submit(generate_mod, world, player))
def get_entrance_to_region(region: Region): # collect ER hint info
for entrance in region.entrances: er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld): world.shuffle[player] != "vanilla" or world.retro[player]}
return entrance from worlds.alttp.Regions import RegionType
for entrance in region.entrances: # BFS might be better here, trying DFS for now. for region in world.regions:
return get_entrance_to_region(entrance.parent_region) 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
# collect ER hint info ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
er_hint_data = {player: {} for player in range(1, world.players + 1) if world.shuffle[player] != "vanilla" or world.retro[player]} 'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
from worlds.alttp.Regions import RegionType 'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
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
ordered_areas = ('Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace', checks_in_area = {player: {area: list() for area in ordered_areas}
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace', for player in range(1, world.players + 1)}
'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total")
checks_in_area = {player: {area: list() for area in ordered_areas} for player in range(1, world.players + 1):
for player in range(1, world.players + 1)} checks_in_area[player]["Total"] = 0
for player in range(1, world.players + 1): for location in [loc for loc in world.get_filled_locations() if type(loc.address) is int]:
checks_in_area[player]["Total"] = 0 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
for location in [loc for loc in world.get_filled_locations() if type(loc.address) is int]: oldmancaves = []
main_entrance = get_entrance_to_region(location.parent_region) takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
if location.game != Games.LTTP: for index, take_any in enumerate(takeanyregions):
checks_in_area[location.player]["Light World"].append(location.address) for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if
elif location.parent_region.dungeon: world.retro[player]]:
dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower', item = world.create_item(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
'Inverted Ganons Tower': 'Ganons Tower'}\ region.player)
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name) player = region.player
checks_in_area[location.player][dungeonname].append(location.address) location_id = SHOP_ID_START + total_shop_slots + index
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
oldmancaves = [] main_entrance = get_entrance_to_region(region)
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"] if main_entrance.parent_region.type == RegionType.LightWorld:
for index, take_any in enumerate(takeanyregions): checks_in_area[player]["Light World"].append(location_id)
for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if world.retro[player]]: else:
item = ItemFactory(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'], region.player) checks_in_area[player]["Dark World"].append(location_id)
player = region.player checks_in_area[player]["Total"] += 1
location_id = SHOP_ID_START + total_shop_slots + index
main_entrance = get_entrance_to_region(region) er_hint_data[player][location_id] = main_entrance.name
if main_entrance.parent_region.type == RegionType.LightWorld: oldmancaves.append(((location_id, player), (item.code, player)))
checks_in_area[player]["Light World"].append(location_id)
FillDisabledShopSlots(world)
def write_multidata(roms, outputs):
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] = world.worlds[slot].get_required_client_version()
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 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)
for i, team in enumerate(parsed_names):
for player, name in enumerate(team, 1):
if player not in world.get_game_players("A Link to the Past"):
connect_names[name] = (i, player)
for slot in world.player_ids:
slot_data[slot] = world.worlds[slot].fill_slot_data()
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 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
}), 9)
with open(os.path.join(temp_dir, '%s.archipelago' % outfilebase), 'wb') as f:
f.write(bytes([1])) # version of format
f.write(multidata)
for future in outputs:
future.result() # collect errors if they occured
multidata_task = pool.submit(write_multidata, rom_futures, output_file_futures)
if not check_accessibility_task.result():
if not world.can_beat_game():
raise Exception("Game appears as unbeatable. Aborting.")
else: else:
checks_in_area[player]["Dark World"].append(location_id) logger.warning("Location Accessibility requirements not fulfilled.")
checks_in_area[player]["Total"] += 1 if multidata_task:
multidata_task.result() # retrieve exception if one exists
er_hint_data[player][location_id] = main_entrance.name pool.shutdown() # wait for all queued tasks to complete
oldmancaves.append(((location_id, player), (item.code, 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
FillDisabledShopSlots(world) world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
zipfilename = output_path(f"AP_{world.seed_name}.zip")
def write_multidata(roms, mods): logger.info(f'Creating final archive at {zipfilename}.')
import base64 with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
import NetUtils compresslevel=9) as zf:
for future in roms: for file in os.scandir(temp_dir):
rom_name = future.result() zf.write(os.path.join(temp_dir, file), arcname=file.name)
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:
if world.game[slot] == "Factorio":
client_versions[slot] = (0, 1, 2)
else:
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))
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start) logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
return world return world
@@ -605,7 +538,8 @@ def create_playthrough(world):
while sphere_candidates: while sphere_candidates:
state.sweep_for_events(key_only=True) 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)} sphere = {location for location in sphere_candidates if state.can_reach(location)}
@@ -636,7 +570,8 @@ def create_playthrough(world):
to_delete = set() to_delete = set()
for location in sphere: for location in sphere:
# we remove the item at location and check if game is still beatable # 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 old_item = location.item
location.item = None location.item = None
if world.can_beat_game(state_cache[num]): if world.can_beat_game(state_cache[num]):
@@ -681,7 +616,8 @@ def create_playthrough(world):
collection_spheres.append(sphere) 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: if not sphere:
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}') raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
@@ -698,16 +634,25 @@ def create_playthrough(world):
pathpairs = zip_longest(pathsiter, pathsiter) pathpairs = zip_longest(pathsiter, pathsiter)
return list(pathpairs) return list(pathpairs)
world.spoiler.paths = dict() world.spoiler.paths = {}
for player in range(1, world.players + 1): topology_worlds = (player for player in world.player_ids if world.worlds[player].topology_present)
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}) for player in topology_worlds:
if player in world.alttp_player_ids: 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"):
for path in dict(world.spoiler.paths).values(): for path in dict(world.spoiler.paths).values():
if any(exit == 'Pyramid Fairy' for (_, exit) in path): if any(exit == 'Pyramid Fairy' for (_, exit) in path):
if world.mode[player] != 'inverted': 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)) world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = get_path(state,
world.get_region(
'Big Bomb Shop',
player))
else: 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[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 # we can finally output our playthrough
world.spoiler.playthrough = {"0": sorted([str(item) for item in world.precollected_items if item.advancement])} world.spoiler.playthrough = {"0": sorted([str(item) for item in world.precollected_items if item.advancement])}

View File

@@ -1,222 +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
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 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:
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,15 @@ import prompt_toolkit
from prompt_toolkit.patch_stdout import patch_stdout from prompt_toolkit.patch_stdout import patch_stdout
from fuzzywuzzy import process as fuzzy_process from fuzzywuzzy import process as fuzzy_process
from worlds.alttp import Items, Regions from worlds.AutoWorld import AutoWorldRegister
from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_item_name_to_id, \ proxy_worlds = {name: world(None, 0) for name, world in AutoWorldRegister.world_types.items()}
lookup_any_location_id_to_name, lookup_any_location_name_to_id from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_location_id_to_name
import Utils import Utils
from Utils import get_item_name_from_id, get_location_name_from_id, \ from Utils import get_item_name_from_id, get_location_name_from_id, \
_version_tuple, restricted_loads, Version version_tuple, restricted_loads, Version
from NetUtils import Node, Endpoint, ClientStatus, NetworkItem, decode, NetworkPlayer from NetUtils import Node, Endpoint, ClientStatus, NetworkItem, decode, NetworkPlayer
colorama.init() 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): class Client(Endpoint):
version = Version(0, 0, 0) version = Version(0, 0, 0)
@@ -54,6 +50,7 @@ class Client(Endpoint):
self.messageprocessor = client_message_processor(ctx, self) self.messageprocessor = client_message_processor(ctx, self)
self.ctx = weakref.ref(ctx) self.ctx = weakref.ref(ctx)
team_slot = typing.Tuple[int, int]
class Context(Node): class Context(Node):
simple_options = {"hint_cost": int, simple_options = {"hint_cost": int,
@@ -74,11 +71,12 @@ class Context(Node):
self.data_filename = None self.data_filename = None
self.save_filename = None self.save_filename = None
self.saving = False self.saving = False
self.player_names = {} self.player_names: typing.Dict[team_slot, str] = {}
self.connect_names = {} # names of slots clients can connect to self.player_name_lookup: typing.Dict[str, team_slot] = {}
self.connect_names = {} # names of slots clients can connect to
self.allow_forfeits = {} self.allow_forfeits = {}
self.remote_items = set() 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.host = host
self.port = port self.port = port
self.server_password = server_password self.server_password = server_password
@@ -86,21 +84,21 @@ class Context(Node):
self.server = None self.server = None
self.countdown_timer = 0 self.countdown_timer = 0
self.received_items = {} 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.location_checks = collections.defaultdict(set)
self.hint_cost = hint_cost self.hint_cost = hint_cost
self.location_check_points = location_check_points self.location_check_points = location_check_points
self.hints_used = collections.defaultdict(int) 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.forfeit_mode: str = forfeit_mode
self.remaining_mode: str = remaining_mode self.remaining_mode: str = remaining_mode
self.item_cheat = item_cheat self.item_cheat = item_cheat
self.running = True self.running = True
self.client_activity_timers: typing.Dict[ 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[ self.client_connection_timers: typing.Dict[
typing.Tuple[int, int], datetime.datetime] = {} # datetime of last connection team_slot, datetime.datetime] = {} # datetime of last connection
self.client_game_state: typing.Dict[typing.Tuple[int, int], int] = collections.defaultdict(int) self.client_game_state: typing.Dict[team_slot, int] = collections.defaultdict(int)
self.er_hint_data: typing.Dict[int, typing.Dict[int, str]] = {} self.er_hint_data: typing.Dict[int, typing.Dict[int, str]] = {}
self.auto_shutdown = auto_shutdown self.auto_shutdown = auto_shutdown
self.commandprocessor = ServerCommandProcessor(self) self.commandprocessor = ServerCommandProcessor(self)
@@ -110,7 +108,7 @@ class Context(Node):
self.auto_saver_thread = None self.auto_saver_thread = None
self.save_dirty = False self.save_dirty = False
self.tags = ['AP'] self.tags = ['AP']
self.games = {} self.games: typing.Dict[int, str] = {}
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {} self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
self.seed_name = "" self.seed_name = ""
@@ -136,9 +134,9 @@ class Context(Node):
def _load(self, decoded_obj: dict, use_embedded_server_options: bool): def _load(self, decoded_obj: dict, use_embedded_server_options: bool):
mdata_ver = decoded_obj["minimum_versions"]["server"] 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}," 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", {}) clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {} self.minimum_client_versions = {}
for player, version in clients_ver.items(): for player, version in clients_ver.items():
@@ -146,7 +144,8 @@ class Context(Node):
for team, names in enumerate(decoded_obj['names']): for team, names in enumerate(decoded_obj['names']):
for player, name in enumerate(names, 1): 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.seed_name = decoded_obj["seed_name"]
self.connect_names = decoded_obj['connect_names'] self.connect_names = decoded_obj['connect_names']
self.remote_items = decoded_obj['remote_items'] self.remote_items = decoded_obj['remote_items']
@@ -166,7 +165,6 @@ class Context(Node):
server_options = decoded_obj.get("server_options", {}) server_options = decoded_obj.get("server_options", {})
self._set_options(server_options) self._set_options(server_options)
def get_players_package(self): 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()] return [NetworkPlayer(t, p, self.get_aliased_name(t, p), n) for (t, p), n in self.player_names.items()]
@@ -174,7 +172,7 @@ class Context(Node):
for key, value in server_options.items(): for key, value in server_options.items():
data_type = self.simple_options.get(key, None) data_type = self.simple_options.get(key, None)
if data_type is not None: if data_type is not None:
if value not in {False, True, None}: # some can be boolean OR text, such as password if value not in {False, True, None}: # some can be boolean OR text, such as password
try: try:
value = data_type(value) value = data_type(value)
except Exception as e: except Exception as e:
@@ -200,7 +198,7 @@ class Context(Node):
return False return False
def _save(self, exit_save:bool=False) -> bool: def _save(self, exit_save: bool = False) -> bool:
try: try:
encoded_save = pickle.dumps(self.get_save()) encoded_save = pickle.dumps(self.get_save())
with open(self.save_filename, "wb") as f: with open(self.save_filename, "wb") as f:
@@ -244,7 +242,15 @@ class Context(Node):
import atexit import atexit
atexit.register(self._save, True) # make sure we save on exit too atexit.register(self._save, True) # make sure we save on exit too
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_save(self) -> dict: def get_save(self) -> dict:
self.recheck_hints()
d = { d = {
"connect_names": self.connect_names, "connect_names": self.connect_names,
"received_items": self.received_items, "received_items": self.received_items,
@@ -366,7 +372,8 @@ async def server(websocket, path, ctx: Context):
if not isinstance(e, websockets.WebSocketException): if not isinstance(e, websockets.WebSocketException):
logging.exception(e) logging.exception(e)
finally: finally:
logging.info("Disconnected") if ctx.log_network:
logging.info("Disconnected")
await ctx.disconnect(client) await ctx.disconnect(client)
@@ -374,17 +381,21 @@ async def on_client_connected(ctx: Context, client: Client):
await ctx.send_msgs(client, [{ await ctx.send_msgs(client, [{
'cmd': 'RoomInfo', 'cmd': 'RoomInfo',
'password': ctx.password is not None, '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 'players': [
in ctx.endpoints if client.auth], 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. # tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate. # Name them by feature or fork, as you feel is appropriate.
'tags': ctx.tags, 'tags': ctx.tags,
'version': Utils._version_tuple, 'version': Utils.version_tuple,
'forfeit_mode': ctx.forfeit_mode, 'forfeit_mode': ctx.forfeit_mode,
'remaining_mode': ctx.remaining_mode, 'remaining_mode': ctx.remaining_mode,
'hint_cost': ctx.hint_cost, 'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points, 'location_check_points': ctx.location_check_points,
'datapackage_version': network_data_package["version"], 'datapackage_version': 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 'seed_name': ctx.seed_name
}]) }])
@@ -403,9 +414,11 @@ async def on_client_joined(ctx: Context, client: Client):
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
async def on_client_left(ctx: Context, client: Client): async def on_client_left(ctx: Context, client: Client):
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN) 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) ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
@@ -447,7 +460,7 @@ def get_received_items(ctx: Context, team: int, player: int) -> typing.List[Netw
def send_new_items(ctx: Context): def send_new_items(ctx: Context):
for client in ctx.endpoints: 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) items = get_received_items(ctx, client.team, client.slot)
if len(items) > client.send_index: if len(items) > client.send_index:
asyncio.create_task(ctx.send_msgs(client, [{ asyncio.create_task(ctx.send_msgs(client, [{
@@ -504,10 +517,9 @@ def notify_team(ctx: Context, team: int, text: str):
ctx.broadcast_team(team, [['Print', {"text": text}]]) ctx.broadcast_team(team, [['Print', {"text": text}]])
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]: def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
hints = [] 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 finding_player, check_data in ctx.locations.items():
for location_id, result in check_data.items(): for location_id, result in check_data.items():
item_id, receiving_player = result item_id, receiving_player = result
@@ -520,8 +532,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]: def collect_hints_location(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
seeked_location: int = proxy_worlds[ctx.games[slot]].location_name_to_id[location]
seeked_location: int = Regions.lookup_name_to_id[location]
item_id, receiving_player = ctx.locations[slot].get(seeked_location, (None, None)) item_id, receiving_player = ctx.locations[slot].get(seeked_location, (None, None))
if item_id: if item_id:
found = seeked_location in ctx.location_checks[team, slot] found = seeked_location in ctx.location_checks[team, slot]
@@ -540,6 +551,7 @@ def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
text += f" at {hint.entrance}" text += f" at {hint.entrance}"
return text + (". (found)" if hint.found else ".") return text + (". (found)" if hint.found else ".")
def json_format_send_event(net_item: NetworkItem, receiving_player: int): def json_format_send_event(net_item: NetworkItem, receiving_player: int):
parts = [] parts = []
NetUtils.add_json_text(parts, net_item.player, type=NetUtils.JSONTypes.player_id) NetUtils.add_json_text(parts, net_item.player, type=NetUtils.JSONTypes.player_id)
@@ -557,9 +569,11 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
NetUtils.add_json_text(parts, ")") NetUtils.add_json_text(parts, ")")
return {"cmd": "PrintJSON", "data": parts, "type": "ItemSend", 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) picks = fuzzy_process.extract(input_text, possible_answers, limit=2)
if len(picks) > 1: if len(picks) > 1:
dif = picks[0][1] - picks[1][1] dif = picks[0][1] - picks[1][1]
@@ -684,11 +698,12 @@ class CommonCommandProcessor(CommandProcessor):
"""List all current options. Warning: lists password.""" """List all current options. Warning: lists password."""
self.output("Current options:") self.output("Current options:")
for option in self.ctx.simple_options: for option in self.ctx.simple_options:
if option == "server_password" and self.marker == "!": #Do not display the server password to the client. 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))}") self.output(f"Option server_password is set to {('*' * random.randint(4, 16))}")
else: else:
self.output(f"Option {option} is set to {getattr(self.ctx, option)}") self.output(f"Option {option} is set to {getattr(self.ctx, option)}")
class ClientMessageProcessor(CommonCommandProcessor): class ClientMessageProcessor(CommonCommandProcessor):
marker = "!" marker = "!"
@@ -715,11 +730,14 @@ class ClientMessageProcessor(CommonCommandProcessor):
"""Allow remote administration of the multiworld server""" """Allow remote administration of the multiworld server"""
output = f"!admin {command}" 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))}" 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))}" 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: if not self.ctx.server_password:
self.output("Sorry, Remote administration is disabled") self.output("Sorry, Remote administration is disabled")
@@ -727,7 +745,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
if not command: if not command:
if self.is_authenticated(): 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: else:
self.output("Usage: !admin login [password]") self.output("Usage: !admin login [password]")
return True return True
@@ -810,7 +829,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
"Sorry, !remaining requires you to have beaten the game on this server") "Sorry, !remaining requires you to have beaten the game on this server")
return False return False
def _cmd_missing(self) -> bool: def _cmd_missing(self) -> bool:
"""List all missing location checks from the server's perspective""" """List all missing location checks from the server's perspective"""
@@ -846,11 +864,15 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_getitem(self, item_name: str) -> bool: def _cmd_getitem(self, item_name: str) -> bool:
"""Cheat in an item, if it is enabled on this server""" """Cheat in an item, if it is enabled on this server"""
if self.ctx.item_cheat: if self.ctx.item_cheat:
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: 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) 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) send_new_items(self.ctx)
return True return True
else: else:
@@ -873,16 +895,17 @@ class ClientMessageProcessor(CommonCommandProcessor):
notify_hints(self.ctx, self.client.team, list(hints)) notify_hints(self.ctx, self.client.team, list(hints))
return True return True
else: 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 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.") self.output(f"Sorry, \"{item_name}\" is marked as non-hintable.")
hints = [] hints = []
elif item_name in Items.item_name_groups: elif item_name in world.item_name_groups:
hints = [] 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)) 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) hints = collect_hints(self.ctx, self.client.team, self.client.slot, item_name)
else: # location name else: # location name
hints = collect_hints_location(self.ctx, self.client.team, self.client.slot, item_name) hints = collect_hints_location(self.ctx, self.client.team, self.client.slot, item_name)
@@ -959,24 +982,22 @@ def get_client_points(ctx: Context, client: Client) -> int:
async def process_client_cmd(ctx: Context, client: Client, args: dict): async def process_client_cmd(ctx: Context, client: Client, args: dict):
try: try:
cmd:str = args["cmd"] cmd: str = args["cmd"]
except: except:
logging.exception(f"Could not get command from {args}") 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 raise
if type(cmd) is not str: if type(cmd) is not str:
await ctx.send_msgs(client, [{"cmd": "InvalidCmd", "text": f"Command should be str, got {type(cmd)}"}]) await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
return "text": f"Command should be str, got {type(cmd)}"}])
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}'}])
return return
if cmd == 'Connect': if cmd == 'Connect':
if not args or 'password' not in args or type(args['password']) not in [str, type(None)] or \ if not args or 'password' not in args or type(args['password']) not in [str, type(None)] or \
'game' not in args: '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 return
errors = set() errors = set()
@@ -996,6 +1017,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if clients: if clients:
# likely same player with a "ghosted" slot. We bust the ghost. # likely same player with a "ghosted" slot. We bust the ghost.
if "uuid" in args and ctx.client_ids[team, slot] == args["uuid"]: 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 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.name = ctx.player_names[(team, slot)]
client.team = team client.team = team
@@ -1011,10 +1034,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
errors.add('IncompatibleVersion') errors.add('IncompatibleVersion')
# only exact version match allowed # 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') errors.add('IncompatibleVersion')
if errors: 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)}]) await ctx.send_msgs(client, [{"cmd": "ConnectionRefused", "errors": list(errors)}])
else: else:
ctx.client_ids[client.team, client.slot] = args["uuid"] ctx.client_ids[client.team, client.slot] = args["uuid"]
@@ -1027,6 +1050,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
"players": ctx.get_players_package(), "players": ctx.get_players_package(),
"missing_locations": get_missing_checks(ctx, client), "missing_locations": get_missing_checks(ctx, client),
"checked_locations": get_checked_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, {}) "slot_data": ctx.slot_data.get(client.slot, {})
}] }]
items = get_received_items(ctx, client.team, client.slot) items = get_received_items(ctx, client.team, client.slot)
@@ -1038,14 +1062,23 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
await on_client_joined(ctx, client) await on_client_joined(ctx, client)
elif cmd == "GetDataPackage": elif cmd == "GetDataPackage":
await ctx.send_msgs(client, [{"cmd": "DataPackage", exclusions = set(args.get("exclusions", []))
"data": network_data_package}]) 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: elif client.auth:
if cmd == 'Sync': if cmd == 'Sync':
items = get_received_items(ctx, client.team, client.slot) items = get_received_items(ctx, client.team, client.slot)
if items: if items:
client.send_index = len(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}]) "items": items}])
elif cmd == 'LocationChecks': elif cmd == 'LocationChecks':
@@ -1055,7 +1088,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
locs = [] locs = []
for location in args["locations"]: for location in args["locations"]:
if type(location) is not int or location not in lookup_any_location_id_to_name: if type(location) is not int 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 return
target_item, target_player = ctx.locations[client.slot][location] target_item, target_player = ctx.locations[client.slot][location]
locs.append(NetworkItem(target_item, location, target_player)) locs.append(NetworkItem(target_item, location, target_player))
@@ -1067,11 +1100,12 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if cmd == 'Say': if cmd == 'Say':
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable(): 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 return
client.messageprocessor(args["text"]) client.messageprocessor(args["text"])
def update_client_status(ctx: Context, client: Client, new_status: ClientStatus): def update_client_status(ctx: Context, client: Client, new_status: ClientStatus):
current = ctx.client_game_state[client.team, client.slot] current = ctx.client_game_state[client.team, client.slot]
if current != ClientStatus.CLIENT_GOAL: # can't undo goal completion if current != ClientStatus.CLIENT_GOAL: # can't undo goal completion
@@ -1083,6 +1117,7 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
ctx.client_game_state[client.team, client.slot] = new_status ctx.client_game_state[client.team, client.slot] = new_status
class ServerCommandProcessor(CommonCommandProcessor): class ServerCommandProcessor(CommonCommandProcessor):
def __init__(self, ctx: Context): def __init__(self, ctx: Context):
self.ctx = ctx self.ctx = ctx
@@ -1190,7 +1225,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
for (team, slot), name in self.ctx.player_names.items(): for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player: if name.lower() == seeked_player:
self.ctx.allow_forfeits[(team, slot)] = False 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 return True
self.output(f"Could not find player {player_name} to forbid the !forfeit command for.") self.output(f"Could not find player {player_name} to forbid the !forfeit command for.")
@@ -1200,17 +1236,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
"""Sends an item to the specified player""" """Sends an item to the specified player"""
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values()) seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable: if usable:
team, slot = self.ctx.player_name_lookup[seeked_player]
item = " ".join(item_name) 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: if usable:
for client in self.ctx.endpoints: new_item = NetworkItem(world.item_name_to_id[item], -1, 0)
if client.name == seeked_player: get_received_items(self.ctx, team, slot).append(new_item)
new_item = NetworkItem(lookup_any_item_name_to_id[item], -1, 0) self.ctx.notify_all('Cheat console: sending "' + item + '" to ' +
get_received_items(self.ctx, client.team, client.slot).append(new_item) self.ctx.get_aliased_name(team, slot))
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' + send_new_items(self.ctx)
self.ctx.get_aliased_name(client.team, client.slot)) return True
send_new_items(self.ctx)
return True
else: else:
self.output(response) self.output(response)
return False return False
@@ -1222,27 +1258,27 @@ class ServerCommandProcessor(CommonCommandProcessor):
"""Send out a hint for a player's item or location to their team""" """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()) seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable: if usable:
for (team, slot), name in self.ctx.player_names.items(): team, slot = self.ctx.player_name_lookup[seeked_player]
if name == seeked_player: item = " ".join(item_or_location)
item = " ".join(item_or_location) world = proxy_worlds[self.ctx.games[slot]]
item, usable, response = get_intended_text(item) item, usable, response = get_intended_text(item, world.all_names)
if usable: if usable:
if item in Items.item_name_groups: if item in world.item_name_groups:
hints = [] hints = []
for item in Items.item_name_groups[item]: for item in world.item_name_groups[item]:
hints.extend(collect_hints(self.ctx, team, slot, item)) hints.extend(collect_hints(self.ctx, team, slot, item))
elif item in all_items: # item name elif item in world.item_names: # item name
hints = collect_hints(self.ctx, team, slot, item) hints = collect_hints(self.ctx, team, slot, item)
else: # location name else: # location name
hints = collect_hints_location(self.ctx, team, slot, item) hints = collect_hints_location(self.ctx, team, slot, item)
if hints: if hints:
notify_hints(self.ctx, team, hints) notify_hints(self.ctx, team, hints)
else: else:
self.output("No hints found.") self.output("No hints found.")
return True return True
else: else:
self.output(response) self.output(response)
return False return False
else: else:
self.output(response) self.output(response)
@@ -1270,6 +1306,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
f"{', '.join(known)}") f"{', '.join(known)}")
return False return False
async def console(ctx: Context): async def console(ctx: Context):
session = prompt_toolkit.PromptSession() session = prompt_toolkit.PromptSession()
while ctx.running: while ctx.running:
@@ -1285,11 +1322,11 @@ async def console(ctx: Context):
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
defaults = Utils.get_options()["server_options"] defaults = Utils.get_options()["server_options"]
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
parser.add_argument('--host', default=defaults["host"]) parser.add_argument('--host', default=defaults["host"])
parser.add_argument('--port', default=defaults["port"], type=int) parser.add_argument('--port', default=defaults["port"], type=int)
parser.add_argument('--server_password', default=defaults["server_password"]) parser.add_argument('--server_password', default=defaults["server_password"])
parser.add_argument('--password', default=defaults["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('--savefile', default=defaults["savefile"])
parser.add_argument('--disable_save', default=defaults["disable_save"], action='store_true') parser.add_argument('--disable_save', default=defaults["disable_save"], action='store_true')
parser.add_argument('--loglevel', default=defaults["loglevel"], parser.add_argument('--loglevel', default=defaults["loglevel"],
@@ -1356,7 +1393,7 @@ async def auto_shutdown(ctx, to_cancel=None):
async def main(args: argparse.Namespace): 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)) 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, ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
@@ -1371,7 +1408,20 @@ async def main(args: argparse.Namespace):
import tkinter.filedialog import tkinter.filedialog
root = tkinter.Tk() root = tkinter.Tk()
root.withdraw() root.withdraw()
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago"),)) data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago *.zip"),))
if data_filename.endswith(".zip"):
import zipfile
with zipfile.ZipFile(data_filename) as zf:
for file in zf.namelist():
if file.endswith(".archipelago"):
import os
data_filename = os.path.join(os.path.dirname(data_filename), file)
with open(data_filename, "wb") as f:
f.write(zf.read(file))
break
else:
raise Exception("No .archipelago found in archive.")
ctx.load(data_filename, args.use_embedded_options) ctx.load(data_filename, args.use_embedded_options)

View File

@@ -109,9 +109,9 @@ class Node:
for endpoint in self.endpoints: for endpoint in self.endpoints:
asyncio.create_task(self.send_encoded_msgs(endpoint, msgs)) asyncio.create_task(self.send_encoded_msgs(endpoint, msgs))
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]): async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed: if not endpoint.socket or not endpoint.socket.open:
return return False
msg = self.dumper(msgs) msg = self.dumper(msgs)
try: try:
await endpoint.socket.send(msg) await endpoint.socket.send(msg)
@@ -121,18 +121,20 @@ class Node:
else: else:
if self.log_network: if self.log_network:
logging.info(f"Outgoing message: {msg}") logging.info(f"Outgoing message: {msg}")
return True
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str): async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed: if not endpoint.socket or not endpoint.socket.open:
return return False
try: try:
await endpoint.socket.send(msg) await endpoint.socket.send(msg)
except websockets.ConnectionClosed: except websockets.ConnectionClosed:
logging.exception("Exception during send_msgs") logging.exception("Exception during send_encoded_msgs")
await self.disconnect(endpoint) await self.disconnect(endpoint)
else: else:
if self.log_network: if self.log_network:
logging.info(f"Outgoing message: {msg}") logging.info(f"Outgoing message: {msg}")
return True
async def disconnect(self, endpoint): async def disconnect(self, endpoint):
if endpoint in self.endpoints: if endpoint in self.endpoints:
@@ -307,7 +309,9 @@ class Hint(typing.NamedTuple):
else: else:
add_json_text(parts, ".") 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 @property
def local(self): def local(self):

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import typing import typing
import random
class AssembleOptions(type): class AssembleOptions(type):
@@ -7,8 +8,9 @@ class AssembleOptions(type):
options = attrs["options"] = {} options = attrs["options"] = {}
name_lookup = attrs["name_lookup"] = {} name_lookup = attrs["name_lookup"] = {}
for base in bases: for base in bases:
options.update(base.options) if hasattr(base, "options"):
name_lookup.update(name_lookup) options.update(base.options)
name_lookup.update(name_lookup)
new_options = {name[7:].lower(): option_id for name, option_id in attrs.items() if new_options = {name[7:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("option_")} name.startswith("option_")}
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()}) attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
@@ -17,8 +19,16 @@ class AssembleOptions(type):
# apply aliases, without name_lookup # apply aliases, without name_lookup
options.update({name[6:].lower(): option_id for name, option_id in attrs.items() if options.update({name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")}) name.startswith("alias_")})
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
# 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)
class Option(metaclass=AssembleOptions): class Option(metaclass=AssembleOptions):
value: int value: int
@@ -88,6 +98,8 @@ class Toggle(Option):
def get_option_name(self): def get_option_name(self):
return bool(self.value) return bool(self.value)
class DefaultOnToggle(Toggle):
default = 1
class Choice(Option): class Choice(Option):
def __init__(self, value: int): def __init__(self, value: int):
@@ -109,6 +121,44 @@ class Choice(Option):
return cls.from_text(str(data)) return cls.from_text(str(data))
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):
return str(self.value)
def __str__(self):
return str(self.value)
class OptionNameSet(Option): class OptionNameSet(Option):
default = frozenset() default = frozenset()
@@ -142,240 +192,25 @@ class OptionDict(Option):
def get_option_name(self): def get_option_name(self):
return str(self.value) return str(self.value)
class Logic(Choice):
option_no_glitches = 0
option_minor_glitches = 1
option_overworld_glitches = 2
option_no_logic = 4
alias_owg = 2
class Objective(Choice):
option_crystals = 0
# option_pendants = 1
option_triforce_pieces = 2
option_pedestal = 3
option_bingo = 4
local_objective = Toggle # local triforce pieces, local dungeon prizes etc. 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): class Accessibility(Choice):
option_locations = 0 option_locations = 0
option_items = 1 option_items = 1
option_beatable = 2 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_large_diamonds = 3
option_small_pyramids = 4
option_medium_pyramids = 5
option_large_pyramids = 6
option_small_funnels = 7
option_medium_funnels = 8
option_large_funnels = 9
option_funnels = 4
alias_pyramid = 6
alias_funnel = 9
default = 0
class Visibility(Choice):
option_none = 0
option_sending = 1
default = 1
class RecipeTime(Choice):
option_vanilla = 0
option_fast = 1
option_normal = 2
option_slow = 4
option_chaos = 5
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,
"recipe_time": RecipeTime}
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__": if __name__ == "__main__":
import argparse
from worlds.alttp.Options import Logic
import argparse
mapshuffle = Toggle
compassshuffle = Toggle
keyshuffle = Toggle
bigkeyshuffle = Toggle
hints = Toggle
test = argparse.Namespace() test = argparse.Namespace()
test.logic = Logic.from_text("no_logic") test.logic = Logic.from_text("no_logic")
test.mapshuffle = mapshuffle.from_text("ON") test.mapshuffle = mapshuffle.from_text("ON")

155
Utils.py
View File

@@ -12,8 +12,9 @@ class Version(typing.NamedTuple):
minor: int minor: int
build: int build: int
__version__ = "0.1.2"
_version_tuple = tuplize_version(__version__) __version__ = "0.1.5"
version_tuple = tuplize_version(__version__)
import builtins import builtins
import os import os
@@ -22,6 +23,7 @@ import sys
import pickle import pickle
import functools import functools
import io import io
import collections
from yaml import load, dump, safe_load from yaml import load, dump, safe_load
@@ -52,7 +54,6 @@ def snes_to_pc(value):
def parse_player_names(names, players, teams): def parse_player_names(names, players, teams):
names = tuple(n for n in (n.strip() for n in names.split(",")) if n) names = tuple(n for n in (n.strip() for n in names.split(",")) if n)
if len(names) != len(set(names)): if len(names) != len(set(names)):
import collections
name_counter = collections.Counter(names) name_counter = collections.Counter(names)
raise ValueError(f"Duplicate Player names is not supported, " raise ValueError(f"Duplicate Player names is not supported, "
f'found multiple "{name_counter.most_common(1)[0][0]}".') f'found multiple "{name_counter.most_common(1)[0][0]}".')
@@ -68,7 +69,22 @@ def parse_player_names(names, players, teams):
return ret return ret
def is_bundled() -> bool: def cache_argsless(function):
if function.__code__.co_argcount:
raise Exception("Can only cache 0 argument functions with this cache.")
result = sentinel = object()
def _wrap():
nonlocal result
if result is sentinel:
result = function()
return result
return _wrap
def is_frozen() -> bool:
return getattr(sys, 'frozen', False) return getattr(sys, 'frozen', False)
@@ -76,7 +92,7 @@ def local_path(*path):
if local_path.cached_path: if local_path.cached_path:
return os.path.join(local_path.cached_path, *path) return os.path.join(local_path.cached_path, *path)
elif is_bundled(): elif is_frozen():
if hasattr(sys, "_MEIPASS"): if hasattr(sys, "_MEIPASS"):
# we are running in a PyInstaller bundle # we are running in a PyInstaller bundle
local_path.cached_path = sys._MEIPASS # pylint: disable=protected-access,no-member local_path.cached_path = sys._MEIPASS # pylint: disable=protected-access,no-member
@@ -118,20 +134,10 @@ def open_file(filename):
subprocess.call([open_command, 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 parse_yaml = safe_load
unsafe_parse_yaml = functools.partial(load, Loader=Loader) unsafe_parse_yaml = functools.partial(load, Loader=Loader)
@cache_argsless
def get_public_ipv4() -> str: def get_public_ipv4() -> str:
import socket import socket
import urllib.request import urllib.request
@@ -147,7 +153,7 @@ def get_public_ipv4() -> str:
pass # we could be offline, in a local game, so no point in erroring out pass # we could be offline, in a local game, so no point in erroring out
return ip return ip
@cache_argsless
def get_public_ipv6() -> str: def get_public_ipv6() -> str:
import socket import socket
import urllib.request import urllib.request
@@ -160,70 +166,55 @@ def get_public_ipv6() -> str:
pass # we could be offline, in a local game, or ipv6 may not be available pass # we could be offline, in a local game, or ipv6 may not be available
return ip return ip
@cache_argsless
def get_default_options() -> dict: 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.
# Refer to host.yaml for comments as to what all these options mean. options = {
options = { "general_options": {
"general_options": { "output_path": "output",
"output_path": "output", },
}, "factorio_options": {
"factorio_options": { "executable": "factorio\\bin\\x64\\factorio",
"executable": "factorio\\bin\\x64\\factorio", },
}, "lttp_options": {
"lttp_options": { "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", "sni": "SNI",
"qusb2snes": "QUsb2Snes\\QUsb2Snes.exe", "rom_start": True,
"rom_start": True,
}, },
"server_options": { "server_options": {
"host": None, "host": None,
"port": 38281, "port": 38281,
"password": None, "password": None,
"multidata": None, "multidata": None,
"savefile": None, "savefile": None,
"disable_save": False, "disable_save": False,
"loglevel": "info", "loglevel": "info",
"server_password": None, "server_password": None,
"disable_item_cheat": False, "disable_item_cheat": False,
"location_check_points": 1, "location_check_points": 1,
"hint_cost": 10, "hint_cost": 10,
"forfeit_mode": "goal", "forfeit_mode": "goal",
"remaining_mode": "goal", "remaining_mode": "goal",
"auto_shutdown": 0, "auto_shutdown": 0,
"compatibility": 2, "compatibility": 2,
"log_network": 0 "log_network": 0
}, },
"multi_mystery_options": { "generator": {
"teams": 1, "teams": 1,
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe", "enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
"player_files_path": "Players", "player_files_path": "Players",
"players": 0, "players": 0,
"weights_file_path": "weights.yaml", "weights_file_path": "weights.yaml",
"meta_file_path": "meta.yaml", "meta_file_path": "meta.yaml",
"pre_roll": False, "spoiler": 2,
"create_spoiler": 1, "glitch_triforce_room": 1,
"zip_roms": 0, "race": 0,
"zip_diffs": 2, "plando_options": "bosses",
"zip_apmcs": 1,
"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",
}
} }
}
get_default_options.options = options return options
return get_default_options.options
blacklisted_options = {"multi_mystery_options.cpu_threads", blacklisted_options = {"multi_mystery_options.cpu_threads",
@@ -253,7 +244,7 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
dest[key] = update_options(value, dest[key], filename, new_keys) dest[key] = update_options(value, dest[key], filename, new_keys)
return dest return dest
@cache_argsless
def get_options() -> dict: def get_options() -> dict:
if not hasattr(get_options, "options"): if not hasattr(get_options, "options"):
locations = ("options.yaml", "host.yaml", locations = ("options.yaml", "host.yaml",
@@ -367,7 +358,7 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
return romfile, adjusted return romfile, adjusted
return romfile, False return romfile, False
@cache_argsless
def get_unique_identifier(): def get_unique_identifier():
uuid = persistent_load().get("client", {}).get("uuid", None) uuid = persistent_load().get("client", {}).get("uuid", None)
if uuid: if uuid:
@@ -405,3 +396,9 @@ class RestrictedUnpickler(pickle.Unpickler):
def restricted_loads(s): def restricted_loads(s):
"""Helper function analogous to pickle.loads().""" """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

@@ -6,11 +6,17 @@ import ModuleUpdate
ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt")) ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt"))
ModuleUpdate.update() 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 WebHostLib import app as raw_app
from waitress import serve from waitress import serve
from WebHostLib.models import db from WebHostLib.models import db
from WebHostLib.autolauncher import autohost 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") configpath = os.path.abspath("config.yaml")
@@ -30,7 +36,9 @@ if __name__ == "__main__":
multiprocessing.freeze_support() multiprocessing.freeze_support()
multiprocessing.set_start_method('spawn') multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO) logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
update_sprites_lttp()
app = get_app() app = get_app()
create_options_files()
if app.config["SELFLAUNCH"]: if app.config["SELFLAUNCH"]:
autohost(app.config) autohost(app.config)
if app.config["SELFHOST"]: # using WSGI, you just want to run get_app() if app.config["SELFHOST"]: # using WSGI, you just want to run get_app()

View File

@@ -3,6 +3,7 @@ import uuid
import base64 import base64
import socket import socket
import jinja2.exceptions
from pony.flask import Pony from pony.flask import Pony
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from flask_caching import Cache from flask_caching import Cache
@@ -74,6 +75,58 @@ def register_session():
session["_id"] = uuid4() # uniquely identify each session without needing a login 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
games_list = {
"A Link to the Past": ("The Legend of Zelda: A Link to the Past",
"""
The Legend of Zelda: A Link to the Past is an action/adventure game. Take on the role of
Link, a boy who is destined to save the land of Hyrule. Delve through three palaces and nine
dungeons on your quest to rescue the descendents of the seven wise men and defeat the evil
Ganon!"""),
"Factorio": ("Factorio",
"""
Factorio is a game about automation. You play as an engineer who has crash landed on the planet
Nauvis, an inhospitable world filled with dangerous creatures called biters. Build a factory,
research new technologies, and become more efficient in your quest to build a rocket and return home.
"""),
"Minecraft": ("Minecraft",
"""
Minecraft is a game about creativity. In a world made entirely of cubes, you explore, discover, mine,
craft, and try not to explode. Delve deep into the earth and discover abandoned mines, ancient
structures, and materials to create a portal to another world. Defeat the Ender Dragon, and claim
victory!""")
}
# Player settings pages
@app.route('/games/<string:game>/player-settings')
def player_settings(game):
return render_template(f"player-settings.html")
# 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():
return render_template("games/games.html", games_list=games_list)
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>') @app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
def tutorial(game, file, lang): def tutorial(game, file, lang):
return render_template("tutorial.html", game=game, file=file, lang=lang) return render_template("tutorial.html", game=game, file=file, lang=lang)
@@ -84,13 +137,8 @@ def tutorial_landing():
return render_template("tutorialLanding.html") return render_template("tutorialLanding.html")
@app.route('/player-settings')
def player_settings_simple():
return render_template("playerSettings.html")
@app.route('/weighted-settings') @app.route('/weighted-settings')
def player_settings(): def weighted_settings():
return render_template("weightedSettings.html") return render_template("weightedSettings.html")
@@ -128,7 +176,7 @@ def display_log(room: UUID):
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8") 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): def hostRoom(room: UUID):
room = Room.get(id=room) room = Room.get(id=room)
if room is None: if room is None:
@@ -144,6 +192,9 @@ def hostRoom(room: UUID):
return render_template("hostRoom.html", room=room) 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') @app.route('/favicon.ico')
def favicon(): def favicon():
@@ -153,4 +204,5 @@ def favicon():
from WebHostLib.customserver import run_server_process from WebHostLib.customserver import run_server_process
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
app.register_blueprint(api.api_endpoints) app.register_blueprint(api.api_endpoints)

View File

@@ -4,14 +4,15 @@ from uuid import UUID
from flask import Blueprint, abort from flask import Blueprint, abort
from ..models import Room from ..models import Room
from .. import cache
api_endpoints = Blueprint('api', __name__, url_prefix="/api") api_endpoints = Blueprint('api', __name__, url_prefix="/api")
from . import generate, user # trigger registration from . import generate, user # trigger registration
# unsorted/misc endpoints # unsorted/misc endpoints
@api_endpoints.route('/room_status/<suuid:room>') @api_endpoints.route('/room_status/<suuid:room>')
def room_info(room: UUID): def room_info(room: UUID):
room = Room.get(id=room) room = Room.get(id=room)
@@ -22,3 +23,18 @@ def room_info(room: UUID):
"last_port": room.last_port, "last_port": room.last_port,
"last_activity": room.last_activity, "last_activity": room.last_activity,
"timeout": room.timeout} "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 sys
import typing import typing
import time import time
import os
from pony.orm import db_session, select, commit from pony.orm import db_session, select, commit
@@ -15,10 +16,13 @@ from Utils import restricted_loads
class CommonLocker(): class CommonLocker():
"""Uses a file lock to signal that something is already running""" """Uses a file lock to signal that something is already running"""
lock_folder = "file_locks"
def __init__(self, lockname: str): 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.lockname = lockname
self.lockfile = f"./{self.lockname}.lck" self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
class AlreadyRunningException(Exception): class AlreadyRunningException(Exception):
@@ -26,9 +30,6 @@ class AlreadyRunningException(Exception):
if sys.platform == 'win32': if sys.platform == 'win32':
import os
class Locker(CommonLocker): class Locker(CommonLocker):
def __enter__(self): def __enter__(self):
try: try:

View File

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

View File

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

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 worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain from Main import main as ERmain
from Main import get_seed, seeddigits from Main import get_seed, seeddigits
from Mystery import handle_name from Generate import handle_name
import pickle import pickle
from .models import * from .models import *
@@ -80,7 +80,6 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
erargs.outputname = seedname erargs.outputname = seedname
erargs.outputpath = target.name erargs.outputpath = target.name
erargs.teams = 1 erargs.teams = 1
erargs.create_diff = True
name_counter = Counter() name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1): for player, (playerfile, settings) in enumerate(gen_options.items(), 1):

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

54
WebHostLib/options.py Normal file
View File

@@ -0,0 +1,54 @@
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():
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
)
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",
"friendlyName": option.friendly_name if hasattr(option, "friendly_name") 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": sub_option_name,
"value": sub_option_name,
})
if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name
game_options[option_name] = this_option
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

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

View File

@@ -1,5 +1,13 @@
let gameName = null;
window.addEventListener('load', () => { window.addEventListener('load', () => {
Promise.all([fetchSettingData(), fetchSpriteData()]).then((results) => { const urlMatches = window.location.href.match(/^.*\/(.*)\/player-settings/);
gameName = decodeURIComponent(urlMatches[1]);
// Update game name on page
document.getElementById('game-name').innerHTML = gameName;
Promise.all([fetchSettingData()]).then((results) => {
// Page setup // Page setup
createDefaultSettings(results[0]); createDefaultSettings(results[0]);
buildUI(results[0]); buildUI(results[0]);
@@ -11,24 +19,13 @@ window.addEventListener('load', () => {
document.getElementById('generate-game').addEventListener('click', () => generateGame()); document.getElementById('generate-game').addEventListener('click', () => generateGame());
// Name input field // Name input field
const playerSettings = JSON.parse(localStorage.getItem('playerSettings')); const playerSettings = JSON.parse(localStorage.getItem(gameName));
const nameInput = document.getElementById('player-name'); const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateSetting(event)); nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
nameInput.value = playerSettings.name; 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) => { }).catch((error) => {
console.error(error); const url = new URL(window.location.href);
window.location.replace(`${url.protocol}//${url.hostname}/page-not-found`);
}) })
}); });
@@ -43,27 +40,22 @@ const fetchSettingData = () => new Promise((resolve, reject) => {
try{ resolve(JSON.parse(ajax.responseText)); } try{ resolve(JSON.parse(ajax.responseText)); }
catch(error){ reject(error); } catch(error){ reject(error); }
}; };
ajax.open('GET', `${window.location.origin}/static/static/playerSettings.json`, true); ajax.open('GET', `${window.location.origin}/static/generated/${gameName}.json`, true);
ajax.send(); ajax.send();
}); });
const createDefaultSettings = (settingData) => { const createDefaultSettings = (settingData) => {
if (!localStorage.getItem('playerSettings')) { if (!localStorage.getItem(gameName)) {
const newSettings = {}; const newSettings = {
for (let roSetting of Object.keys(settingData.readOnly)){ [gameName]: {},
newSettings[roSetting] = settingData.readOnly[roSetting]; };
} for (let baseOption of Object.keys(settingData.baseOptions)){
for (let generalOption of Object.keys(settingData.generalOptions)){ newSettings[baseOption] = settingData.baseOptions[baseOption];
newSettings[generalOption] = settingData.generalOptions[generalOption];
} }
for (let gameOption of Object.keys(settingData.gameOptions)){ for (let gameOption of Object.keys(settingData.gameOptions)){
newSettings[gameOption] = settingData.gameOptions[gameOption].defaultValue; newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue;
} }
newSettings.rom = {}; localStorage.setItem(gameName, JSON.stringify(newSettings));
for (let romOption of Object.keys(settingData.romOptions)){
newSettings.rom[romOption] = settingData.romOptions[romOption].defaultValue;
}
localStorage.setItem('playerSettings', JSON.stringify(newSettings));
} }
}; };
@@ -77,20 +69,10 @@ const buildUI = (settingData) => {
}); });
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts)); document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts)); 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 buildOptionsTable = (settings, romOpts = false) => {
const currentSettings = JSON.parse(localStorage.getItem('playerSettings')); const currentSettings = JSON.parse(localStorage.getItem(gameName));
const table = document.createElement('table'); const table = document.createElement('table');
const tbody = document.createElement('tbody'); const tbody = document.createElement('tbody');
@@ -122,7 +104,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
} }
select.appendChild(option); select.appendChild(option);
}); });
select.addEventListener('change', (event) => updateSetting(event)); select.addEventListener('change', (event) => updateGameSetting(event));
tdr.appendChild(select); tdr.appendChild(select);
tr.appendChild(tdr); tr.appendChild(tdr);
tbody.appendChild(tr); tbody.appendChild(tr);
@@ -132,20 +114,22 @@ const buildOptionsTable = (settings, romOpts = false) => {
return table; return table;
}; };
const updateSetting = (event) => { const updateBaseSetting = (event) => {
const options = JSON.parse(localStorage.getItem('playerSettings')); const options = JSON.parse(localStorage.getItem(gameName));
if (event.target.getAttribute('data-romOpt')) { options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
options.rom[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); event.target.value : parseInt(event.target.value, 10);
} else { localStorage.setItem(gameName, JSON.stringify(options));
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 exportSettings = () => {
const settings = JSON.parse(localStorage.getItem('playerSettings')); const settings = JSON.parse(localStorage.getItem(gameName));
if (!settings.name || settings.name.trim().length === 0) { settings.name = "noname"; } if (!settings.name || settings.name.trim().length === 0) { settings.name = "noname"; }
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`); const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText); download(`${document.getElementById('player-name').value}.yaml`, yamlText);
@@ -164,8 +148,8 @@ const download = (filename, text) => {
const generateGame = (raceMode = false) => { const generateGame = (raceMode = false) => {
axios.post('/api/generate', { axios.post('/api/generate', {
weights: { player: localStorage.getItem('playerSettings') }, weights: { player: localStorage.getItem(gameName) },
presetData: { player: localStorage.getItem('playerSettings') }, presetData: { player: localStorage.getItem(gameName) },
playerCount: 1, playerCount: 1,
race: raceMode ? '1' : '0', race: raceMode ? '1' : '0',
}).then((response) => { }).then((response) => {
@@ -173,22 +157,11 @@ const generateGame = (raceMode = false) => {
}).catch((error) => { }).catch((error) => {
const userMessage = document.getElementById('user-message'); const userMessage = document.getElementById('user-message');
userMessage.innerText = 'Something went wrong and your game could not be generated.'; 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'); userMessage.classList.add('visible');
window.scrollTo(0, 0); window.scrollTo(0, 0);
console.error(error); 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, paging: false,
info: false, info: false,
dom: "t", 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 // DO NOT use the scrollX or scrollY options. They cause DataTables to split the thead from
// the tbody and render two separate tables. // the tbody and render two separate tables.

View File

@@ -33,19 +33,17 @@ use-system-read-write-data-directories=false
## Joining a MultiWorld Game ## 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 <Factorio Directory>/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.
4. Run FactorioClient, it should launch a Factorio server, which you can control with `/factorio <original factorio commands>`, 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.
* 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.
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.
* / commands are run on your local client, ! commands are requests for the AP server * / 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. * 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

@@ -93,7 +93,7 @@ const fetchSpriteData = () => new Promise((resolve, reject) => {
} }
resolve(ajax.responseText); resolve(ajax.responseText);
}; };
ajax.open('GET', `${window.location.origin}/static/static/spriteData.json`, true); ajax.open('GET', `${window.location.origin}/static/generated/spriteData.json`, true);
ajax.send(); ajax.send();
}); });
@@ -446,7 +446,7 @@ const buildSpritePicker = (spriteData) => {
let spriteGifFile = sprite.file.split('.'); let spriteGifFile = sprite.file.split('.');
spriteGifFile.pop(); spriteGifFile.pop();
spriteGifFile = spriteGifFile.join('.') + '.gif'; spriteGifFile = spriteGifFile.join('.') + '.gif';
spriteImg.setAttribute('src', `static/static/sprites/${spriteGifFile}`); spriteImg.setAttribute('src', `static/generated/sprites/${spriteGifFile}`);
spriteImg.setAttribute('data-sprite', sprite.file.split('.')[0]); spriteImg.setAttribute('data-sprite', sprite.file.split('.')[0]);
spriteImg.setAttribute('alt', sprite.name); spriteImg.setAttribute('alt', sprite.name);
@@ -476,6 +476,9 @@ const generateGame = (raceMode = false) => {
}).catch((error) => { }).catch((error) => {
const userMessage = document.getElementById('user-message'); const userMessage = document.getElementById('user-message');
userMessage.innerText = 'Something went wrong and your game could not be generated.'; 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'); userMessage.classList.add('visible');
window.scrollTo(0, 0); window.scrollTo(0, 0);
console.error(error); 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

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