Compare commits

..

415 Commits
0.0.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
Fabian Dill
4b5ac3f926 Update VC Redist 2021-06-07 11:53:33 +02:00
Fabian Dill
72e5acfb86 Factorio recipe time: adjust triangular mode 2021-06-07 11:32:39 +02:00
espeon65536
16c6e17a49 Initial handling of hybrid glitch logic outside of UnderworldGlitchRules 2021-06-07 01:19:27 -05:00
espeon65536
ac31671914 initial hybridmg logic file commit 2021-06-07 00:38:30 -05:00
Fabian Dill
4b283242fe FactorioClient: remove duplicate log 2021-06-06 23:59:15 +02:00
Fabian Dill
353ea0fbbe encode correct color 2021-06-06 23:44:04 +02:00
Fabian Dill
fc941f55ef FactorioClientGUI.py: disable multitouch emulation on mouse 2021-06-06 23:23:06 +02:00
Fabian Dill
12600a8cbd FactorioClientGUI.py: fix frozen logging 2021-06-06 23:13:19 +02:00
Fabian Dill
33fa9542e0 move FactorioJSONtoTextParser 2021-06-06 22:49:37 +02:00
Fabian Dill
d872ea32af Update various links 2021-06-06 22:14:13 +02:00
Fabian Dill
46bb2d1367 Factorio: add chaos recipe time and use random.triangular distribution 2021-06-06 21:38:53 +02:00
Fabian Dill
403ddd603f Factorio: implement random recipe times 2021-06-06 21:11:58 +02:00
Fabian Dill
7907838c24 Factorio: Revamp Tech Tree Layouts 2021-06-06 20:26:40 +02:00
Fabian Dill
15bd79186a remove player_name feature in MultiMystery
MultiMystery is slated to be integrated into Mystery and the auto-launch feature is not maintainable for a growing games list
2021-06-06 18:12:19 +02:00
Fabian Dill
4555b77204 FactorioClient.py formatting 2021-06-06 17:50:48 +02:00
Fabian Dill
dd3c612dec Factorio: Colored ingame text relay for AP texts 2021-06-06 17:41:06 +02:00
Fabian Dill
09b6698de8 revamp some spoiler log conditions 2021-06-06 17:13:34 +02:00
Fabian Dill
27ee156706 tiny cleanup 2021-06-06 17:10:49 +02:00
espeon65536
48c3d1fa4a Added campfire for Sticky Situation, by popular demand 2021-06-06 15:10:45 +00:00
espeon65536
286254c5cd require end crystals for Free the End, since it's possible to kill the dragon with beds and not receive the advancement 2021-06-06 15:10:45 +00:00
espeon65536
82cd51f5f4 structure plando for Minecraft 2021-06-06 15:10:45 +00:00
espeon65536
08bf993146 only write Medallions section to spoiler log if there is an ALttP world 2021-06-06 15:10:45 +00:00
espeon65536
a55bcae3ec Minecraft logic improvements
- Very Very Frightening now properly accounts for getting a villager into the overworld by curing a zombie villager
- Hot Tourist Destinations no longer requires striders, since no one was using them anyway
- Saddles are now also obtainable from raids by killing a ravager (100% drop rate)
2021-06-06 15:10:45 +00:00
Fabian Dill
607a14e921 FactorioClient: log kivy exceptions 2021-06-06 16:09:00 +02:00
Fabian Dill
c71387ad00 Factorio: fix single-player static node placement 2021-06-06 16:08:17 +02:00
Fabian Dill
c095c28618 Split requirements into world types, automatically discover and resolve them. 2021-06-06 15:30:20 +02:00
Fabian Dill
cae1188ff8 Allow ModuleUpdate to use multiple requirements files, no longer need to care about naming, and use conventional requirement parsing. Also add WebHost to it. 2021-06-06 15:11:17 +02:00
CaitSith2
7e599c51f8 Make defaults for missing options in host.yaml consistent. 2021-06-05 21:15:54 -07:00
CaitSith2
6ccb9d2dc2 Fix adjuster reference 2021-06-05 13:58:59 -07:00
Fabian Dill
1d00ed463e fix updated name aliases for tracker 2021-06-05 03:54:16 +02:00
Fabian Dill
c99054e479 add /build_factorio to gitignore 2021-06-04 01:00:03 +02:00
Fabian Dill
85a9e0d0bc write Factorio options to spoiler 2021-06-04 00:29:59 +02:00
Fabian Dill
8b4ea3c80c fix max progressive item icon in per-player tracker 2021-06-03 01:02:31 +02:00
Fabian Dill
30dec34b72 update websockets 2021-06-02 04:40:43 +02:00
Fabian Dill
a3d2df7c45 Merge branch 'factorio_gui_client' into Archipelago_Main 2021-06-02 04:31:39 +02:00
Fabian Dill
034f338f45 set default hint cost to 10 2021-06-01 04:28:15 +02:00
Fabian Dill
1d84346705 Factorio: Don't trigger bridge file on receiving a technology from server 2021-05-29 20:02:36 +02:00
Fabian Dill
6e916ebd45 bake correct minimum version for Factorio into multidata 2021-05-29 06:23:35 +02:00
Fabian Dill
a993bed8dc move factorio_client_setup.py into setup.py 2021-05-27 12:26:08 +02:00
Fabian Dill
aa6f65ee1f Prevent logical lockout from Pedestal/Pyramid Fairy in ice rod hunt 2021-05-27 12:14:20 +02:00
Fabian Dill
573931930c remove debugging helper 2021-05-25 01:06:15 +02:00
Fabian Dill
252bb69808 FactorioClient: Read Bridge file after a server log indicates that the file was written 2021-05-25 01:03:04 +02:00
Fabian Dill
0175c8ab8a move FactorioClient log to logs folder 2021-05-24 16:09:10 +02:00
Fabian Dill
f78bb2078d make sure Factorio subprocess is terminated properly 2021-05-24 13:51:27 +02:00
Fabian Dill
bc028a63cd first version of a Factorio Graphical Client 2021-05-24 12:49:01 +02:00
Fabian Dill
4b04f2b918 update icons 2021-05-24 12:48:18 +02:00
Fabian Dill
887a3b0922 update flask and jinja 2021-05-24 05:03:45 +02:00
Fabian Dill
3df78fa387 Factorio add ap_unimportant.png 2021-05-23 20:13:19 +02:00
Fabian Dill
c36ac5baba consider the ability to craft a rocket-silo for factorio completion 2021-05-22 21:13:53 +02:00
Fabian Dill
d8e33fe596 Factorio: Differentiate advancement items. 2021-05-22 10:46:27 +02:00
Fabian Dill
80b7e2e188 Factorio: Build logic for rocket launch, allow beatable only to work correctly
Convert Science requirements to Event of "automate <pack>"
2021-05-22 10:06:21 +02:00
Fabian Dill
14b430a168 Factorio: simplify resulting data-final-fixes.lua after templating a bit. 2021-05-22 08:08:37 +02:00
Fabian Dill
22aa4cbb9f Factorio: Fix Rocket Launch event getting encoded into mod 2021-05-22 07:54:12 +02:00
Fabian Dill
71bb5b850e set correct player ID for Factorio Victory 2021-05-22 07:06:09 +02:00
Fabian Dill
066c830a43 Fix LttP progressive starting Items not writing to ROM 2021-05-22 06:27:22 +02:00
Fabian Dill
760107becf remove no longer needed imports 2021-05-22 03:00:24 +02:00
Fabian Dill
8dad49e385 assign generic tracker's checked locations to correct player 2021-05-20 01:22:18 +02:00
Fabian Dill
518e5db55b use item_name filter for generic tracker 2021-05-19 21:57:10 +02:00
Fabian Dill
31a3c1cf33 Add a generic fallback tracker for all games 2021-05-19 21:55:18 +02:00
Fabian Dill
e1b4975a11 Add Crafting Machine awareness to Factorio logic
(should have no effect on vanilla, mostly for modded gameplay)
2021-05-19 06:52:53 +02:00
Fabian Dill
f8a5e8bfc7 add Factorio Victory Event 2021-05-19 05:33:44 +02:00
Fabian Dill
a656ad5cd2 potential fix for rcon timing issue 2021-05-18 20:45:56 +02:00
Fabian Dill
b43e4fae86 update websockets 2021-05-16 23:10:45 +02:00
Fabian Dill
1f17aa394e allow uploading of Factorio mods 2021-05-16 22:59:45 +02:00
Fabian Dill
a1d7bc558c preconfigure and sign qusb2snes 2021-05-16 18:30:13 +02:00
Fabian Dill
de31fc320c allow webhost handling of APMC files 2021-05-16 01:16:51 +02:00
espeon65536
685de847c4 Minecraft updates (#13)
* Minecraft locations, items, and generation without logic

* added id lookup for minecraft

* typing import fix in minecraft/Items.py

* fix 2

* implementing Minecraft options and hard/postgame advancement exclusion

* first logic pass (75/80)

* logic pass 2 and proper completion conditions

* added insane difficulty pool, modified method of excluding item pools for easier extension

* bump network_data_package version

* minecraft testing framework

* switch Ancient Debris to Netherite Scrap to avoid advancement triggering on receiving that item

* Testing now functions, split tests up by advancement pane, added some story tests

* Newer testing framework: every advancement gets its own function, for ease of testing

* fixed logic for The End... Again...

* changed option names to "include_hard_advancements" etc.

* village/pillager-related advancements now require can_adventure: weapon + food

* a few minecraft tests

* rename "Flint & Steel" to "Flint and Steel" for parity with in-game name

* additional MC tests

* more tests, mostly nether-related tests

* more tests, removed anvil path for Two Birds One Arrow

* include Minecraft slot data, and a world seed for each Minecraft player slot

* Added new items: ender pearls, lapis, porkchops

* All remaining Minecraft tests

* formatting of Minecraft tests and logic for better readability

* require Wither kill for Monsters Hunted

* properly removed 8 Emeralds item from item pool

* enchanting required for wither; fishing rod required for water breathing; water breathing required for elder guardian kill

* Added 12 new advancements (ported from old achievement system)

* renamed "On a Rail" for consistency with modern advancements

* tests for the new advancements

* moved slot_data generation for minecraft into worlds/minecraft/__init__.py, added logic_version to slot_data

* output minecraft options in the spoiler log

* modified advancement goal values for new advancements

* make non-native Minecraft items appear as Shovel in ALttP, and unknown-game items as Power Stars

* fixed glowstone block logic for Not Quite Nine Lives

* setup for shuffling MC structures: building ER world and shuffling regions/entrances

* ensured Nether Fortresses can't be placed in the End

* finished logic for structure randomization

* fixed nonnative items always showing up as Hammers in ALttP shops

* output minecraft structure info in the spoiler

* generate .apmc file for communication with MC client

* fixed structure rando always using the same seed

* move stuff to worlds/minecraft/Regions.py

* make output apmc file have consistent name with other files

* added minecraft bottle macro; fixed tests imports

* generalizing MC region generation

* restructured structure shuffling in preparation for structure plando

* only output structure rando info in spoiler if they are shuffled

* Force structure rando to always be off, for the stable release

* added Minecraft options to player settings

* formally added combat_difficulty as an option

* Added Ender Dragon into playthrough, cleaned up goal map

* Added new difficulties: Easy, Normal, Hard combat

* moved .apmc generation time to prevent outputs on failed generation

* updated tests for new combat logic

* Fixed bug causing generation to fail; removed Nether Fortress event since it should no longer be needed with the fix

* moved all MC-specific functions into gen_minecraft

* renamed "logic_version" to "client_version"

* bug fixes
properly flagged event locations/items with id None
moved generation back to Main.py to fix mysterious generation failures

* moved link_minecraft_regions into minecraft init, left create_regions in Main for caching

* added seed_name, player_name, client_version to apmc file

* reenabled structure shuffle

* added entrance tests for minecraft

* Minecraft logic updates
Wither kill now considers nether fortresses as a valid source of soul sand
A Furious Cocktail now requires beacons for resistance and village access for carrots
Uneasy Alliance now requires fishing rod to pull the ghast through the portal
On a Rail now requires iron pickaxe to make powered rails
Overkill now may require strength II without stone axe, which needs nether access

* embed all apmc info into slot_data

* updated MC tests for logic changes

* put apmc into zipfile

Co-authored-by: achuang <alexander.w.chuang@gmail.com>
2021-05-16 00:49:58 +02:00
Kono Tyran
40751f267b removed reference to playersettings yaml as full descriptions are now in the provided example. 2021-05-15 22:46:21 +00:00
Fabian Dill
3e1941a561 allow Factorio Client to recognize if it's trying to connect to the wrong multiworld. 2021-05-16 00:21:00 +02:00
Fabian Dill
8e27ad3547 include full websockets module due to dynamic imports not being identifiable by cx_freeze 2021-05-15 23:01:52 +02:00
Fabian Dill
c4f5db9c84 pass through sys args to factorio server 2021-05-15 22:11:20 +02:00
Fabian Dill
19896e1fae prepare webhost for multi-game per-slot downloads 2021-05-14 15:25:57 +02:00
Fabian Dill
23678b814d specify get_id as being alttp only 2021-05-14 14:38:23 +02:00
Fabian Dill
13fe1f2ea2 /api/generate send back error message 2021-05-14 14:12:21 +02:00
Chris Wilson
c24d6a0785 Add error message to player-settings and weighted-settings pages if the call to /api/generate returns a non-2xx response code. 2021-05-13 21:33:56 -04:00
Fabian Dill
b2f3fd56f4 bunch of fixes after testing round 2021-05-14 01:25:41 +02:00
Fabian Dill
b82d6cec31 regain basic WebHost functionality 2021-05-13 21:57:11 +02:00
Fabian Dill
c5ff962ea1 document start_hints 2021-05-13 02:53:59 +02:00
Fabian Dill
4aa56c1a7f don't default to active start_hints 2021-05-13 02:39:20 +02:00
Fabian Dill
681279cb2b Implement "start_hints" option 2021-05-13 02:35:50 +02:00
Fabian Dill
c4ea879651 "precollect" visible Factorio tech tree as hints, so points are never spent on what was visible. 2021-05-13 02:10:37 +02:00
Fabian Dill
8cdf9d2ddc faster .apsave loading and saving 2021-05-13 01:58:53 +02:00
Fabian Dill
daa959e353 remove suppress rom argument 2021-05-13 01:40:36 +02:00
Fabian Dill
d5cdff5ec9 filter hints to whom they concern 2021-05-13 01:37:50 +02:00
Fabian Dill
109eb5b9dc start of split 2021-05-13 01:34:59 +02:00
Fabian Dill
fb192b989d update jinja templates to use base static files 2021-05-13 00:41:49 +02:00
Fabian Dill
d35adc5868 Update Flask and Jinja
Flask Autoversion is now integrated
Flask config.from_file is now integrated
2021-05-13 00:28:53 +02:00
Fabian Dill
c0bf4f58ad extend gitignore 2021-05-11 23:57:42 +02:00
Kono Tyran
f24a81fdaf fix !remaining command to look beyond ALTTP 2021-05-11 21:38:44 +00:00
Kono Tyran
40ff0e867c fixed abbreviation ap to full name. 2021-05-11 21:38:34 +00:00
Fabian Dill
a231850911 Make hint costs relative 2021-05-11 23:08:50 +02:00
Fabian Dill
1b2283b173 Factorio: correctly cache control_template to allow multiple Factorio worlds 2021-05-11 13:28:58 +02:00
Fabian Dill
729088fd85 Fix generation failure if aga tower door was placed on HC ledge in inverted dungeonsfull 2021-05-11 01:26:59 +02:00
Fabian Dill
88d75a41ae Factorio setup tutorial 2021-05-10 22:42:11 +02:00
Fabian Dill
237b44ca66 Update Documentation to match compatibility variable 2021-05-10 22:04:19 +02:00
Fabian Dill
6fef30d9b3 remove german tutorial video 2021-05-10 13:06:51 +02:00
Fabian Dill
4813fcac08 include version tuple in manifest 2021-05-10 12:02:18 +02:00
Fabian Dill
e50db61030 constrict Factorio logic to require all paths to a product, not any.
Should narrow this down in a careful manner later.
2021-05-10 02:33:54 +02:00
Fabian Dill
f8c3b695d0 Add Swedish Minecraft Tutorial by Albinum 2021-05-10 01:46:42 +02:00
Edos512
431b64c574 Merge Tutorials from BM 2021-05-10 01:38:26 +02:00
Fabian Dill
f06d160615 don't check the AP tag anymore 2021-05-10 01:18:57 +02:00
Fabian Dill
909172cbad Factorio, Minecraft & Hollow Knight: add startinventory support 2021-05-09 21:22:21 +02:00
Fabian Dill
382c6d0445 Factorio: Fix free samples still preventing starting items 2021-05-09 20:39:42 +02:00
Fabian Dill
4efd27694a Factorio: no longer lock starting_items behind free_samples 2021-05-09 18:13:29 +02:00
KonoTyran
fa24fd31d0 re-added stuff (#7)
* add minecraft randomizer usage/installation tutorial

* update tutorial to specify Java edition of Minecraft to avoid confusion.

* fixed some grammar and spelling in the minecraft_en.md tutorial

* add minecraft randomizer usage/installation tutorial

* update tutorial to specify Java edition of Minecraft to avoid confusion.

* fixed some grammar and spelling in the minecraft_en.md tutorial

* update readme to relfect removal of name field from connect command.

* re-added explenations in yaml manualy wrapped the lines to stop them from looking weird on the web page.
2021-05-09 18:06:51 +02:00
Fabian Dill
c55983af5f Factorio: add starting_items 2021-05-09 17:46:26 +02:00
Fabian Dill
9c3d12dc55 Factorio: Embed slot name into mod 2021-05-09 17:26:53 +02:00
Fabian Dill
37755cd362 Factorio: Automatically find and force create bridge file 2021-05-09 16:49:47 +02:00
KonoTyran
eb02d65dbb minecraft setup tutorial changes. (#6)
* add minecraft randomizer usage/installation tutorial

* update tutorial to specify Java edition of Minecraft to avoid confusion.

* fixed some grammar and spelling in the minecraft_en.md tutorial

* add minecraft randomizer usage/installation tutorial

* update tutorial to specify Java edition of Minecraft to avoid confusion.

* fixed some grammar and spelling in the minecraft_en.md tutorial

* update readme to relfect removal of name field from connect command.
2021-05-08 19:51:12 +02:00
Fabian Dill
4d38f44da3 fix defaults for Factorio and Minecraft 2021-05-08 19:11:08 +02:00
Fabian Dill
212abc2b5a increment version 2021-05-08 13:42:18 +02:00
Fabian Dill
3797c20488 update README.md 2021-05-08 13:40:23 +02:00
espeon65536
2f7e532f4f Minecraft Randomizer
Squash merge, original Commits:

* Minecraft locations, items, and generation without logic

* added id lookup for minecraft

* typing import fix in minecraft/Items.py

* fix 2

* implementing Minecraft options and hard/postgame advancement exclusion

* first logic pass (75/80)

* logic pass 2 and proper completion conditions

* added insane difficulty pool, modified method of excluding item pools for easier extension

* bump network_data_package version

* minecraft testing framework

* switch Ancient Debris to Netherite Scrap to avoid advancement triggering on receiving that item

* Testing now functions, split tests up by advancement pane, added some story tests

* Newer testing framework: every advancement gets its own function, for ease of testing

* fixed logic for The End... Again...

* changed option names to "include_hard_advancements" etc.

* village/pillager-related advancements now require can_adventure: weapon + food

* a few minecraft tests

* rename "Flint & Steel" to "Flint and Steel" for parity with in-game name

* additional MC tests

* more tests, mostly nether-related tests

* more tests, removed anvil path for Two Birds One Arrow

* include Minecraft slot data, and a world seed for each Minecraft player slot

* Added new items: ender pearls, lapis, porkchops

* All remaining Minecraft tests

* formatting of Minecraft tests and logic for better readability

* require Wither kill for Monsters Hunted

* properly removed 8 Emeralds item from item pool

* enchanting required for wither; fishing rod required for water breathing; water breathing required for elder guardian kill

* Added 12 new advancements (ported from old achievement system)

* renamed "On a Rail" for consistency with modern advancements

* tests for the new advancements

* moved slot_data generation for minecraft into worlds/minecraft/__init__.py, added logic_version to slot_data

* output minecraft options in the spoiler log

* modified advancement goal values for new advancements

* make non-native Minecraft items appear as Shovel in ALttP, and unknown-game items as Power Stars

* fixed glowstone block logic for Not Quite Nine Lives

* setup for shuffling MC structures: building ER world and shuffling regions/entrances

* ensured Nether Fortresses can't be placed in the End

* finished logic for structure randomization

* fixed nonnative items always showing up as Hammers in ALttP shops

* output minecraft structure info in the spoiler

* generate .apmc file for communication with MC client

* fixed structure rando always using the same seed

* move stuff to worlds/minecraft/Regions.py

* make output apmc file have consistent name with other files

* added minecraft bottle macro; fixed tests imports

* generalizing MC region generation

* restructured structure shuffling in preparation for structure plando

* only output structure rando info in spoiler if they are shuffled

* Force structure rando to always be off, for the stable release

* added Minecraft options to player settings

* formally added combat_difficulty as an option

* Added Ender Dragon into playthrough, cleaned up goal map

* Added new difficulties: Easy, Normal, Hard combat

* moved .apmc generation time to prevent outputs on failed generation

* updated tests for new combat logic

* Fixed bug causing generation to fail; removed Nether Fortress event since it should no longer be needed with the fix

* moved all MC-specific functions into gen_minecraft

* renamed "logic_version" to "client_version"

* bug fixes
properly flagged event locations/items with id None
moved generation back to Main.py to fix mysterious generation failures

* moved link_minecraft_regions into minecraft init, left create_regions in Main for caching

* added seed_name, player_name, client_version to apmc file

* reenabled structure shuffle

* added entrance tests for minecraft

Co-authored-by: achuang <alexander.w.chuang@gmail.com>
2021-05-08 13:38:57 +02:00
Fabian Dill
eb2a3009f4 Add dungeonscrossed to sample yaml 2021-05-08 12:08:55 +02:00
Fabian Dill
5c9aa09c80 LttP: implement dungeonscrossed ER 2021-05-08 12:04:03 +02:00
Fabian Dill
e5bbcb8d27 merge of 6bf02455e6 2021-05-08 01:49:30 +02:00
Fabian Dill
cf488e5a5d convert parse_zspr to staticmethod 2021-05-08 01:44:37 +02:00
Fabian Dill
36ef9e8a72 LttP: Read author game display name from zspr 2021-05-07 22:34:02 +02:00
Fabian Dill
e7d254aed7 Factorio: make automation science pack rocket recipe consistent with the others 2021-05-07 22:09:04 +02:00
Fabian Dill
298f2f652a Factorio: add rocket recipe scaling by max science 2021-05-07 21:58:46 +02:00
Fabian Dill
5cb2689609 LttP: speed up ER shuffling caves 2021-05-07 21:01:13 +02:00
Fabian Dill
9ab5ec426d remove leftover from when recipes were events 2021-05-06 12:53:57 +02:00
Fabian Dill
77d3bf9172 Factorio: Allow assembling machine 1 to use fluids
Should improve the flow of the game a bit, no longer having to wait for automation-2 to get started with rocket fuel, processing units and others.
2021-05-03 18:06:21 +02:00
Fabian Dill
328f132498 include player name in factorio mod name. 2021-05-03 16:29:05 +02:00
Fabian Dill
de2ead3a9b Embed Minecraft tutorial in menu 2021-05-03 12:30:09 +02:00
KonoTyran
ff96b391b9 Mincraft Tutorial (#4)
* add minecraft randomizer usage/installation tutorial

* update tutorial to specify Java edition of Minecraft to avoid confusion.
2021-05-03 12:26:35 +02:00
Fabian Dill
4a75d27261 don't precollect Hollow Knight items 2021-05-01 02:16:19 +02:00
Chris Wilson
05e464e379 Update my copyright notice in the LICENSE 2021-04-29 21:09:26 -04:00
Chris Wilson
e8e141b206 Update copyright year on the WebHost to 2021 2021-04-29 20:09:10 -04:00
Fabian Dill
bed8fe82cf speed up can_beat_game 2021-04-29 09:54:49 +02:00
Fabian Dill
3a1d33f499 Allow removing non-LttP Progressive items from CollectionState 2021-04-29 08:25:06 +02:00
Fabian Dill
6ea68dd290 Count non-LttP Progressive items during playthrough creation
(there seem to be more problems hiding though)
2021-04-29 08:14:09 +02:00
Fabian Dill
97030590c2 Factorio: send goal completion 2021-04-29 04:34:47 +02:00
Fabian Dill
60f64cc46b rename get_location_name_from_address to get_location_name_from_id 2021-04-28 15:48:11 +02:00
KonoTyran
5087b78c28 fixed !missing to point to location table not item. (#2) 2021-04-28 15:46:54 +02:00
Fabian Dill
95358bc523 Never download a sprite with Author "Nintendo" 2021-04-28 10:31:24 +02:00
Fabian Dill
4fc1ce77ac only build vanilla sprite data once correctly 2021-04-28 02:39:55 +02:00
Fabian Dill
b8c7d6a72f remove remaining sprite data 2021-04-27 07:19:53 +02:00
Fabian Dill
e04fbd1d77 update setup for newer cx_Freeze #2 2021-04-27 05:26:27 +02:00
Fabian Dill
fb8229fda5 update setup for newer cx_Freeze 2021-04-25 04:02:38 +02:00
Fabian Dill
569e0e3004 Factorio: add option: random tech ingredients 2021-04-24 01:16:49 +02:00
Fabian Dill
73ed18c11d Move update_sprites as --update_sprites to LttPAdjuster 2021-04-21 23:53:59 +02:00
Daniel Grace
65df153947 Overhauls control.lua template (#1)
* Overhauls control.lua template

- Adds buffering of received free-samples items.
  - Players with a full inventory will receive new items as space becomes
    available.
  - Players who do not yet exist in the world will receive all free samples
    upon joining.

- When receiving new technologies, announce the technology BEFORE marking
  it as researched so that it appears before its free samples in the log.

- If receiving a half-stack of free samples, use math.ceil on the division
  in case a mod uses an odd number as a stack size... or a stack size of 1

- Handle free_samples logic in the Lua side rather than as part of a Jinja
  template.  This makes this hopefully more adaptable to a future setup
  where all the rando information is shipped as startup settings.

* Apparently, I'm supposed to give myself credit.  Or something.
2021-04-18 06:06:53 +02:00
Fabian Dill
2dd6dcab20 remote now unneccessary line 2021-04-17 22:01:34 +02:00
Fabian Dill
4494207717 generalize LocationScout and make LttP remote items break entirely 2021-04-17 22:00:45 +02:00
Fabian Dill
88265c5585 flatten and integer cast HK options per request 2021-04-17 21:16:09 +02:00
Fabian Dill
501c55cc26 add per-slot data and embed HK options in it 2021-04-17 21:03:57 +02:00
Fabian Dill
a5efed83b9 Set non-LttP Shop prices to 5 to 140 Rupee range 2021-04-16 21:41:19 +02:00
Fabian Dill
e7a746c06c fix unittest local path 2021-04-15 04:01:25 +02:00
Fabian Dill
063997610d use rich text for received technologies 2021-04-14 18:38:06 +02:00
Fabian Dill
432ae5865d Factorio: Filter bridged technologies correctly
Turns out, lua does not use regex, nor simple string matching, but its own invention.
2021-04-14 17:51:11 +02:00
Fabian Dill
73bc5fb376 restore sanity check for exported technologies 2021-04-14 04:19:44 +02:00
Fabian Dill
a7c9474a37 Factorio: fix sending 2021-04-14 04:14:37 +02:00
Fabian Dill
0cf9baef4b Factorio: add document visibility option to playerSettings.yaml 2021-04-14 02:45:36 +02:00
Fabian Dill
6a06117786 Factorio: use game.print 2021-04-13 20:09:26 +02:00
Fabian Dill
ee30914b2c Send AP text into Factorio worlds 2021-04-13 14:49:32 +02:00
Fabian Dill
a995627e98 Factorio: default to visible tech tree 2021-04-13 12:38:39 +02:00
Fabian Dill
7884c6cd97 Factorio: add mod thumbnail.png 2021-04-13 12:36:40 +02:00
Fabian Dill
4fe10b88b3 Factorio: move info dump to https://mods.factorio.com/mod/archipelago-extractor 2021-04-13 12:35:42 +02:00
Fabian Dill
b7327138f3 Factorio: Show item source and enable research queue 2021-04-13 11:14:05 +02:00
Fabian Dill
433981fd3d pass explicit seed_name from MultiMystery.py 2021-04-12 09:45:07 +02:00
Fabian Dill
2df7e4e537 add seed_name to multidata and RoomInfo 2021-04-12 09:36:45 +02:00
Fabian Dill
764e6e7926 Fix MultiTracker breaking after Hint is used 2021-04-12 00:06:27 +02:00
Fabian Dill
4292cdddd5 Factorio: add Funnel tech shape 2021-04-11 18:19:47 +02:00
Fabian Dill
9aef76767a Include example trigger for legacy weapons 2021-04-11 15:53:13 +02:00
Fabian Dill
3858a12f26 MultiServer: check for correct game 2021-04-10 21:08:01 +02:00
Fabian Dill
1943586221 Factorio: add medium_diamonds and pyramid tech tree layouts 2021-04-10 19:34:30 +02:00
Fabian Dill
6d15aef88a Factorio: align tech tree sections in growing ingredient requirements 2021-04-10 18:45:11 +02:00
Chris Wilson
50f06c3aac Add "swords" option back to playerSettings.yaml 2021-04-10 12:31:04 -04:00
Fabian Dill
7f3c46dd8a Factorio: Allow connecting and entering slot name in one command; /connect server_address slot_name 2021-04-10 15:29:56 +02:00
Fabian Dill
ea15f221ae various fixes to WebHost 2021-04-10 15:26:30 +02:00
Fabian Dill
d4b422840a Fix dynamic world attributes not updating 2021-04-10 06:36:06 +02:00
Fabian Dill
0586b24579 Factorio: add small_diamonds tech tree layout 2021-04-10 03:03:46 +02:00
Fabian Dill
e11016b0a2 fix _done having ingredient letters instead of starting name 2021-04-10 00:21:56 +02:00
Fabian Dill
74a368458e dynamically mark advancement technologies 2021-04-10 00:17:55 +02:00
Fabian Dill
1b70d485c0 shortcut logic for requirement-less technologies 2021-04-10 00:08:59 +02:00
Fabian Dill
2355f9c8d3 Only apply logic for allowed science pack 2021-04-09 22:16:55 +02:00
Fabian Dill
ceea55e3c6 traverse recipe tree for Factorio logic 2021-04-09 22:10:04 +02:00
Fabian Dill
c4d6ac50be turn weapons into boolean swordless 2021-04-09 20:40:45 +02:00
Fabian Dill
4461cb67f0 fix ap-sync and remove infinite techs from randomization 2021-04-09 00:33:32 +02:00
Fabian Dill
f0a6b5a8e4 Factorio:
add visibility option
fix tech_cost using the wrong variable name
fix yaml defaults not init'ing the Option class
LttP:
fix potential pathing confusion in maseya palette shuffler
Server:
Minimum version per team made no sense, removed
2021-04-08 19:53:24 +02:00
Fabian Dill
443fc03700 Send actual NetworkPlayer on Connected too 2021-04-07 02:49:36 +02:00
Fabian Dill
6567f14415 add log_network Server argument 2021-04-07 02:37:21 +02:00
Fabian Dill
32560eac92 send actual NetworkPlayers 2021-04-07 02:20:03 +02:00
Fabian Dill
4c71662719 factorio: award free samples to entire force 2021-04-07 01:55:53 +02:00
Fabian Dill
96a28ed41e implement Factorio option "free_samples" 2021-04-06 21:16:25 +02:00
Fabian Dill
bc1d0ed583 Update Factorio mod to give free samples
(for now always, probably an option later)
2021-04-06 02:20:13 +02:00
Fabian Dill
635897574f clean up technology handling a bit 2021-04-05 15:37:15 +02:00
Fabian Dill
0eca0b2209 add jinja 2 requirement 2021-04-04 11:27:37 +02:00
Fabian Dill
20b72369d8 allow basic WebHost functionality to work 2021-04-04 03:18:19 +02:00
Fabian Dill
d451145d53 Merge branch 'main' into Archipelago_Main 2021-04-04 03:17:46 +02:00
Fabian Dill
4ab59d522d Make bridge notification less spammy 2021-04-04 01:19:54 +02:00
Fabian Dill
250099f5fd Small adjustments 2021-04-03 20:02:15 +02:00
Fabian Dill
c14a150795 Output Factorio mod as zip 2021-04-03 15:06:32 +02:00
Fabian Dill
91bcd59940 implement Factorio options max_science_pack and tech_cost
also give warnings about deprecated LttP options
also fix FactorioClient.py getting stuck if send an unknown item id
also fix !missing having an extra newline after each entry
also default to no webui
2021-04-03 14:47:49 +02:00
Fabian Dill
b871a688a4 correctly add 4 bows to easy item pool
(found by el0)
2021-04-02 14:55:39 +02:00
Fabian Dill
d225eb9ca8 update readme links 2021-04-01 20:46:43 +02:00
584 changed files with 12534 additions and 24335 deletions

115
.gitignore vendored
View File

@@ -12,14 +12,16 @@
*.db3
*multidata
*multisave
*.archipelago
*.apsave
build
/build_factorio/
bundle/components.wxs
dist
README.html
.vs/
EnemizerCLI/
.mypy_cache/
RaceRom.py
weights/
/MultiMystery/
@@ -35,4 +37,113 @@ mystery_result_*.yaml
success.txt
output/
Output Logs/
/factorio/
/factorio/
/WebHostLib/static/generated
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
*.dll
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
Archipelago.zip

View File

@@ -6,7 +6,7 @@ import logging
import json
import functools
from collections import OrderedDict, Counter, deque
from typing import *
from typing import List, Dict, Optional, Set, Iterable, Union, Any
import secrets
import random
@@ -14,19 +14,26 @@ import random
class MultiWorld():
debug_types = False
player_names: Dict[int, List[str]]
_region_cache: dict
_region_cache: Dict[int, Dict[str, Region]]
difficulty_requirements: dict
required_medallions: dict
dark_room_logic: Dict[int, str]
restrict_dungeon_item_on_boss: Dict[int, bool]
plando_texts: List[Dict[str, str]]
plando_items: List[PlandoItem]
plando_connections: List[PlandoConnection]
plando_items: List
plando_connections: List
er_seeds: Dict[int, str]
worlds: Dict[int, Any]
is_race: bool = False
class AttributeProxy():
def __init__(self, rule):
self.rule = rule
def __getitem__(self, player) -> bool:
return self.rule(player)
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.players = players
self.teams = 1
@@ -37,6 +44,7 @@ class MultiWorld():
self.shops = []
self.itempool = []
self.seed = None
self.seed_name: str = "Unavailable"
self.precollected_items = []
self.state = CollectionState(self)
self._cached_entrances = None
@@ -56,15 +64,21 @@ class MultiWorld():
self.dynamic_regions = []
self.dynamic_locations = []
self.spoiler = Spoiler(self)
self.fix_trock_doors = self.AttributeProxy(lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
self.fix_skullwoods_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.NOTCURSED = self.AttributeProxy(lambda player: not self.CURSED[player])
for player in range(1, players + 1):
def set_player_attr(attr, val):
self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('tech_tree_layout_prerequisites', {})
set_player_attr('_region_cache', {})
set_player_attr('shuffle', "vanilla")
set_player_attr('logic', "noglitches")
set_player_attr('mode', 'open')
set_player_attr('swords', 'random')
set_player_attr('swordless', False)
set_player_attr('difficulty', 'normal')
set_player_attr('item_functionality', 'normal')
set_player_attr('timer', False)
@@ -74,17 +88,11 @@ class MultiWorld():
set_player_attr('retro', False)
set_player_attr('hints', True)
set_player_attr('player_names', [])
set_player_attr('remote_items', False)
set_player_attr('required_medallions', ['Ether', 'Quake'])
set_player_attr('swamp_patch_required', False)
set_player_attr('powder_patch_required', False)
set_player_attr('ganon_at_pyramid', True)
set_player_attr('ganonstower_vanilla', True)
set_player_attr('sewer_light_cone', self.mode[player] == 'standard')
set_player_attr('fix_trock_doors', self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
set_player_attr('fix_skullwoods_exit', self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
set_player_attr('fix_palaceofdarkness_exit', self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
set_player_attr('fix_trock_exit', self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
set_player_attr('can_access_trock_eyebridge', None)
set_player_attr('can_access_trock_front', None)
set_player_attr('can_access_trock_big_chest', None)
@@ -104,8 +112,6 @@ class MultiWorld():
set_player_attr('bush_shuffle', False)
set_player_attr('beemizer', 0)
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('treasure_hunt_icon', 'Triforce Piece')
set_player_attr('treasure_hunt_count', 0)
@@ -122,7 +128,6 @@ class MultiWorld():
set_player_attr('triforce_pieces_available', 30)
set_player_attr('triforce_pieces_required', 20)
set_player_attr('shop_shuffle', 'off')
set_player_attr('shop_shuffle_slots', 0)
set_player_attr('shuffle_prizes', "g")
set_player_attr('sprite_pool', [])
set_player_attr('dark_room_logic', "lamp")
@@ -132,36 +137,29 @@ class MultiWorld():
set_player_attr('plando_connections', [])
set_player_attr('game', "A Link to the Past")
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.worlds = {}
self.worlds = []
#for i in range(players):
# self.worlds.append(worlds.alttp.ALTTPWorld({}, i))
@property
def NOTCURSED(self): # not here to stay
return {player: not cursed for player, cursed in self.CURSED.items()}
def set_options(self, args):
from worlds import AutoWorld
for player in self.player_ids:
self.custom_data[player] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
for option in world_type.options:
setattr(self, option, getattr(args, option, {}))
self.worlds[player] = world_type(self, player)
def secure(self):
self.random = secrets.SystemRandom()
self.is_race = True
@property
@functools.cached_property
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")
@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:
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_names(obj.player)})'
@@ -226,52 +224,12 @@ class MultiWorld():
def get_all_state(self, keys=False) -> CollectionState:
ret = CollectionState(self)
def soft_collect(item):
if item.name.startswith('Progressive '):
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:
soft_collect(item)
self.worlds[item.player].collect(ret, item)
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
for item in ItemFactory(
['Small Key (Hyrule Castle)', 'Big Key (Eastern Palace)', 'Big Key (Desert Palace)',
@@ -286,7 +244,7 @@ class MultiWorld():
'Small Key (Misery Mire)'] * 3 + ['Small Key (Turtle Rock)'] * 4 + [
'Small Key (Ganons Tower)'] * 4,
p):
soft_collect(item)
world.collect(ret, item)
ret.sweep_for_events()
return ret
@@ -301,6 +259,8 @@ class MultiWorld():
return next(location for location in self.get_locations() if
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):
item.world = self
@@ -317,7 +277,7 @@ class MultiWorld():
if location.can_fill(self.state, item, False):
location.item = item
item.location = location
item.world = self # try to not have this here anymore
item.world = self # try to not have this here anymore
if collect:
self.state.collect(item, location.event, location)
@@ -399,24 +359,24 @@ class MultiWorld():
if self.has_beaten_game(self.state):
return True
state = CollectionState(self)
prog_locations = {location for location in self.get_locations() if location.item is not None and (
location.item.advancement or location.event) and location not in state.locations_checked}
prog_locations = {location for location in self.get_locations() if location.item
and location.item.advancement and location not in state.locations_checked}
while prog_locations:
sphere = []
sphere = set()
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
for location in prog_locations:
if location.can_reach(state):
sphere.append(location)
sphere.add(location)
if not sphere:
# ran out of places and did not finish yet, quit
return False
for location in sphere:
prog_locations.remove(location)
state.collect(location.item, True, location)
prog_locations -= sphere
if self.has_beaten_game(state):
return True
@@ -592,31 +552,25 @@ class CollectionState(object):
def has(self, item, player: int, count: int = 1):
return self.prog_items[item, player] >= count
def has_essence(self, player: int, count: int):
return self.prog_items["Dream_Nail", player]
# return self.prog_items["Essence", player] >= count
def has_all(self, items: Set[str], player:int):
return all(self.prog_items[item, player] for item in items)
def has_grubs(self, player: int, count: int):
from worlds.hk import Items as HKItems
found = 0
def has_any(self, items: Set[str], player:int):
return any(self.prog_items[item, player] for item in items)
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]
if found >= count:
return True
return False
def has_flames(self, player: int, count: int):
from worlds.hk import Items as HKItems
found = 0
for item_name in HKItems.lookup_type_to_names["Flame"]:
def count_group(self, item_name_group: str, player: int):
found: int = 0
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
found += self.prog_items[item_name, player]
if found >= count:
return True
return False
return found
def has_key(self, item, player, count: int = 1):
if self.world.logic[player] == 'nologic':
@@ -650,25 +604,9 @@ class CollectionState(object):
def can_lift_rocks(self, player: int):
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:
found: int = 0
for bottlename in item_name_groups["Bottles"]:
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
return min(self.world.difficulty_requirements[player].progressive_bottle_limit,
self.count_group("Bottles", player))
def has_hearts(self, player: int, count: int) -> int:
# Warning: This only considers items that are marked as advancement items
@@ -717,7 +655,7 @@ class CollectionState(object):
def can_get_good_bee(self, player: int) -> bool:
cave = self.world.get_region('Good Bee Cave', player)
return (
self.has_bottle(player) and
self.has_group("Bottles", 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
cave.can_reach(self) and
@@ -726,7 +664,7 @@ class CollectionState(object):
def can_retrieve_tablet(self, player:int) -> bool:
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
(self.world.swords[player] == "swordless" and
(self.world.swordless[player] and
self.has("Hammer", player)))
def has_sword(self, player: int) -> bool:
@@ -747,7 +685,7 @@ class CollectionState(object):
def can_melt_things(self, player: int) -> bool:
return self.has('Fire Rod', player) or \
(self.has('Bombos', player) and
(self.world.swords[player] == "swordless" or
(self.world.swordless[player] or
self.has_sword(player)))
def can_avoid_lasers(self, player: int) -> bool:
@@ -800,63 +738,16 @@ class CollectionState(object):
rules.append(self.has('Moon Pearl', player))
return all(rules)
def can_bomb_clip(self, region: Region, player: int) -> bool:
return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player)
def collect(self, item: Item, event: bool = False, location: Location = None) -> bool:
if location:
self.locations_checked.add(location)
changed = False
# TODO: create a mapping for progressive items in each game and use that
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
changed = self.world.worlds[item.player].collect(self, item)
if not changed and (event or item.advancement):
if not changed and event:
self.prog_items[item.name, item.player] += 1
changed = True
@@ -870,7 +761,7 @@ class CollectionState(object):
def remove(self, item):
if item.advancement:
to_remove = item.name
if to_remove.startswith('Progressive '):
if item.game == "A Link to the Past" and to_remove.startswith('Progressive '):
if 'Sword' in to_remove:
if self.has('Golden Sword', item.player):
to_remove = 'Golden Sword'
@@ -897,7 +788,7 @@ class CollectionState(object):
elif self.has('Blue Shield', item.player):
to_remove = 'Blue Shield'
else:
to_remove = 'None'
to_remove = None
elif 'Bow' in item.name:
if self.has('Silver Bow', item.player):
to_remove = 'Silver Bow'
@@ -906,7 +797,7 @@ class CollectionState(object):
else:
to_remove = None
if to_remove is not None:
if to_remove:
self.prog_items[to_remove, item.player] -= 1
if self.prog_items[to_remove, item.player] < 1:
@@ -917,7 +808,8 @@ class CollectionState(object):
self.stale[item.player] = True
@unique
class RegionType(Enum):
class RegionType(int, Enum):
Generic = 0
LightWorld = 1
DarkWorld = 2
Cave = 3 # Also includes Houses
@@ -931,7 +823,7 @@ class RegionType(Enum):
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.type = type
self.entrances = []
@@ -939,15 +831,14 @@ class Region(object):
self.locations = []
self.dungeon = None
self.shop = None
self.world = None
self.world = world
self.is_light_world = False # will be set after making connections.
self.is_dark_world = False
self.spot_type = 'Region'
self.hint_text = hint
self.recursion_count = 0
self.player = player
def can_reach(self, state):
def can_reach(self, state: CollectionState):
if state.stale[self.player]:
state.update_reachable_regions(self.player)
return self in state.reachable_regions[self.player]
@@ -976,6 +867,7 @@ class Region(object):
class Entrance(object):
spot_type = 'Entrance'
def __init__(self, player: int, name: str = '', parent=None):
self.name = name
@@ -983,9 +875,6 @@ class Entrance(object):
self.connected_region = None
self.target = None
self.addresses = None
self.spot_type = 'Entrance'
self.recursion_count = 0
self.vanilla = None
self.access_rule = lambda state: True
self.player = player
self.hide_path = False
@@ -998,11 +887,10 @@ class Entrance(object):
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.target = target
self.addresses = addresses
self.vanilla = vanilla
region.entrances.append(self)
def __repr__(self):
@@ -1075,17 +963,16 @@ class Location():
spot_type = 'Location'
game: str = "Generic"
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):
self.name = name
self.address = address
self.name: str = name
self.address: Optional[int] = address
self.parent_region: Region = parent
self.recursion_count = 0
self.player = player
self.item = None
self.always_allow = lambda item, state: False
self.access_rule = lambda state: True
self.item_rule = lambda item: True
self.player: int = player
self.item: Optional[Item] = None
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)))
@@ -1096,6 +983,14 @@ class Location():
return True
return False
def place_locked_item(self, item: Item):
if self.item:
raise Exception(f"Location {self} already filled.")
self.item = item
self.event = item.advancement
self.item.world = self.parent_region.world
self.locked = True
def __repr__(self):
return self.__str__()
@@ -1109,22 +1004,30 @@ class Location():
def __lt__(self, other):
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
def hint_text(self):
return getattr(self, "_hint_text", self.name)
return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
class Item():
location: Optional[Location] = None
world: Optional[MultiWorld] = None
game: str = "Generic"
type: str = None
pedestal_credit_text = "and the Unknown Item"
sickkid_credit_text = None
magicshop_credit_text = None
zora_credit_text = None
fluteboy_credit_text = None
never_exclude = False # change manually to ensure that a specific nonprogression item never goes on an excluded location
pedestal_credit_text: str = "and the Unknown Item"
sickkid_credit_text: Optional[str] = None
magicshop_credit_text: Optional[str] = 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: int, player: int):
def __init__(self, name: str, advancement: bool, code: Optional[int], player: int):
self.name = name
self.advancement = advancement
self.player = player
@@ -1132,11 +1035,11 @@ class Item():
@property
def hint_text(self):
return getattr(self, "_hint_text", self.name)
return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
@property
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):
return self.name == other.name and self.player == other.player
@@ -1226,7 +1129,7 @@ class Spoiler(object):
def parse_data(self):
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'Turtle Rock ({self.world.get_player_names(player)})'] = self.world.required_medallions[player][1]
@@ -1282,7 +1185,7 @@ class Spoiler(object):
shopdata['item_{}'.format(index)] += ", {} - {}".format(item['replacement'], item['replacement_price']) if item['replacement_price'] else item['replacement']
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)]["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
@@ -1313,13 +1216,11 @@ class Spoiler(object):
'dark_room_logic': self.world.dark_room_logic,
'mode': self.world.mode,
'retro': self.world.retro,
'weapons': self.world.swords,
'swordless': self.world.swordless,
'goal': self.world.goal,
'shuffle': self.world.shuffle,
'item_pool': self.world.difficulty,
'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,
'accessibility': self.world.accessibility,
'hints': self.world.hints,
@@ -1343,7 +1244,6 @@ class Spoiler(object):
'triforce_pieces_available': self.world.triforce_pieces_available,
'triforce_pieces_required': self.world.triforce_pieces_required,
'shop_shuffle': self.world.shop_shuffle,
'shop_shuffle_slots': self.world.shop_shuffle_slots,
'shuffle_prizes': self.world.shuffle_prizes,
'sprite_pool': self.world.sprite_pool,
'restrict_dungeon_item_on_boss': self.world.restrict_dungeon_item_on_boss,
@@ -1370,6 +1270,7 @@ class Spoiler(object):
return json.dumps(out)
def to_file(self, filename):
import Options
self.parse_data()
def bool_to_text(variable: Union[bool, str]) -> str:
@@ -1392,16 +1293,17 @@ class Spoiler(object):
outfile.write('Progression Balanced: %s\n' % (
'Yes' if self.metadata['progression_balancing'][player] else 'No'))
outfile.write('Accessibility: %s\n' % self.metadata['accessibility'][player])
if player in self.world.hk_player_ids:
import Options
for hk_option in Options.hollow_knight_options:
res = getattr(self.world, hk_option)[player]
outfile.write(f'{hk_option+":":33}{res}\n')
if player in self.world.alttp_player_ids:
options = self.world.worlds[player].options
if options:
for f_option in options:
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')
if player in self.world.get_game_players("A Link to the Past"):
for team in range(self.world.teams):
outfile.write('%s%s\n' % (
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]))
outfile.write('Logic: %s\n' % self.metadata['logic'][player])
@@ -1412,7 +1314,7 @@ class Spoiler(object):
outfile.write('Mode: %s\n' % self.metadata['mode'][player])
outfile.write('Retro: %s\n' %
('Yes' if self.metadata['retro'][player] else 'No'))
outfile.write('Swords: %s\n' % self.metadata['weapons'][player])
outfile.write('Swordless: %s\n' % ('Yes' if self.metadata['swordless'][player] else 'No'))
outfile.write('Goal: %s\n' % self.metadata['goal'][player])
if "triforce" in self.metadata["goal"][player]: # triforce hunt
outfile.write("Pieces available for Triforce: %s\n" %
@@ -1425,8 +1327,6 @@ class Spoiler(object):
outfile.write('Entrance Shuffle: %s\n' % self.metadata['shuffle'][player])
if self.metadata['shuffle'][player] != "vanilla":
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' % (
'Yes' if self.metadata['open_pyramid'][player] else 'No'))
@@ -1449,8 +1349,6 @@ class Spoiler(object):
"f" in self.metadata["shop_shuffle"][player]))
outfile.write('Custom Potion Shop: %s\n' %
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(
'Enemy shuffle: %s\n' % bool_to_text(self.metadata['enemy_shuffle'][player]))
@@ -1473,17 +1371,31 @@ class Spoiler(object):
'<=>' if entry['direction'] == 'both' else
'<=' if entry['direction'] == 'exit' else '=>',
entry['exit']) for entry in self.entrances.values()]))
outfile.write('\n\nMedallions:\n')
for dungeon, medallion in self.medallions.items():
outfile.write(f'\n{dungeon}: {medallion}')
if self.medallions:
outfile.write('\n\nMedallions:\n')
for dungeon, medallion in self.medallions.items():
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:
outfile.write('\n\nStarting Inventory:\n\n')
outfile.write('\n'.join(self.startinventory))
outfile.write('\n\nLocations:\n\n')
outfile.write('\n'.join(['%s: %s' % (location, item) for grouping in self.locations.values() for (location, item) in grouping.items()]))
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))
for player in range(1, self.world.players + 1):
if self.shops:
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))
for player in self.world.get_game_players("A Link to the Past"):
if self.world.boss_shuffle[player] != 'none':
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')
@@ -1493,19 +1405,17 @@ class Spoiler(object):
if self.unreachables:
outfile.write('\n\nUnreachable Items:\n\n')
outfile.write('\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))
outfile.write('\n\nPaths:\n\n')
path_listings = []
for location, path in sorted(self.paths.items()):
path_lines = []
for region, exit in path:
if exit is not None:
path_lines.append("{} -> {}".format(region, exit))
else:
path_lines.append(region)
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
if self.paths:
outfile.write('\n\nPaths:\n\n')
path_listings = []
for location, path in sorted(self.paths.items()):
path_lines = []
for region, exit in path:
if exit is not None:
path_lines.append("{} -> {}".format(region, exit))
else:
path_lines.append(region)
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
outfile.write('\n'.join(path_listings))
from worlds.alttp.Items import item_name_groups
from worlds.generic import PlandoItem, PlandoConnection
outfile.write('\n'.join(path_listings))

View File

@@ -4,20 +4,13 @@ import typing
import asyncio
import urllib.parse
import prompt_toolkit
import websockets
from prompt_toolkit.patch_stdout import patch_stdout
import Utils
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, color, ClientStatus
from Utils import Version
# logging note:
# logging.* gets send to only the text console, logger.* gets send to the WebUI as well, if it's initialized.
from worlds import network_data_package
from worlds.alttp import Items, Regions
from worlds import network_data_package, AutoWorldRegister
logger = logging.getLogger("Client")
@@ -47,23 +40,16 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_received(self) -> bool:
"""List all received items"""
logger.info('Received items:')
logger.info(f'{len(self.ctx.items_received)} received items:')
for index, item in enumerate(self.ctx.items_received, 1):
self.ctx.ui_node.notify_item_received(self.ctx.player_names[item.player], self.ctx.item_name_getter(item.item),
self.ctx.location_name_getter(item.location), index,
len(self.ctx.items_received),
self.ctx.item_name_getter(item.item) in Items.progression_items)
logging.info('%s from %s (%s) (%d/%d in list)' % (
color(self.ctx.item_name_getter(item.item), 'red', 'bold'),
color(self.ctx.player_names[item.player], 'yellow'),
self.ctx.location_name_getter(item.location), index, len(self.ctx.items_received)))
self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
return True
def _cmd_missing(self) -> bool:
"""List all missing location checks, from your local game state"""
count = 0
checked_count = 0
for location, location_id in Regions.lookup_name_to_id.items():
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id.items():
if location_id < 0:
continue
if location_id not in self.ctx.locations_checked:
@@ -101,7 +87,9 @@ class CommonContext():
starting_reconnect_delay = 5
current_reconnect_delay = starting_reconnect_delay
command_processor = ClientCommandProcessor
def __init__(self, server_address, password, found_items: bool):
game: None
def __init__(self, server_address, password):
# server state
self.server_address = server_address
self.password = password
@@ -112,11 +100,10 @@ class CommonContext():
# own state
self.finished_game = False
self.ready = False
self.found_items = found_items
self.team = None
self.slot = None
self.auth = None
self.ui_node = None
self.seed_name = None
self.locations_checked: typing.Set[int] = set()
self.locations_scouted: typing.Set[int] = set()
@@ -129,7 +116,7 @@ class CommonContext():
self.input_requests = 0
# game state
self.player_names: typing.Dict[int: str] = {}
self.player_names: typing.Dict[int: str] = {0: "Archipelago"}
self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event()
@@ -184,7 +171,6 @@ class CommonContext():
async def disconnect(self):
if self.server and not self.server.socket.closed:
await self.server.socket.close()
self.ui_node.send_connection_status(self)
if self.server_task is not None:
await self.server_task
@@ -195,6 +181,7 @@ class CommonContext():
def consume_players_package(self, package: typing.List[tuple]):
self.player_names = {slot: name for team, slot, name, orig_name in package if self.team == team}
self.player_names[0] = "Archipelago"
def event_invalid_slot(self):
raise Exception('Invalid Slot; please verify that you have connected to the correct world.')
@@ -213,11 +200,14 @@ class CommonContext():
await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address))
def on_print(self, args: dict):
logger.info(args["text"])
def on_print_json(self, args: dict):
logger.info(self.jsontotextparser(args["data"]))
async def server_loop(ctx: CommonContext, address=None):
ui_node = getattr(ctx, "ui_node", None)
if ui_node:
ui_node.send_connection_status(ctx)
cached_address = None
if ctx.server and ctx.server.socket:
logger.error('Already connected')
@@ -229,8 +219,6 @@ async def server_loop(ctx: CommonContext, address=None):
# Wait for the user to provide a multiworld server address
if not address:
logger.info('Please connect to an Archipelago server.')
if ui_node:
ui_node.poll_for_server_ip()
return
address = f"ws://{address}" if "://" not in address else address
@@ -242,8 +230,6 @@ async def server_loop(ctx: CommonContext, address=None):
ctx.server = Endpoint(socket)
logger.info('Connected')
ctx.server_address = address
if ui_node:
ui_node.send_connection_status(ctx)
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
async for data in ctx.server.socket:
for msg in decode(data):
@@ -265,8 +251,6 @@ async def server_loop(ctx: CommonContext, address=None):
await ctx.connection_closed()
if ctx.server_address:
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
if ui_node:
ui_node.send_connection_status(ctx)
asyncio.create_task(server_autoreconnect(ctx))
ctx.current_reconnect_delay *= 2
@@ -284,41 +268,42 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.exception(f"Could not get command from {args}")
raise
if cmd == 'RoomInfo':
logger.info('--------------------------------')
logger.info('Room Information:')
logger.info('--------------------------------')
version = args["version"]
ctx.server_version = tuple(version)
version = ".".join(str(item) for item in version)
logger.info(f'Server protocol version: {version}')
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
if args['password']:
logger.info('Password required')
logger.info(f"Forfeit setting: {args['forfeit_mode']}")
logger.info(f"Remaining setting: {args['remaining_mode']}")
logger.info(f"A !hint costs {args['hint_cost']} points and you get {args['location_check_points']}"
f" for each location checked.")
ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points'])
ctx.forfeit_mode = args['forfeit_mode']
ctx.remaining_mode = args['remaining_mode']
if ctx.ui_node:
ctx.ui_node.send_game_info(ctx)
if len(args['players']) < 1:
logger.info('No player connected')
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
logger.info("The server is running a different multiworld than your client is. (invalid seed_name)")
else:
args['players'].sort()
current_team = -1
logger.info('Players:')
for team, slot, name in args['players']:
if team != current_team:
logger.info(f' Team #{team + 1}')
current_team = team
logger.info(' %s (Player %d)' % (name, slot))
if args["datapackage_version"] > network_data_package["version"]:
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
await ctx.server_auth(args['password'])
logger.info('--------------------------------')
logger.info('Room Information:')
logger.info('--------------------------------')
version = args["version"]
ctx.server_version = tuple(version)
version = ".".join(str(item) for item in version)
logger.info(f'Server protocol version: {version}')
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
if args['password']:
logger.info('Password required')
logger.info(f"Forfeit setting: {args['forfeit_mode']}")
logger.info(f"Remaining setting: {args['remaining_mode']}")
logger.info(f"A !hint costs {args['hint_cost']}% of checks points and you get {args['location_check_points']}"
f" for each location checked. Use !hint for more information.")
ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points'])
ctx.forfeit_mode = args['forfeit_mode']
ctx.remaining_mode = args['remaining_mode']
if len(args['players']) < 1:
logger.info('No player connected')
else:
args['players'].sort()
current_team = -1
logger.info('Players:')
for network_player in args['players']:
if network_player.team != current_team:
logger.info(f' Team #{network_player.team + 1}')
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
logger.info("Got new ID/Name Datapackage")
@@ -338,9 +323,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.error('Invalid password')
ctx.password = None
await ctx.server_auth(True)
else:
elif errors:
raise Exception("Unknown connection errors: " + str(errors))
raise Exception('Connection refused by the multiworld host, no reason provided')
else:
raise Exception('Connection refused by the multiworld host, no reason provided')
elif cmd == 'Connected':
ctx.team = args["team"]
@@ -356,7 +342,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
if msgs:
await ctx.send_msgs(msgs)
if ctx.finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": CLientStatus.CLIENT_GOAL}])
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
# Get the server side view of missing as of time of connecting.
# This list is used to only send to the server what is reported as ACTUALLY Missing.
@@ -394,15 +380,13 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.hint_points = args['hint_points']
elif cmd == 'Print':
logger.info(args["text"])
ctx.on_print(args)
elif cmd == 'PrintJSON':
if not ctx.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(ctx.jsontotextparser(args["data"]))
ctx.on_print_json(args)
elif cmd == 'InvalidArguments':
logger.warning(f"Invalid Arguments: {args['text']}")
elif cmd == 'InvalidPacket':
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
else:
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

@@ -1,43 +1,140 @@
from __future__ import annotations
import os
import logging
import json
import string
from concurrent.futures import ThreadPoolExecutor
import copy
import sys
import subprocess
import factorio_rcon
import colorama
import asyncio
from queue import Queue, Empty
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor
from queue import Queue
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger
from MultiServer import mark_raw
import Utils
import random
from NetUtils import RawJSONtoTextParser, NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
from worlds.factorio.Technologies import lookup_id_to_name
rcon_port = 24242
rcon_password = ''.join(random.choice(string.ascii_letters) for x in range(32))
save_name = "Archipelago"
os.makedirs("logs", exist_ok=True)
server_args = (save_name, "--rcon-port", rcon_port, "--rcon-password", rcon_password)
# Log to file in gui case
if getattr(sys, "frozen", False) and not "--nogui" in sys.argv:
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"))
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO)
options = Utils.get_options()
executable = options["factorio_options"]["executable"]
bin_dir = os.path.dirname(executable)
if not os.path.isdir(bin_dir):
raise FileNotFoundError(bin_dir)
if not os.path.exists(executable):
if os.path.exists(executable + ".exe"):
executable = executable + ".exe"
else:
raise FileNotFoundError(executable)
gui_enabled = Utils.is_frozen() or "--nogui" not in sys.argv
script_folder = options["factorio_options"]["script-output"]
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
threadpool = ThreadPoolExecutor(10)
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):
ctx: FactorioContext
@mark_raw
def _cmd_factorio(self, text: str) -> bool:
"""Send the following command to the bound Factorio Server."""
@@ -48,59 +145,98 @@ class FactorioCommandProcessor(ClientCommandProcessor):
return True
return False
def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server"""
if not self.ctx.auth:
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)
class FactorioContext(CommonContext):
command_processor = FactorioCommandProcessor
game = "Factorio"
def __init__(self, *args, **kwargs):
super(FactorioContext, self).__init__(*args, **kwargs)
def __init__(self, server_address, password):
super(FactorioContext, self).__init__(server_address, password)
self.send_index = 0
self.rcon_client = None
self.awaiting_bridge = False
self.raw_json_text_parser = RawJSONtoTextParser(self)
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
async def server_auth(self, password_requested):
if password_requested and not self.password:
await super(FactorioContext, self).server_auth(password_requested)
if self.auth is None:
logging.info('Enter the name of your slot to join this game:')
self.auth = await self.console_input()
await self.send_msgs([{"cmd": 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils._version_tuple,
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': ['AP'],
'uuid': Utils.get_unique_identifier(), 'game': "Factorio"
}])
def on_print(self, args: dict):
logger.info(args["text"])
if self.rcon_client:
cleaned_text = args['text'].replace('"', '')
self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] "
f"{cleaned_text}\")")
def on_print_json(self, args: dict):
text = self.raw_json_text_parser(copy.deepcopy(args["data"]))
logger.info(text)
if self.rcon_client:
text = self.factorio_json_text_parser(args["data"])
cleaned_text = text.replace('"', '')
self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] "
f"{cleaned_text}\")")
@property
def savegame_name(self) -> str:
return f"AP_{self.seed_name}_{self.auth}.zip"
async def game_watcher(ctx: FactorioContext):
research_logger = logging.getLogger("FactorioWatcher")
researches_done_file = os.path.join(script_folder, "research_done.json")
if os.path.exists(researches_done_file):
os.remove(researches_done_file)
from worlds.factorio.Technologies import lookup_id_to_name, tech_table
bridge_logger = logging.getLogger("FactorioWatcher")
from worlds.factorio.Technologies import lookup_id_to_name
try:
while 1:
if os.path.exists(researches_done_file):
research_logger.info("Found Factorio Bridge file.")
while 1:
with open(researches_done_file) as f:
data = json.load(f)
research_data = {int(tech_name.split("-")[1]) for tech_name in data if tech_name.startswith("ap-")}
while not ctx.exit_event.is_set():
if ctx.awaiting_bridge and ctx.rcon_client:
ctx.awaiting_bridge = False
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
if data["slot_name"] != ctx.auth:
logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}")
elif data["seed_name"] != ctx.seed_name:
logger.warning(
f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}")
else:
data = data["info"]
research_data = data["research_done"]
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
if ctx.locations_checked != research_data:
research_logger.info(f"New researches done: "
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
bridge_logger.info(
f"New researches done: "
f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
await asyncio.sleep(1)
else:
research_logger.info("Did not find Factorio Bridge file.")
await asyncio.sleep(5)
await asyncio.sleep(1)
except Exception as e:
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
def stream_factorio_output(pipe, queue):
def stream_factorio_output(pipe, queue, process):
def queuer():
while 1:
while process.poll() is None:
text = pipe.readline().strip()
if text:
queue.put_nowait(text)
@@ -109,80 +245,202 @@ def stream_factorio_output(pipe, queue):
thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True)
thread.start()
return thread
async def factorio_server_watcher(ctx: FactorioContext):
import subprocess
import factorio_rcon
factorio_server_logger = logging.getLogger("FactorioServer")
factorio_process = subprocess.Popen((executable, "--start-server", *(str(elem) for elem in server_args)),
savegame_name = os.path.abspath(ctx.savegame_name)
if not os.path.exists(savegame_name):
logger.info(f"Creating savegame {savegame_name}")
subprocess.run((
executable, "--create", savegame_name, "--preset", "archipelago"
))
factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name,
*(str(elem) for elem in server_args)),
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
encoding="utf-8")
factorio_server_logger.info("Started Factorio Server")
factorio_queue = Queue()
stream_factorio_output(factorio_process.stdout, factorio_queue)
stream_factorio_output(factorio_process.stderr, factorio_queue)
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
try:
while 1:
while not ctx.exit_event.is_set():
if factorio_process.poll():
factorio_server_logger.info("Factorio server has exited.")
ctx.exit_event.set()
while not factorio_queue.empty():
msg = factorio_queue.get()
factorio_server_logger.info(msg)
if not ctx.rcon_client and "Hosting game at IP ADDR:" in msg:
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
# trigger lua interface confirmation
ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')")
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
ctx.awaiting_bridge = True
if ctx.rcon_client:
while ctx.send_index < len(ctx.items_received):
item_id = ctx.items_received[ctx.send_index].item
item_name = lookup_id_to_name[item_id]
factorio_server_logger.info(f"Sending {item_name} to Nauvis.")
response = ctx.rcon_client.send_command(f'/ap-get-technology {item_name}')
if response:
factorio_server_logger.info(response)
transfer_item: NetworkItem = ctx.items_received[ctx.send_index]
item_id = transfer_item.item
player_name = ctx.player_names[transfer_item.player]
if item_id not in lookup_id_to_name:
logging.error(f"Cannot send unknown item ID: {item_id}")
else:
item_name = lookup_id_to_name[item_id]
factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.")
ctx.rcon_client.send_command(f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}')
ctx.send_index += 1
await asyncio.sleep(0.1)
await asyncio.sleep(1)
except Exception as e:
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
ctx.rcon_client = None
ctx.exit_event.set()
finally:
factorio_process.terminate()
factorio_process.wait(5)
async def main():
ctx = FactorioContext(None, None, True)
# testing shortcuts
# ctx.server_address = "localhost"
# ctx.auth = "Nauvis"
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
await asyncio.sleep(3)
watcher_task = asyncio.create_task(game_watcher(ctx), name="FactorioProgressionWatcher")
input_task = asyncio.create_task(console_loop(ctx), name="Input")
def get_info(ctx, rcon_client):
info = json.loads(rcon_client.send_command("/ap-rcon-info"))
ctx.auth = info["slot_name"]
ctx.seed_name = info["seed_name"]
async def factorio_spinup_server(ctx: FactorioContext):
savegame_name = os.path.abspath("Archipelago.zip")
if not os.path.exists(savegame_name):
logger.info(f"Creating savegame {savegame_name}")
subprocess.run((
executable, "--create", savegame_name
))
factorio_process = subprocess.Popen(
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
encoding="utf-8")
factorio_server_logger.info("Started Information Exchange Factorio Server")
factorio_queue = Queue()
stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process)
stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process)
rcon_client = None
try:
while not ctx.auth:
while not factorio_queue.empty():
msg = factorio_queue.get()
factorio_server_logger.info(msg)
if 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")
progression_watcher = asyncio.create_task(
game_watcher(ctx), name="FactorioProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
ctx.snes_reconnect_address = None
await asyncio.gather(watcher_task, 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()
if ctx.server_task is not None:
await ctx.server_task
await factorio_server_task
while ctx.input_requests > 0:
ctx.input_queue.put_nowait(None)
ctx.input_requests -= 1
await input_task
if ui_task:
await ui_task
if input_task:
input_task.cancel()
class FactorioJSONtoTextParser(JSONtoTextParser):
def _handle_color(self, node: JSONMessagePart):
colors = node["color"].split(";")
for color in colors:
if color in {"red", "green", "blue", "orange", "yellow", "pink", "purple", "white", "black", "gray",
"brown", "cyan", "acid"}:
node["text"] = f"[color={color}]{node['text']}[/color]"
return self._handle_text(node)
elif color == "magenta":
node["text"] = f"[color=pink]{node['text']}[/color]"
return self._handle_text(node)
return self._handle_text(node)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description="Optional arguments to FactorioClient follow. "
"Remaining arguments get passed into bound Factorio instance."
"Refer to factorio --help for those.")
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
parser.add_argument('--password', default=None, help='Password of the multiworld host.')
if not Utils.is_frozen(): # Frozen state has no cmd window in the first place
parser.add_argument('--nogui', default=False, action='store_true', help="Turns off Client GUI.")
args, rest = parser.parse_known_args()
colorama.init()
rcon_port = args.rcon_port
rcon_password = args.rcon_password if args.rcon_password else ''.join(random.choice(string.ascii_letters) for x in range(32))
factorio_server_logger = logging.getLogger("FactorioServer")
options = Utils.get_options()
executable = options["factorio_options"]["executable"]
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.run_until_complete(main())
loop.run_until_complete(main(args))
loop.close()
colorama.deinit()

156
Fill.py
View File

@@ -3,16 +3,17 @@ import typing
import collections
import itertools
from BaseClasses import CollectionState, PlandoItem, Location
from BaseClasses import CollectionState, Location, MultiWorld
from worlds.alttp.Items import ItemFactory
from worlds.alttp.Regions import key_drop_data
from worlds.generic import PlandoItem
class FillError(RuntimeError):
pass
def fill_restrictive(world, base_state: CollectionState, locations, itempool, single_player_placement=False,
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations, itempool, single_player_placement=False,
lock=False):
def sweep_from_pool():
new_state = base_state.copy()
@@ -68,7 +69,7 @@ def fill_restrictive(world, base_state: CollectionState, locations, itempool, si
itempool.extend(unplaced_items)
def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None):
def distribute_items_restrictive(world: MultiWorld, gftower_trash=False, fill_locations=None):
# If not passed in, then get a shuffled list of locations to fill in
if not fill_locations:
fill_locations = world.get_unfilled_locations()
@@ -77,12 +78,15 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
# get items to distribute
world.random.shuffle(world.itempool)
progitempool = []
nonexcludeditempool = []
localrestitempool = {player: [] for player in range(1, world.players + 1)}
restitempool = []
for item in world.itempool:
if item.advancement:
progitempool.append(item)
elif item.never_exclude: # this only gets nonprogression items which should not appear in excluded locations
nonexcludeditempool.append(item)
elif item.name in world.local_items[item.player]:
localrestitempool[item.player].append(item)
else:
@@ -91,9 +95,9 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
standard_keyshuffle_players = set()
# 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 \
world.logic[player] in {'owglitches', "nologic"}:
world.logic[player] in {'owglitches', 'hybridglitches', "nologic"}:
gtower_trash_count = 0
elif 'triforcehunt' in world.goal[player] and ('local' in world.goal[player] or world.players == 1):
gtower_trash_count = world.random.randint(world.crystals_needed_for_gt[player] * 2,
@@ -136,6 +140,10 @@ def distribute_items_restrictive(world, gftower_trash=False, fill_locations=None
world.random.shuffle(fill_locations)
fill_restrictive(world, world.state, fill_locations, progitempool)
if nonexcludeditempool:
world.random.shuffle(fill_locations)
fill_restrictive(world, world.state, fill_locations, nonexcludeditempool) # needs logical fill to not conflict with local items
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
local_locations = {player: [] for player in world.player_ids}
for location in fill_locations:
@@ -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}')
def fast_fill(world, item_pool: typing.List, fill_locations: typing.List) -> typing.Tuple[typing.List, typing.List]:
def fast_fill(world: MultiWorld, item_pool: typing.List, fill_locations: typing.List) -> typing.Tuple[typing.List, typing.List]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
def flood_items(world):
def flood_items(world: MultiWorld):
# get items to distribute
world.random.shuffle(world.itempool)
itempool = world.itempool
@@ -234,7 +242,7 @@ def flood_items(world):
break
def balance_multiworld_progression(world):
def balance_multiworld_progression(world: MultiWorld):
balanceable_players = {player for player in range(1, world.players + 1) if world.progression_balancing[player]}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
@@ -347,7 +355,8 @@ def balance_multiworld_progression(world):
if world.has_beaten_game(state):
break
elif not sphere_locations:
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
logging.warning("Progression Balancing ran out of paths.")
break
def swap_location_item(location_1: Location, location_2: Location, check_locked=True):
@@ -363,73 +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
def distribute_planned(world):
def distribute_planned(world: MultiWorld):
world_name_lookup = world.world_name_lookup
for player in world.player_ids:
placement: PlandoItem
for placement in world.plando_items[player]:
if placement.location in key_drop_data:
placement.warn(
f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
continue
item = ItemFactory(placement.item, player)
target_world: int = placement.world
if target_world is False or world.players == 1:
target_world = player # in own world
elif target_world is True: # in any other world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids) - {player}) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
try:
placement: PlandoItem
for placement in world.plando_items[player]:
if placement.location in key_drop_data:
placement.warn(
f"Can't place '{placement.item}' at '{placement.location}', as key drop shuffle locations are not supported yet.")
continue
item = ItemFactory(placement.item, player)
target_world: int = placement.world
if target_world is False or world.players == 1:
target_world = player # in own world
elif target_world is True: # in any other world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids) - {player}) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
continue
target_world = world.random.choice(unfilled).player
elif target_world is None: # any random world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids)) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
continue
target_world = world.random.choice(unfilled).player
elif type(target_world) == int: # target world by player id
if target_world not in range(1, world.players + 1):
placement.failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
ValueError)
continue
else: # find world by name
if target_world not in world_name_lookup:
placement.failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
ValueError)
continue
target_world = world_name_lookup[target_world]
location = world.get_location(placement.location, target_world)
if location.item:
placement.failed(f"Cannot place item into already filled location {location}.")
continue
target_world = world.random.choice(unfilled).player
elif target_world is None: # any random world
unfilled = list(location for location in world.get_unfilled_locations_for_players(
placement.location,
set(world.player_ids)) if location.item_rule(item)
)
if not unfilled:
placement.failed(f"Could not find a world with an unfilled location {placement.location}",
FillError)
if location.can_fill(world.state, item, False):
world.push_item(location, item, collect=False)
location.event = True # flag location to be checked during fill
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
else:
placement.failed(f"Can't place {item} at {location} due to fill condition not met.")
continue
target_world = world.random.choice(unfilled).player
elif type(target_world) == int: # target world by player id
if target_world not in range(1, world.players + 1):
placement.failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {world.players})",
ValueError)
continue
else: # find world by name
if target_world not in world_name_lookup:
placement.failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
ValueError)
continue
target_world = world_name_lookup[target_world]
location = world.get_location(placement.location, target_world)
if location.item:
placement.failed(f"Cannot place item into already filled location {location}.")
continue
if location.can_fill(world.state, item, False):
world.push_item(location, item, collect=False)
location.event = True # flag location to be checked during fill
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
else:
placement.failed(f"Can't place {item} at {location} due to fill condition not met.")
continue
if placement.from_pool: # Should happen AFTER the item is placed, in case it was allowed to skip failed placement.
try:
world.itempool.remove(item)
except ValueError:
placement.warn(f"Could not remove {item} from pool as it's already missing from it.")
if placement.from_pool: # Should happen AFTER the item is placed, in case it was allowed to skip failed placement.
try:
world.itempool.remove(item)
except ValueError:
placement.warn(f"Could not remove {item} from pool as it's already missing from it.")
except Exception as e:
raise Exception(f"Error running plando for player {player} ({world.player_names[player]})") from e

View File

@@ -9,162 +9,152 @@ from collections import Counter
import string
import ModuleUpdate
from worlds.generic import PlandoItem, PlandoConnection
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 Main import main as ERmain
from Main import get_seed, seeddigits
import Options
from worlds import lookup_any_item_name_to_id
from worlds.alttp.Items import item_name_groups, item_table
from worlds.alttp import Bosses
from worlds.alttp.Text import TextTable
from worlds.alttp.Regions import location_table, key_drop_data
from worlds.AutoWorld import AutoWorldRegister
categories = set(AutoWorldRegister.world_types)
def mystery_argparse():
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--multi', default=1, type=lambda value: min(max(int(value), 1), 255))
multiargs, _ = parser.parse_known_args()
options = get_options()
defaults = options["generator"]
parser = argparse.ArgumentParser()
parser.add_argument('--weights',
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
parser.add_argument('--weights_file_path', default = defaults["weights_file_path"],
help='Path to the weights file to use for rolling game settings, urls are also valid')
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
action='store_true')
parser.add_argument('--player_files_path', default=defaults["player_files_path"],
help="Input directory for player files.")
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=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('--create_spoiler', action='store_true')
parser.add_argument('--skip_playthrough', action='store_true')
parser.add_argument('--pre_roll', action='store_true')
parser.add_argument('--rom')
parser.add_argument('--enemizercli')
parser.add_argument('--outputpath')
parser.add_argument('--glitch_triforce', action='store_true')
parser.add_argument('--race', action='store_true')
parser.add_argument('--meta', default=None)
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
parser.add_argument('--rom', default=options["lttp_options"]["rom_file"], help="Path to the 1.0 JP LttP Baserom.")
parser.add_argument('--enemizercli', default=defaults["enemizer_path"])
parser.add_argument('--outputpath', default=options["general_options"]["output_path"])
parser.add_argument('--race', action='store_true', default=defaults["race"])
parser.add_argument('--meta_file_path', default=defaults["meta_file_path"])
parser.add_argument('--log_output_path', help='Path to store output log')
parser.add_argument('--loglevel', default='info', help='Sets log level')
parser.add_argument('--create_diff', action="store_true")
parser.add_argument('--log_level', default='info', help='Sets log level')
parser.add_argument('--yaml_output', default=0, type=lambda value: min(max(int(value), 0), 255),
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
parser.add_argument('--plando', default="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"')
for player in range(1, multiargs.multi + 1):
parser.add_argument(f'--p{player}', help=argparse.SUPPRESS)
args = parser.parse_args()
if not os.path.isabs(args.weights_file_path):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: typing.Set[str] = {arg.strip().lower() for arg in args.plando.split(",")}
return args
return args, options
def get_seed_name(random):
return f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None, callback=ERmain):
if not args:
args = mystery_argparse()
args, options = mystery_argparse()
seed = get_seed(args.seed)
random.seed(seed)
seedname = "M" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits))
print(f"Generating mystery for {args.multi} player{'s' if args.multi > 1 else ''}, {seedname} Seed {seed}")
seed_name = get_seed_name(random)
if args.race:
random.seed() # reset to time-based random source
weights_cache = {}
if args.weights:
if args.weights_file_path and os.path.exists(args.weights_file_path):
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:
raise ValueError(f"File {args.weights} is destroyed. Please fix your yaml.") from e
print(f"Weights: {args.weights} >> "
f"{get_choice('description', weights_cache[args.weights], 'No description specified')}")
if args.meta:
raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e
print(f"Weights: {args.weights_file_path} >> "
f"{get_choice('description', weights_cache[args.weights_file_path], 'No description specified')}")
if args.meta_file_path and os.path.exists(args.meta_file_path):
try:
weights_cache[args.meta] = get_weights(args.meta)
weights_cache[args.meta_file_path] = read_weights_yaml(args.meta_file_path)
except Exception as e:
raise ValueError(f"File {args.meta} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta]
print(f"Meta: {args.meta} >> {get_choice('meta_description', meta_weights, 'No description specified')}")
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta_file_path]
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights, 'No description specified')}")
if args.samesettings:
raise Exception("Cannot mix --samesettings with --meta")
for player in range(1, args.multi + 1):
path = getattr(args, f'p{player}')
if path:
else:
meta_weights = None
player_id = 1
player_files = {}
for file in os.scandir(args.player_files_path):
fname = file.name
if file.is_file() and os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
path = os.path.join(args.player_files_path, fname)
try:
if path not in weights_cache:
weights_cache[path] = get_weights(path)
print(f"P{player} Weights: {path} >> "
f"{get_choice('description', weights_cache[path], 'No description specified')}")
weights_cache[fname] = read_weights_yaml(path)
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.seed = seed
erargs.name = {x: "" for x in range(1, args.multi + 1)} # only so it can be overwrittin in mystery
erargs.create_spoiler = args.create_spoiler
erargs.create_diff = args.create_diff
erargs.glitch_triforce = args.glitch_triforce
erargs.create_spoiler = args.spoiler > 0
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
erargs.race = args.race
erargs.skip_playthrough = args.skip_playthrough
erargs.outputname = seedname
erargs.skip_playthrough = args.spoiler < 2
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
erargs.teams = args.teams
# set up logger
if args.loglevel:
erargs.loglevel = args.loglevel
if args.log_level:
erargs.loglevel = args.log_level
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
erargs.loglevel]
if args.log_output_path:
import sys
class LoggerWriter(object):
def __init__(self, writer):
self._writer = writer
self._msg = ''
def write(self, message):
self._msg = self._msg + message
while '\n' in self._msg:
pos = self._msg.find('\n')
self._writer(self._msg[:pos])
self._msg = self._msg[pos + 1:]
def flush(self):
if self._msg != '':
self._writer(self._msg)
self._msg = ''
log = logging.getLogger("stderr")
log.addHandler(logging.StreamHandler())
sys.stderr = LoggerWriter(log.error)
os.makedirs(args.log_output_path, exist_ok=True)
logging.basicConfig(format='%(message)s', level=loglevel,
logging.basicConfig(format='%(message)s', level=loglevel, force=True,
filename=os.path.join(args.log_output_path, f"{seed}.log"))
else:
logging.basicConfig(format='%(message)s', level=loglevel)
if args.rom:
erargs.rom = args.rom
logging.basicConfig(format='%(message)s', level=loglevel, force=True)
if args.enemizercli:
erargs.enemizercli = args.enemizercli
erargs.rom = args.rom
erargs.enemizercli = args.enemizercli
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
for k, v in weights_cache.items()}
player_path_cache = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = 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():
weights_cache[path].setdefault("meta_ignore", [])
meta_weights = weights_cache[args.meta]
for key in meta_weights:
option = get_choice(key, meta_weights)
if option is not None:
@@ -183,27 +173,6 @@ def main(args=None, callback=ERmain):
try:
settings = settings_cache[path] if settings_cache[path] else \
roll_settings(weights_cache[path], args.plando)
if args.pre_roll:
import yaml
if path == args.weights:
settings.name = f"Player{player}"
elif not settings.name:
settings.name = os.path.splitext(os.path.split(path)[-1])[0]
if "-" not in settings.shuffle and settings.shuffle != "vanilla":
settings.shuffle += f"-{random.randint(0, 2 ** 64)}"
pre_rolled = dict()
pre_rolled["original_seed_number"] = seed
pre_rolled["original_seed_name"] = seedname
pre_rolled["pre_rolled"] = vars(settings).copy()
if "plando_items" in pre_rolled["pre_rolled"]:
pre_rolled["pre_rolled"]["plando_items"] = [item.to_dict() for item in pre_rolled["pre_rolled"]["plando_items"]]
if "plando_connections" in pre_rolled["pre_rolled"]:
pre_rolled["pre_rolled"]["plando_connections"] = [connection.to_dict() for connection in pre_rolled["pre_rolled"]["plando_connections"]]
with open(os.path.join(args.outputpath if args.outputpath else ".", f"{os.path.split(path)[-1].split('.')[0]}_pre_rolled.yaml"), "wt") as f:
yaml.dump(pre_rolled, f)
for k, v in vars(settings).items():
if v is not None:
try:
@@ -214,7 +183,7 @@ def main(args=None, callback=ERmain):
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
else:
raise RuntimeError(f'No weights specified for player {player}')
if path == args.weights: # if name came from the weights file, just use base player name
if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}"
elif not erargs.name[player]: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
@@ -243,13 +212,13 @@ def main(args=None, callback=ERmain):
logging.debug(f"No player settings defined for option '{option}'")
if args.outputpath:
os.makedirs(args.outputpath, exist_ok=True)
with open(os.path.join(args.outputpath if args.outputpath else ".", f"mystery_result_{seed}.yaml"), "wt") as f:
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
yaml.dump(important, f)
callback(erargs, seed)
def get_weights(path):
def read_weights_yaml(path):
try:
if urllib.parse.urlparse(path).scheme:
yaml = str(urllib.request.urlopen(path).read(), "utf-8")
@@ -294,10 +263,14 @@ def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name] += 1
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=name_counter[name],
NUMBER=(name_counter[name] if name_counter[name] > 1 else ''),
NUMBER=(name_counter[name] if name_counter[
name] > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
return new_name.strip().replace(' ', '_')[:16]
new_name = new_name.strip().replace(' ', '_')[:16]
if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name
def prefer_int(input_data: str) -> typing.Union[str, int]:
@@ -315,17 +288,31 @@ available_boss_locations: typing.Set[str] = {f"{loc.lower()}{f' {level}' if leve
boss_shuffle_options = {None: 'none',
'none': 'none',
'basic': 'basic',
'normal': 'normal',
'full': 'full',
'chaos': 'chaos',
'singularity': 'singularity'
}
goals = {
'ganon': 'ganon',
'crystals': 'crystals',
'bosses': 'bosses',
'pedestal': 'pedestal',
'ganon_pedestal': 'ganonpedestal',
'triforce_hunt': 'triforcehunt',
'local_triforce_hunt': 'localtriforcehunt',
'ganon_triforce_hunt': 'ganontriforcehunt',
'local_ganon_triforce_hunt': 'localganontriforcehunt',
'ice_rod_hunt': 'icerodhunt',
}
def roll_percentage(percentage: typing.Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
new_options = set(new_weights) - set(weights)
@@ -337,6 +324,7 @@ def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> di
f'This is probably in error.')
return weights
def roll_linked_options(weights: dict) -> dict:
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
for option_set in weights["linked_options"]:
@@ -345,12 +333,12 @@ def roll_linked_options(weights: dict) -> dict:
try:
if roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.")
if "options" in option_set:
weights = update_weights(weights, option_set["options"], "Linked", option_set["name"])
if "rom_options" in option_set:
rom_weights = weights.get("rom", dict())
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Linked Rom", option_set["name"])
weights["rom"] = rom_weights
new_options = option_set["options"]
for category_name, category_options in new_options.items():
currently_targeted_weights = weights
if category_name:
currently_targeted_weights = currently_targeted_weights[category_name]
update_weights(currently_targeted_weights, category_options, "Linked", option_set["name"])
else:
logging.debug(f"linked option {option_set['name']} skipped.")
except Exception as e:
@@ -358,28 +346,33 @@ def roll_linked_options(weights: dict) -> dict:
f"Please fix your linked option.") from e
return weights
def roll_triggers(weights: dict) -> dict:
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
weights["_Generator_Version"] = "Archipelago" # Some means for triggers to know if the seed is on main or doors.
for option_set in weights["triggers"]:
for i, option_set in enumerate(weights["triggers"]):
try:
currently_targeted_weights = weights
category = option_set.get("option_category", None)
if category:
currently_targeted_weights = currently_targeted_weights[category]
key = get_choice("option_name", option_set)
if key not in weights:
if key not in currently_targeted_weights:
logging.warning(f'Specified option name {option_set["option_name"]} did not '
f'match with a root option. '
f'This is probably in error.')
trigger_result = get_choice("option_result", option_set)
result = get_choice(key, 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 "options" in option_set:
weights = update_weights(weights, option_set["options"], "Triggered", option_set["option_name"])
if "rom_options" in option_set:
rom_weights = weights.get("rom", dict())
rom_weights = update_weights(rom_weights, option_set["rom_options"], "Triggered Rom", option_set["option_name"])
weights["rom"] = rom_weights
weights[key] = result
for category_name, category_options in option_set["options"].items():
currently_targeted_weights = weights
if category_name:
currently_targeted_weights = currently_targeted_weights[category_name]
update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"])
except Exception as e:
raise ValueError(f"A trigger is destroyed. "
raise ValueError(f"Your trigger number {i + 1} is destroyed. "
f"Please fix your triggers.") from e
return weights
@@ -419,85 +412,115 @@ def get_plando_bosses(boss_shuffle: str, plando_options: typing.Set[str]) -> str
raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.")
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses", ))):
if "pre_rolled" in weights:
pre_rolled = weights["pre_rolled"]
if "plando_items" in pre_rolled:
pre_rolled["plando_items"] = [PlandoItem(item["item"],
item["location"],
item["world"],
item["from_pool"],
item["force"]) for item in pre_rolled["plando_items"]]
if "items" not in plando_options and pre_rolled["plando_items"]:
raise Exception("Item Plando is turned off. Reusing this pre-rolled setting not permitted.")
if "plando_connections" in pre_rolled:
pre_rolled["plando_connections"] = [PlandoConnection(connection["entrance"],
connection["exit"],
connection["direction"]) for connection in pre_rolled["plando_connections"]]
if "connections" not in plando_options and pre_rolled["plando_connections"]:
raise Exception("Connection Plando is turned off. Reusing this pre-rolled setting not permitted.")
if "bosses" not in plando_options:
try:
pre_rolled["shufflebosses"] = get_plando_bosses(pre_rolled["shufflebosses"], plando_options)
except Exception as ex:
raise Exception("Boss Plando is turned off. Reusing this pre-rolled setting not permitted.") from ex
if pre_rolled.get("plando_texts") and "texts" not in plando_options:
raise Exception("Text Plando is turned off. Reusing this pre-rolled setting not permitted.")
return argparse.Namespace(**pre_rolled)
def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("bosses",))):
if "linked_options" in weights:
weights = roll_linked_options(weights)
if "triggers" in weights:
weights = roll_triggers(weights)
requirements = weights.get("requires", {})
if requirements:
version = requirements.get("version", __version__)
if tuplize_version(version) > version_tuple:
raise Exception(f"Settings reports required version of generator is at least {version}, "
f"however generator is of version {__version__}")
required_plando_options = requirements.get("plando", "")
if required_plando_options:
required_plando_options = set(option.strip() for option in required_plando_options.split(","))
required_plando_options -= plando_options
if required_plando_options:
if len(required_plando_options) == 1:
raise Exception(f"Settings reports required plando module {', '.join(required_plando_options)}, "
f"which is not enabled.")
else:
raise Exception(f"Settings reports required plando modules {', '.join(required_plando_options)}, "
f"which are not enabled.")
ret = argparse.Namespace()
ret.name = get_choice('name', weights)
ret.accessibility = get_choice('accessibility', weights)
ret.progression_balancing = get_choice('progression_balancing', weights, True)
ret.game = get_choice("game", weights, "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()
for item_name in weights.get('local_items', []):
items = item_name_groups.get(item_name, {item_name})
for item_name in game_weights.get('local_items', []):
items = world_type.item_name_groups.get(item_name, {item_name})
for item in items:
if item in lookup_any_item_name_to_id:
if item in world_type.item_names:
ret.local_items.add(item)
else:
raise Exception(f"Could not force item {item} to be world-local, as it was not recognized.")
ret.non_local_items = set()
for item_name in weights.get('non_local_items', []):
items = item_name_groups.get(item_name, {item_name})
for item_name in game_weights.get('non_local_items', []):
items = world_type.item_name_groups.get(item_name, {item_name})
for item in items:
if item in lookup_any_item_name_to_id:
if item in world_type.item_names:
ret.non_local_items.add(item)
else:
raise Exception(f"Could not force item {item} to be world-non-local, as it was not recognized.")
if ret.game == "A Link to the Past":
roll_alttp_settings(ret, weights, plando_options)
elif ret.game == "Hollow Knight":
for option_name, option in Options.hollow_knight_options.items():
setattr(ret, option_name, option.from_any(get_choice(option_name, weights, True)))
elif ret.game == "Factorio":
pass
inventoryweights = game_weights.get('start_inventory', {})
startitems = []
for item in inventoryweights.keys():
itemvalue = get_choice(item, inventoryweights)
if isinstance(itemvalue, int):
for i in range(int(itemvalue)):
startitems.append(item)
elif itemvalue:
startitems.append(item)
ret.startinventory = startitems
ret.start_hints = set(game_weights.get('start_hints', []))
ret.excluded_locations = set()
for location in game_weights.get('exclude_locations', []):
if location in world_type.location_names:
ret.excluded_locations.add(location)
else:
raise Exception(f"Could not exclude location {location}, as it was not recognized.")
if ret.game in AutoWorldRegister.world_types:
for option_name, option in AutoWorldRegister.world_types[ret.game].options.items():
if option_name in game_weights:
try:
if issubclass(option, Options.OptionDict):
setattr(ret, option_name, option.from_any(game_weights[option_name]))
else:
setattr(ret, option_name, option.from_any(get_choice(option_name, game_weights)))
except Exception as e:
raise Exception(f"Error generating option {option_name} in {ret.game}") from e
else:
setattr(ret, option_name, option(option.default))
if ret.game == "Minecraft":
# bad hardcoded behavior to make this work for now
ret.plando_connections = []
if "connections" in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
ret.plando_connections.append(PlandoConnection(
get_choice("entrance", placement),
get_choice("exit", placement),
get_choice("direction", placement, "both")
))
elif ret.game == "A Link to the Past":
roll_alttp_settings(ret, game_weights, plando_options)
else:
raise Exception(f"Unsupported game {ret.game}")
return ret
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
glitches_required = get_choice('glitches_required', weights)
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'minor_glitches']:
logging.warning("Only NMG, OWG and No Logic supported")
if glitches_required not in [None, 'none', 'no_logic', 'overworld_glitches', 'hybrid_major_glitches', 'minor_glitches']:
logging.warning("Only NMG, OWG, HMG and No Logic supported")
glitches_required = 'none'
ret.logic = {None: 'noglitches', 'none': 'noglitches', 'no_logic': 'nologic', 'overworld_glitches': 'owglitches',
'minor_glitches': 'minorglitches'}[
'minor_glitches': 'minorglitches', 'hybrid_major_glitches': 'hybridglitches'}[
glitches_required]
ret.dark_room_logic = get_choice("dark_room_logic", weights, "lamp")
@@ -533,30 +556,16 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.shuffle = entrance_shuffle if entrance_shuffle != 'none' else 'vanilla'
goal = get_choice('goals', weights, 'ganon')
ret.goal = {'ganon': 'ganon',
'crystals': 'crystals',
'bosses': 'bosses',
'pedestal': 'pedestal',
'ganon_pedestal': 'ganonpedestal',
'triforce_hunt': 'triforcehunt',
'local_triforce_hunt': 'localtriforcehunt',
'ganon_triforce_hunt': 'ganontriforcehunt',
'local_ganon_triforce_hunt': 'localganontriforcehunt',
'ice_rod_hunt': 'icerodhunt'
}[goal]
ret.goal = goals[goal]
# TODO consider moving open_pyramid to an automatic variable in the core roller, set to True when
# fast ganon + ganon at hole
ret.open_pyramid = get_choice('open_pyramid', weights, 'goal')
ret.crystals_gt = prefer_int(get_choice('tower_open', weights))
ret.crystals_ganon = prefer_int(get_choice('ganon_open', weights))
extra_pieces = get_choice('triforce_pieces_mode', weights, 'available')
ret.triforce_pieces_required = int(get_choice('triforce_pieces_required', weights, 20))
ret.triforce_pieces_required = min(max(1, int(ret.triforce_pieces_required)), 90)
ret.triforce_pieces_required = LttPOptions.TriforcePieces.from_any(get_choice('triforce_pieces_required', weights, 20))
# sum a percentage to required
if extra_pieces == 'percentage':
@@ -564,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))
# vanilla mode (specify how many pieces are)
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
elif extra_pieces == 'extra':
extra_pieces = max(0, int(get_choice('triforce_pieces_extra', weights, 10)))
@@ -572,11 +582,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
# change minimum to required pieces to avoid problems
ret.triforce_pieces_available = min(max(ret.triforce_pieces_required, int(ret.triforce_pieces_available)), 90)
shuffle_slots = get_choice('shop_shuffle_slots', weights, '0')
if str(shuffle_slots).lower() == "random":
ret.shop_shuffle_slots = random.randint(0, 30)
else:
ret.shop_shuffle_slots = int(shuffle_slots)
ret.shop_shuffle = get_choice('shop_shuffle', weights, '')
if not ret.shop_shuffle:
@@ -587,11 +592,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.hints = get_choice('hints', weights)
ret.swords = {'randomized': 'random',
'assured': 'assured',
'vanilla': 'vanilla',
'swordless': 'swordless'
}[get_choice('weapons', weights, 'assured')]
ret.swordless = get_choice('swordless', weights, False)
ret.difficulty = get_choice('item_pool', weights)
@@ -647,23 +648,8 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
if not ret.required_medallions[index]:
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
inventoryweights = weights.get('startinventory', {})
startitems = []
for item in inventoryweights.keys():
itemvalue = get_choice(item, inventoryweights)
if item.startswith(('Progressive ', 'Small Key ', 'Rupee', 'Piece of Heart', 'Boss Heart Container',
'Sanctuary Heart Container', 'Arrow', 'Bombs ', 'Bomb ', 'Bottle')) and isinstance(
itemvalue, int):
for i in range(int(itemvalue)):
startitems.append(item)
elif itemvalue:
startitems.append(item)
ret.startinventory = ','.join(startitems)
ret.glitch_boots = get_choice('glitch_boots', weights, True)
ret.remote_items = get_choice('remote_items', weights, False)
if get_choice("local_keys", weights, "l" in dungeon_items):
# () important for ordering of commands, without them the Big Keys section is part of the Small Key else
ret.local_items |= item_name_groups["Small Keys"] if ret.keyshuffle else set()
@@ -728,49 +714,43 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
get_choice("direction", placement, "both")
))
if 'rom' in weights:
romweights = weights['rom']
ret.sprite_pool = weights.get('sprite_pool', [])
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 []
ret.sprite = get_choice('sprite', romweights, "Link")
if 'random_sprite_on_event' in romweights:
randomoneventweights = romweights['random_sprite_on_event']
if get_choice('enabled', randomoneventweights, False):
ret.sprite = 'randomon'
ret.sprite += '-hit' if get_choice('on_hit', randomoneventweights, True) else ''
ret.sprite += '-enter' if get_choice('on_enter', randomoneventweights, False) else ''
ret.sprite += '-exit' if get_choice('on_exit', randomoneventweights, False) else ''
ret.sprite += '-slash' if get_choice('on_slash', randomoneventweights, False) else ''
ret.sprite += '-item' if get_choice('on_item', randomoneventweights, False) else ''
ret.sprite += '-bonk' if get_choice('on_bonk', randomoneventweights, False) else ''
ret.sprite = 'randomonall' if get_choice('on_everything', randomoneventweights, False) else ret.sprite
ret.sprite = 'randomonnone' if ret.sprite == 'randomon' else ret.sprite
if (not ret.sprite_pool or get_choice('use_weighted_sprite_pool', randomoneventweights, False)) \
and 'sprite' in weights: # Use sprite as a weighted sprite pool, if a sprite pool is not already defined.
for key, value in weights['sprite'].items():
if key.startswith('random'):
ret.sprite_pool += ['random'] * int(value)
else:
ret.sprite_pool += [key] * int(value)
if (not ret.sprite_pool or get_choice('use_weighted_sprite_pool', randomoneventweights, False)) \
and 'sprite' in romweights: # Use sprite as a weighted sprite pool, if a sprite pool is not already defined.
for key, value in romweights['sprite'].items():
if key.startswith('random'):
ret.sprite_pool += ['random'] * int(value)
else:
ret.sprite_pool += [key] * int(value)
ret.disablemusic = get_choice('disablemusic', weights, False)
ret.triforcehud = get_choice('triforcehud', weights, 'hide_goal')
ret.quickswap = get_choice('quickswap', weights, True)
ret.fastmenu = get_choice('menuspeed', weights, "normal")
ret.reduceflashing = get_choice('reduceflashing', weights, False)
ret.heartcolor = get_choice('heartcolor', weights, "red")
ret.heartbeep = convert_to_on_off(get_choice('heartbeep', weights, "normal"))
ret.ow_palettes = get_choice('ow_palettes', weights, "default")
ret.uw_palettes = get_choice('uw_palettes', weights, "default")
ret.hud_palettes = get_choice('hud_palettes', weights, "default")
ret.sword_palettes = get_choice('sword_palettes', weights, "default")
ret.shield_palettes = get_choice('shield_palettes', weights, "default")
ret.link_palettes = get_choice('link_palettes', weights, "default")
ret.disablemusic = get_choice('disablemusic', romweights, False)
ret.triforcehud = get_choice('triforcehud', romweights, 'hide_goal')
ret.quickswap = get_choice('quickswap', romweights, True)
ret.fastmenu = get_choice('menuspeed', romweights, "normal")
ret.reduceflashing = get_choice('reduceflashing', romweights, False)
ret.heartcolor = get_choice('heartcolor', romweights, "red")
ret.heartbeep = convert_to_on_off(get_choice('heartbeep', romweights, "normal"))
ret.ow_palettes = get_choice('ow_palettes', romweights, "default")
ret.uw_palettes = get_choice('uw_palettes', romweights, "default")
ret.hud_palettes = get_choice('hud_palettes', romweights, "default")
ret.sword_palettes = get_choice('sword_palettes', romweights, "default")
ret.shield_palettes = get_choice('shield_palettes', romweights, "default")
ret.link_palettes = get_choice('link_palettes', romweights, "default")
else:
ret.quickswap = True
ret.sprite = "Link"
if __name__ == '__main__':
main()

1925
Gui.py

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -3,7 +3,7 @@ MIT License
Copyright (c) 2017 LLCoolDave
Copyright (c) 2021 Berserker66
Copyright (c) 2021 CaitSith2
Copyright (c) 2020 LegendaryLinux
Copyright (c) 2021 LegendaryLinux
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,13 @@
import argparse
import atexit
import time
import functools
import webbrowser
import multiprocessing
import socket
import os
import subprocess
import base64
import shutil
from json import loads, dumps
from random import randrange
from Utils import get_item_name_from_id
exit_func = atexit.register(input, "Press enter to close.")
@@ -24,7 +19,6 @@ ModuleUpdate.update()
import colorama
from NetUtils import *
import WebUI
from worlds.alttp import Regions, Shops
from worlds.alttp import Items
@@ -45,12 +39,6 @@ class LttPCommandProcessor(ClientCommandProcessor):
self.output(f"Setting slow mode to {self.ctx.slow_mode}")
def _cmd_web(self):
if self.ctx.webui_socket_port:
webbrowser.open(f'http://localhost:5050?port={self.ctx.webui_socket_port}')
else:
self.output("Web UI was never started.")
@mark_raw
def _cmd_snes(self, snes_address: str = "") -> bool:
"""Connect to a snes. Optionally include network address of a snes to connect to, otherwise show available devices"""
@@ -69,20 +57,10 @@ class LttPCommandProcessor(ClientCommandProcessor):
class Context(CommonContext):
command_processor = LttPCommandProcessor
def __init__(self, snes_address, server_address, password, found_items, port: int):
super(Context, self).__init__(server_address, password, found_items)
game = "A Link to the Past"
# WebUI Stuff
self.ui_node = WebUI.WebUiClient()
logger.addHandler(self.ui_node)
self.webui_socket_port: typing.Optional[int] = port
self.hint_cost = 0
self.check_points = 0
self.forfeit_mode = ''
self.remaining_mode = ''
self.hint_points = 0
# End of WebUI Stuff
def __init__(self, snes_address, server_address, password):
super(Context, self).__init__(server_address, password)
# snes stuff
self.snes_address = snes_address
@@ -92,7 +70,6 @@ class Context(CommonContext):
self.snes_reconnect_address = None
self.snes_recv_queue = asyncio.Queue()
self.snes_request_lock = asyncio.Lock()
self.is_sd2snes = False
self.snes_write_buffer = []
self.awaiting_rom = False
@@ -121,7 +98,7 @@ class Context(CommonContext):
self.auth = self.rom
auth = base64.b64encode(self.rom).decode()
await self.send_msgs([{"cmd": 'Connect',
'password': self.password, 'name': auth, 'version': Utils._version_tuple,
'password': self.password, 'name': auth, 'version': Utils.version_tuple,
'tags': get_tags(self),
'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past"
}])
@@ -162,8 +139,6 @@ SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte
SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes
location_shop_order = [name for name, info in
Shops.shop_table.items()] # probably don't leave this here. This relies on python 3.6+ dictionary keys having defined order
location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
@@ -434,26 +409,30 @@ class SNESState(enum.IntEnum):
SNES_ATTACHED = 3
def launch_qusb2snes(ctx: Context):
qusb2snes_path = Utils.get_options()["lttp_options"]["qusb2snes"]
def launch_sni(ctx: Context):
sni_path = Utils.get_options()["lttp_options"]["sni"]
if not os.path.isfile(qusb2snes_path):
qusb2snes_path = Utils.local_path(qusb2snes_path)
if not os.path.isdir(sni_path):
sni_path = Utils.local_path(sni_path)
if os.path.isdir(sni_path):
for file in os.listdir(sni_path):
if file.startswith("sni.") and not file.endswith(".proto"):
sni_path = os.path.join(sni_path, file)
if os.path.isfile(qusb2snes_path):
logger.info(f"Attempting to start {qusb2snes_path}")
if os.path.isfile(sni_path):
logger.info(f"Attempting to start {sni_path}")
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:
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")
async def _snes_connect(ctx: Context, address: str):
address = f"ws://{address}" if "://" not in address else address
logger.info("Connecting to QUsb2snes at %s ..." % address)
logger.info("Connecting to SNI at %s ..." % address)
seen_problems = set()
succesful = False
while not succesful:
@@ -465,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
if problem not in seen_problems:
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:
# this is the first problem. Let's try launching QUsb2snes if it isn't already running
launch_qusb2snes(ctx)
# this is the first problem. Let's try launching SNI if it isn't already running
launch_sni(ctx)
await asyncio.sleep(1)
else:
@@ -488,14 +467,14 @@ async def get_snes_devices(ctx: Context):
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
if not devices:
logger.info('No SNES device found. Ensure QUsb2Snes is running and connect it to the multibridge.')
logger.info('No SNES device found. Please connect a SNES device to SNI.')
while not devices:
await asyncio.sleep(1)
await socket.send(dumps(DeviceList_Request))
reply = loads(await socket.recv())
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
ctx.ui_node.send_device_list(devices)
await socket.close()
return devices
@@ -517,8 +496,6 @@ async def snes_connect(ctx: Context, address):
if len(devices) == 1:
device = devices[0]
elif ctx.ui_node.manual_snes and ctx.ui_node.manual_snes in devices:
device = ctx.ui_node.manual_snes
elif ctx.snes_reconnect_address:
if ctx.snes_attached_device[1] in devices:
device = ctx.snes_attached_device[1]
@@ -538,18 +515,6 @@ async def snes_connect(ctx: Context, address):
await ctx.snes_socket.send(dumps(Attach_Request))
ctx.snes_state = SNESState.SNES_ATTACHED
ctx.snes_attached_device = (devices.index(device), device)
ctx.ui_node.send_connection_status(ctx)
if 'sd2snes' in device.lower() or 'COM' in device:
logger.info("SD2SNES/FXPAK Detected")
ctx.is_sd2snes = True
await ctx.snes_socket.send(dumps({"Opcode": "Info", "Space": "SNES"}))
reply = loads(await ctx.snes_socket.recv())
if reply and 'Results' in reply:
logger.info(reply['Results'])
else:
ctx.is_sd2snes = False
ctx.snes_reconnect_address = address
recv_task = asyncio.create_task(snes_recv_loop(ctx))
SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay
@@ -607,7 +572,6 @@ async def snes_recv_loop(ctx: Context):
ctx.snes_state = SNESState.SNES_DISCONNECTED
ctx.snes_recv_queue = asyncio.Queue()
ctx.hud_message_queue = []
ctx.ui_node.send_connection_status(ctx)
ctx.rom = None
@@ -644,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)))
if len(data):
logger.error(str(data))
logger.warning('Unable to connect to SNES Device because QUsb2Snes broke temporarily.'
'Try un-selecting and re-selecting the SNES Device.')
logger.warning('Communication Failure with SNI')
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
await ctx.snes_socket.close()
return None
@@ -664,45 +627,16 @@ async def snes_write(ctx: Context, write_list):
return False
PutAddress_Request = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'}
if ctx.is_sd2snes:
cmd = b'\x00\xE2\x20\x48\xEB\x48'
try:
for address, data in write_list:
if (address < WRAM_START) or ((address + len(data)) > (WRAM_START + WRAM_SIZE)):
logger.error("SD2SNES: Write out of range %s (%d)" % (hex(address), len(data)))
return False
for ptr, byte in enumerate(data, address + 0x7E0000 - WRAM_START):
cmd += b'\xA9' # LDA
cmd += bytes([byte])
cmd += b'\x8F' # STA.l
cmd += bytes([ptr & 0xFF, (ptr >> 8) & 0xFF, (ptr >> 16) & 0xFF])
cmd += b'\xA9\x00\x8F\x00\x2C\x00\x68\xEB\x68\x28\x6C\xEA\xFF\x08'
PutAddress_Request['Space'] = 'CMD'
PutAddress_Request['Operands'] = ["2C00", hex(len(cmd) - 1)[2:], "2C00", "1"]
try:
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(cmd)
await ctx.snes_socket.send(data)
else:
logger.warning(f"Could not send data to SNES: {cmd}")
except websockets.ConnectionClosed:
return False
else:
PutAddress_Request['Space'] = 'SNES'
try:
# will pack those requests as soon as qusb2snes actually supports that for real
for address, data in write_list:
PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]]
if ctx.snes_socket is not None:
await ctx.snes_socket.send(dumps(PutAddress_Request))
await ctx.snes_socket.send(data)
else:
logger.warning(f"Could not send data to SNES: {data}")
except websockets.ConnectionClosed:
return False
logger.warning(f"Could not send data to SNES: {data}")
except websockets.ConnectionClosed:
return False
return True
finally:
@@ -732,9 +666,6 @@ def get_tags(ctx: Context):
return tags
async def track_locations(ctx: Context, roomid, roomdata):
new_locations = []
@@ -743,12 +674,10 @@ async def track_locations(ctx: Context, roomid, roomdata):
ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(location_id)
logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
ctx.ui_node.send_location_check(ctx, location)
try:
if roomid in location_shop_ids:
misc_data = await snes_read(ctx, SHOP_ADDR, (len(location_shop_order) * 3) + 5)
misc_data = await snes_read(ctx, SHOP_ADDR, (len(Shops.shop_table) * 3) + 5)
for cnt, b in enumerate(misc_data):
if int(b) and (Shops.SHOP_ID_START + cnt) not in ctx.locations_checked:
new_check(Shops.SHOP_ID_START + cnt)
@@ -887,14 +816,11 @@ async def game_watcher(ctx: Context):
if recv_index < len(ctx.items_received) and recv_item == 0:
item = ctx.items_received[recv_index]
ctx.ui_node.notify_item_received(ctx.player_names[item.player], ctx.item_name_getter(item.item),
ctx.location_name_getter(item.location), recv_index + 1,
len(ctx.items_received),
ctx.item_name_getter(item.item) in Items.progression_items)
recv_index += 1
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_name_getter(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_name_getter(item.location), recv_index + 1, len(ctx.items_received)))
recv_index += 1
ctx.location_name_getter(item.location), recv_index, len(ctx.items_received)))
snes_buffered_write(ctx, RECV_PROGRESS_ADDR, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
snes_buffered_write(ctx, RECV_ITEM_ADDR, bytes([item.item]))
snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR, bytes([item.player if item.player != ctx.slot else 0]))
@@ -920,70 +846,17 @@ async def run_game(romfile):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def websocket_server(websocket: websockets.WebSocketServerProtocol, path, ctx: Context):
endpoint = Endpoint(websocket)
ctx.ui_node.endpoints.append(endpoint)
process_command = LttPCommandProcessor(ctx)
try:
async for incoming_data in websocket:
data = loads(incoming_data)
logging.debug(f"WebUIData:{data}")
if ('type' not in data) or ('content' not in data):
raise Exception('Invalid data received in websocket')
elif data['type'] == 'webStatus':
if data['content'] == 'connections':
ctx.ui_node.send_connection_status(ctx)
elif data['content'] == 'devices':
await get_snes_devices(ctx)
elif data['content'] == 'gameInfo':
ctx.ui_node.send_game_info(ctx)
elif data['content'] == 'checkData':
ctx.ui_node.send_location_check(ctx, 'Waiting for check...')
elif data['type'] == 'webConfig':
if 'serverAddress' in data['content']:
ctx.server_address = data['content']['serverAddress']
await ctx.connect(data['content']['serverAddress'])
elif 'deviceId' in data['content']:
# Allow a SNES disconnect via UI sending -1 as new device
if data['content']['deviceId'] == "-1":
ctx.ui_node.manual_snes = None
ctx.snes_reconnect_address = None
await snes_disconnect(ctx)
else:
await snes_disconnect(ctx)
ctx.ui_node.manual_snes = data['content']['deviceId']
await snes_connect(ctx, ctx.snes_address)
elif data['type'] == 'webControl':
if 'disconnect' in data['content']:
await ctx.disconnect()
elif data['type'] == 'webCommand':
process_command(data['content'])
except Exception as e:
if not isinstance(e, websockets.WebSocketException):
logging.exception(e)
finally:
await ctx.ui_node.disconnect(endpoint)
async def main():
multiprocessing.freeze_support()
parser = argparse.ArgumentParser()
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a Archipelago Binary Patch file')
parser.add_argument('--snes', default='localhost:8080', help='Address of the QUsb2snes server.')
parser.add_argument('--snes', default='localhost:8080', help='Address of the SNI server.')
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
parser.add_argument('--password', default=None, help='Password of the multiworld host.')
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
parser.add_argument('--founditems', default=False, action='store_true',
help='Show items found by other players for themselves.')
parser.add_argument('--disable_web_ui', default=False, action='store_true',
help="Turn off emitting a webserver for the webbrowser based user interface.")
args = parser.parse_args()
logging.basicConfig(format='%(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
if args.diff_file:
@@ -1001,28 +874,12 @@ async def main():
logging.exception(e)
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
port = None
if not args.disable_web_ui:
# Find an available port on the host system to use for hosting the websocket server
while True:
port = randrange(49152, 65535)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if not sock.connect_ex(('localhost', port)) == 0:
break
import threading
WebUI.start_server(
port, on_start=threading.Timer(1, webbrowser.open, (f'http://localhost:5050?port={port}',)).start)
ctx = Context(args.snes, args.connect, args.password, args.founditems, port)
ctx = Context(args.snes, args.connect, args.password)
input_task = asyncio.create_task(console_loop(ctx), name="Input")
if not args.disable_web_ui:
ui_socket = websockets.serve(functools.partial(websocket_server, ctx=ctx),
'localhost', port, ping_timeout=None, ping_interval=None)
await ui_socket
if ctx.server_task is None:
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")
await ctx.exit_event.wait()

609
Main.py
View File

@@ -1,4 +1,3 @@
import copy
from itertools import zip_longest
import logging
import os
@@ -7,28 +6,21 @@ import time
import zlib
import concurrent.futures
import pickle
from typing import Dict
import tempfile
import zipfile
from typing import Dict, Tuple
from BaseClasses import MultiWorld, CollectionState, Region, Item
from worlds.alttp import ALttPLocation
from worlds.alttp.Items import ItemFactory, item_table, item_name_groups
from worlds.alttp.Regions import create_regions, mark_light_world_regions, \
lookup_vanilla_location_to_entrance
from worlds.alttp.InvertedRegions import create_inverted_regions, mark_dark_world_regions
from worlds.alttp.EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect
from worlds.alttp.Items import item_name_groups
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
from worlds.alttp.Rom import patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, LocalRom, get_hash_string
from worlds.alttp.Rules import set_rules
from worlds.alttp.Dungeons import create_dungeons, fill_dungeons, fill_dungeons_restrictive
from worlds.alttp.Dungeons import fill_dungeons, fill_dungeons_restrictive
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
from worlds.alttp.Shops import create_shops, ShopSlotFill, SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from worlds.alttp.ItemPool import generate_itempool, difficulties, fill_prizes
from Utils import output_path, parse_player_names, get_options, __version__, _version_tuple
from worlds.hk import gen_hollow, set_rules as set_hk_rules
from worlds.hk import create_regions as hk_create_regions
from worlds.factorio import gen_factorio, factorio_create_regions
from worlds.factorio.Mod import generate_mod
from worlds.generic.Rules import locality_rules
from worlds import Games
from worlds.alttp.Shops import ShopSlotFill, SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from worlds.alttp.ItemPool import difficulties, fill_prizes
from Utils import output_path, parse_player_names, get_options, __version__, version_tuple
from worlds.generic.Rules import locality_rules, exclusion_rules
from worlds import AutoWorld
import Patch
seeddigits = 20
@@ -66,18 +58,19 @@ def main(args, seed=None):
world.secure()
else:
world.random.seed(world.seed)
world.seed_name = str(args.outputname if args.outputname else world.seed)
world.shuffle = args.shuffle.copy()
world.logic = args.logic.copy()
world.mode = args.mode.copy()
world.swords = args.swords.copy()
world.swordless = args.swordless.copy()
world.difficulty = args.difficulty.copy()
world.item_functionality = args.item_functionality.copy()
world.timer = args.timer.copy()
world.progressive = args.progressive.copy()
world.goal = args.goal.copy()
world.local_items = args.local_items.copy()
if hasattr(args, "algorithm"): # current GUI options
if hasattr(args, "algorithm"): # current GUI options
world.algorithm = args.algorithm
world.shuffleganon = args.shuffleganon
world.custom = args.custom
@@ -88,17 +81,10 @@ def main(args, seed=None):
world.hints = args.hints.copy()
world.remote_items = args.remote_items.copy()
world.mapshuffle = args.mapshuffle.copy()
world.compassshuffle = args.compassshuffle.copy()
world.keyshuffle = args.keyshuffle.copy()
world.bigkeyshuffle = args.bigkeyshuffle.copy()
world.crystals_needed_for_ganon = {
player: world.random.randint(0, 7) if args.crystals_ganon[player] == 'random' else int(
args.crystals_ganon[player]) for player in range(1, world.players + 1)}
world.crystals_needed_for_gt = {
player: world.random.randint(0, 7) if args.crystals_gt[player] == 'random' else int(args.crystals_gt[player])
for player in range(1, world.players + 1)}
world.open_pyramid = args.open_pyramid.copy()
world.boss_shuffle = args.shufflebosses.copy()
world.enemy_shuffle = args.enemy_shuffle.copy()
@@ -120,7 +106,6 @@ def main(args, seed=None):
world.triforce_pieces_available = args.triforce_pieces_available.copy()
world.triforce_pieces_required = args.triforce_pieces_required.copy()
world.shop_shuffle = args.shop_shuffle.copy()
world.shop_shuffle_slots = args.shop_shuffle_slots.copy()
world.progression_balancing = args.progression_balancing.copy()
world.shuffle_prizes = args.shuffle_prizes.copy()
world.sprite_pool = args.sprite_pool.copy()
@@ -132,14 +117,16 @@ def main(args, seed=None):
world.restrict_dungeon_item_on_boss = args.restrict_dungeon_item_on_boss.copy()
world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy()
import Options
for hk_option in Options.hollow_knight_options:
setattr(world, hk_option, getattr(args, hk_option, {}))
world.set_options(args)
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))
if "-" in world.shuffle[player]:
@@ -148,8 +135,8 @@ def main(args, seed=None):
if shuffle == "vanilla":
world.er_seeds[player] = "vanilla"
elif seed.startswith("group-") or args.race:
# renamed from team to group to not confuse with existing team name use
world.er_seeds[player] = get_same_seed(world, (shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
world.er_seeds[player] = get_same_seed(world, (
shuffle, seed, world.retro[player], world.mode[player], world.logic[player]))
else: # not a race or group seed, use set seed as is.
world.er_seeds[player] = seed
elif world.shuffle[player] == "vanilla":
@@ -157,6 +144,11 @@ def main(args, seed=None):
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)
world.teams = len(parsed_names)
for i, team in enumerate(parsed_names, 1):
@@ -166,104 +158,58 @@ def main(args, seed=None):
world.player_names[player].append(name)
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]]
for player in world.player_ids:
for tok in filter(None, args.startinventory[player].split(',')):
item = ItemFactory(tok.strip(), player)
if item:
world.push_precollected(item)
for item_name in args.startinventory[player]:
world.push_precollected(world.create_item(item_name, player))
# enforce pre-defined local items.
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
world.local_items[player].add('Triforce Piece')
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')
# dungeon items can't be in non-local if the appropriate dungeon item shuffle setting is not set.
if not world.mapshuffle[player]:
world.non_local_items[player] -= item_name_groups['Maps']
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
world.non_local_items[player] -= world.local_items[player]
# dungeon items can't be in non-local if the appropriate dungeon item shuffle setting is not set.
if not world.mapshuffle[player]:
world.non_local_items[player] -= item_name_groups['Maps']
logger.info('Creating World.')
AutoWorld.call_all(world, "create_regions")
if not world.compassshuffle[player]:
world.non_local_items[player] -= item_name_groups['Compasses']
if not world.keyshuffle[player]:
world.non_local_items[player] -= item_name_groups['Small Keys']
if not world.bigkeyshuffle[player]:
world.non_local_items[player] -= item_name_groups['Big Keys']
# Not possible to place pendants/crystals out side of boss prizes yet.
world.non_local_items[player] -= item_name_groups['Pendants']
world.non_local_items[player] -= item_name_groups['Crystals']
for player in world.hk_player_ids:
hk_create_regions(world, player)
for player in world.factorio_player_ids:
factorio_create_regions(world, player)
for player in world.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'} or not world.shuffle_ganon)
else:
world.open_pyramid[player] = {'on': True, 'off': False, 'yes': True, 'no': False}.get(world.open_pyramid[player], world.open_pyramid[player])
world.triforce_pieces_available[player] = max(world.triforce_pieces_available[player], world.triforce_pieces_required[player])
if world.mode[player] != 'inverted':
create_regions(world, player)
else:
create_inverted_regions(world, player)
create_shops(world, player)
create_dungeons(world, player)
logger.info('Shuffling the World about.')
for player in world.alttp_player_ids:
if world.logic[player] not in ["noglitches", "minorglitches"] and world.shuffle[player] in \
{"vanilla", "dungeonssimple", "dungeonsfull", "simple", "restricted", "full"}:
world.fix_fake_world[player] = False
# seeded entrance shuffle
old_random = world.random
world.random = random.Random(world.er_seeds[player])
if world.mode[player] != 'inverted':
link_entrances(world, player)
mark_light_world_regions(world, player)
else:
link_inverted_entrances(world, player)
mark_dark_world_regions(world, player)
world.random = old_random
plando_connect(world, player)
logger.info('Generating Item Pool.')
for player in world.alttp_player_ids:
generate_itempool(world, player)
logger.info('Creating Items.')
AutoWorld.call_all(world, "create_items")
logger.info('Calculating Access Rules.')
if world.players > 1:
for player in world.player_ids:
locality_rules(world, player)
for player in world.alttp_player_ids:
set_rules(world, player)
AutoWorld.call_all(world, "set_rules")
for player in world.hk_player_ids:
gen_hollow(world, player)
for player in world.player_ids:
exclusion_rules(world, player, args.excluded_locations[player])
for player in world.factorio_player_ids:
gen_factorio(world, player)
AutoWorld.call_all(world, "generate_basic")
logger.info("Running Item Plando")
@@ -304,12 +250,10 @@ def main(args, seed=None):
balance_multiworld_progression(world)
logger.info('Generating output files.')
outfilebase = 'AP_%s' % (args.outputname if args.outputname else world.seed)
outfilebase = 'AP_' + world.seed_name
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]
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
or world.shufflepots[player] or world.bush_shuffle[player]
@@ -320,20 +264,20 @@ def main(args, seed=None):
patch_rom(world, rom, player, team, 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:
patch_race_rom(rom, world, player)
world.spoiler.hashes[(player, team)] = get_hash_string(rom.hash)
palettes_options={}
palettes_options['dungeon']=args.uw_palettes[player]
palettes_options['overworld']=args.ow_palettes[player]
palettes_options['hud']=args.hud_palettes[player]
palettes_options['sword']=args.sword_palettes[player]
palettes_options['shield']=args.shield_palettes[player]
palettes_options['link']=args.link_palettes[player]
palettes_options = {}
palettes_options['dungeon'] = args.uw_palettes[player]
palettes_options['overworld'] = args.ow_palettes[player]
palettes_options['hud'] = args.hud_palettes[player]
palettes_options['sword'] = args.sword_palettes[player]
palettes_options['shield'] = args.shield_palettes[player]
palettes_options['link'] = args.link_palettes[player]
apply_rom_settings(rom, args.heartbeep[player], args.heartcolor[player], args.quickswap[player],
args.fastmenu[player], args.disablemusic[player], args.sprite[player],
@@ -349,8 +293,8 @@ def main(args, seed=None):
world.bigkeyshuffle[player]].count(True) == 1:
mcsb_name = '-mapshuffle' if world.mapshuffle[player] else \
'-compassshuffle' if world.compassshuffle[player] else \
'-universal_keys' if world.keyshuffle[player] == "universal" else \
'-keyshuffle' if world.keyshuffle[player] else '-bigkeyshuffle'
'-universal_keys' if world.keyshuffle[player] == "universal" else \
'-keyshuffle' if world.keyshuffle[player] else '-bigkeyshuffle'
elif any([world.mapshuffle[player], world.compassshuffle[player], world.keyshuffle[player],
world.bigkeyshuffle[player]]):
mcsb_name = '-%s%s%s%sshuffle' % (
@@ -358,75 +302,75 @@ def main(args, seed=None):
'U' if world.keyshuffle[player] == "universal" else 'S' if world.keyshuffle[player] else '',
'B' if world.bigkeyshuffle[player] else '')
outfilepname = f'_T{team + 1}' if world.teams > 1 else ''
outfilepname += f'_P{player}'
outfilepname = f'_P{player}'
outfilepname += f"_{world.player_names[player][team].replace(' ', '_')}" \
if world.player_names[player][team] != 'Player%d' % player else ''
outfilestuffs = {
"logic": world.logic[player], # 0
"difficulty": world.difficulty[player], # 1
"item_functionality": world.item_functionality[player], # 2
"mode": world.mode[player], # 3
"goal": world.goal[player], # 4
"timer": str(world.timer[player]), # 5
"shuffle": world.shuffle[player], # 6
"algorithm": world.algorithm, # 7
"mscb": mcsb_name, # 8
"retro": world.retro[player], # 9
"progressive": world.progressive, # A
"hints": 'True' if world.hints[player] else 'False' # B
"logic": world.logic[player], # 0
"difficulty": world.difficulty[player], # 1
"item_functionality": world.item_functionality[player], # 2
"mode": world.mode[player], # 3
"goal": world.goal[player], # 4
"timer": str(world.timer[player]), # 5
"shuffle": world.shuffle[player], # 6
"algorithm": world.algorithm, # 7
"mscb": mcsb_name, # 8
"retro": world.retro[player], # 9
"progressive": world.progressive, # A
"hints": 'True' if world.hints[player] else 'False' # B
}
# 0 1 2 3 4 5 6 7 8 9 A B
outfilesuffix = ('_%s_%s-%s-%s-%s%s_%s-%s%s%s%s%s' % (
# 0 1 2 3 4 5 6 7 8 9 A B C
# _noglitches_normal-normal-open-ganon-ohko_simple-balanced-keysanity-retro-prog_random-nohints
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity-retro
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -prog_random
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -nohints
outfilestuffs["logic"], # 0
# 0 1 2 3 4 5 6 7 8 9 A B C
# _noglitches_normal-normal-open-ganon-ohko_simple-balanced-keysanity-retro-prog_random-nohints
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity-retro
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -prog_random
# _noglitches_normal-normal-open-ganon _simple-balanced-keysanity -nohints
outfilestuffs["logic"], # 0
outfilestuffs["difficulty"], # 1
outfilestuffs["item_functionality"], # 2
outfilestuffs["mode"], # 3
outfilestuffs["goal"], # 4
"" if outfilestuffs["timer"] in ['False', 'none', 'display'] else "-" + outfilestuffs["timer"], # 5
outfilestuffs["difficulty"], # 1
outfilestuffs["item_functionality"], # 2
outfilestuffs["mode"], # 3
outfilestuffs["goal"], # 4
"" if outfilestuffs["timer"] in ['False', 'none', 'display'] else "-" + outfilestuffs["timer"], # 5
outfilestuffs["shuffle"], # 6
outfilestuffs["algorithm"], # 7
outfilestuffs["mscb"], # 8
outfilestuffs["shuffle"], # 6
outfilestuffs["algorithm"], # 7
outfilestuffs["mscb"], # 8
"-retro" if outfilestuffs["retro"] == "True" else "", # 9
"-prog_" + outfilestuffs["progressive"] if outfilestuffs["progressive"] in ['off', 'random'] else "", # A
"-nohints" if not outfilestuffs["hints"] == "True" else "") # B
) if not args.outputname else ''
rompath = output_path(f'{outfilebase}{outfilepname}{outfilesuffix}.sfc')
"-retro" if outfilestuffs["retro"] == "True" else "", # 9
"-prog_" + outfilestuffs["progressive"] if outfilestuffs["progressive"] in ['off', 'random'] else "", # A
"-nohints" if not outfilestuffs["hints"] == "True" else "") # B
) if not args.outputname else ''
rompath = os.path.join(output_directory, f'{outfilebase}{outfilepname}{outfilesuffix}.sfc')
rom.write_to_file(rompath, hide_enemizer=True)
if args.create_diff:
Patch.create_patch_file(rompath)
Patch.create_patch_file(rompath, player=player, player_name=world.player_names[player][team])
os.unlink(rompath)
return player, team, bytes(rom.name)
pool = concurrent.futures.ThreadPoolExecutor()
multidata_task = None
check_accessibility_task = pool.submit(world.fulfills_accessibility)
if not args.suppress_rom:
output = tempfile.TemporaryDirectory()
with output as temp_dir:
check_accessibility_task = pool.submit(world.fulfills_accessibility)
rom_futures = []
mod_futures = []
output_file_futures = []
for team in range(world.teams):
for player in world.alttp_player_ids:
rom_futures.append(pool.submit(_gen_rom, team, player))
for player in world.factorio_player_ids:
mod_futures.append(pool.submit(generate_mod, world, player))
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))
def get_entrance_to_region(region: Region):
for entrance in region.entrances:
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld):
if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic):
return entrance
for entrance in region.entrances: # BFS might be better here, trying DFS for now.
return get_entrance_to_region(entrance.parent_region)
# collect ER hint info
er_hint_data = {player: {} for player in range(1, world.players + 1) if world.shuffle[player] != "vanilla" or world.retro[player]}
er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if
world.shuffle[player] != "vanilla" or world.retro[player]}
from worlds.alttp.Regions import RegionType
for region in world.regions:
if region.player in er_hint_data and region.locations:
@@ -448,11 +392,11 @@ def main(args, seed=None):
for location in [loc for loc in world.get_filled_locations() if type(loc.address) is int]:
main_entrance = get_entrance_to_region(location.parent_region)
if location.game != Games.LTTP:
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'}\
'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:
@@ -464,8 +408,10 @@ def main(args, seed=None):
oldmancaves = []
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
for index, take_any in enumerate(takeanyregions):
for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if world.retro[player]]:
item = ItemFactory(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'], region.player)
for region in [world.get_region(take_any, player) for player in range(1, world.players + 1) if
world.retro[player]]:
item = world.create_item(region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
region.player)
player = region.player
location_id = SHOP_ID_START + total_shop_slots + index
@@ -479,218 +425,109 @@ def main(args, seed=None):
er_hint_data[player][location_id] = main_entrance.name
oldmancaves.append(((location_id, player), (item.code, player)))
precollected_items = [[] for player in range(world.players)]
for item in world.precollected_items:
precollected_items[item.player - 1].append(item.code)
FillDisabledShopSlots(world)
def write_multidata(roms, mods):
def write_multidata(roms, outputs):
import base64
import NetUtils
for future in roms:
rom_name = future.result()
rom_names.append(rom_name)
minimum_versions = {"server": (0, 0, 2)}
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}
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.alttp_player_ids:
if player not in world.get_game_players("A Link to the Past"):
connect_names[name] = (i, player)
multidata = zlib.compress(pickle.dumps({"names": parsed_names,
"connect_names": connect_names,
"remote_items": {player for player in range(1, world.players + 1) if
world.remote_items[player] or
world.game[player] != "A Link to the Past"},
"locations": {
(location.address, location.player):
(location.item.code, location.item.player)
for location in world.get_filled_locations() if
type(location.address) is int},
"checks_in_area": checks_in_area,
"server_options": get_options()["server_options"],
"er_hint_data": er_hint_data,
"precollected_items": precollected_items,
"version": tuple(_version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
}), 9)
with open(output_path('%s.archipelago' % outfilebase), 'wb') as f:
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 mods:
future.result() # collect errors if they occured
for future in outputs:
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
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))
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:
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
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(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
zipfilename = output_path(f"AP_{world.seed_name}.zip")
logger.info(f'Creating final archive at {zipfilename}.')
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
for file in os.scandir(temp_dir):
zf.write(os.path.join(temp_dir, file), arcname=file.name)
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
return world
def copy_world(world):
# ToDo: Not good yet
# delete now?
ret = MultiWorld(world.players, world.shuffle, world.logic, world.mode, world.swords, world.difficulty, world.item_functionality, world.timer, world.progressive, world.goal, world.algorithm, world.accessibility, world.shuffle_ganon, world.retro, world.custom, world.customitemarray, world.hints)
ret.teams = world.teams
ret.player_names = copy.deepcopy(world.player_names)
ret.remote_items = world.remote_items.copy()
ret.required_medallions = world.required_medallions.copy()
ret.swamp_patch_required = world.swamp_patch_required.copy()
ret.ganon_at_pyramid = world.ganon_at_pyramid.copy()
ret.powder_patch_required = world.powder_patch_required.copy()
ret.ganonstower_vanilla = world.ganonstower_vanilla.copy()
ret.treasure_hunt_count = world.treasure_hunt_count.copy()
ret.treasure_hunt_icon = world.treasure_hunt_icon.copy()
ret.sewer_light_cone = world.sewer_light_cone.copy()
ret.light_world_light_cone = world.light_world_light_cone
ret.dark_world_light_cone = world.dark_world_light_cone
ret.seed = world.seed
ret.can_access_trock_eyebridge = world.can_access_trock_eyebridge.copy()
ret.can_access_trock_front = world.can_access_trock_front.copy()
ret.can_access_trock_big_chest = world.can_access_trock_big_chest.copy()
ret.can_access_trock_middle = world.can_access_trock_middle.copy()
ret.can_take_damage = world.can_take_damage
ret.difficulty_requirements = world.difficulty_requirements.copy()
ret.fix_fake_world = world.fix_fake_world.copy()
ret.mapshuffle = world.mapshuffle.copy()
ret.compassshuffle = world.compassshuffle.copy()
ret.keyshuffle = world.keyshuffle.copy()
ret.bigkeyshuffle = world.bigkeyshuffle.copy()
ret.crystals_needed_for_ganon = world.crystals_needed_for_ganon.copy()
ret.crystals_needed_for_gt = world.crystals_needed_for_gt.copy()
ret.open_pyramid = world.open_pyramid.copy()
ret.boss_shuffle = world.boss_shuffle.copy()
ret.enemy_shuffle = world.enemy_shuffle.copy()
ret.enemy_health = world.enemy_health.copy()
ret.enemy_damage = world.enemy_damage.copy()
ret.beemizer = world.beemizer.copy()
ret.timer = world.timer.copy()
ret.shufflepots = world.shufflepots.copy()
ret.shuffle_prizes = world.shuffle_prizes.copy()
ret.shop_shuffle = world.shop_shuffle.copy()
ret.shop_shuffle_slots = world.shop_shuffle_slots.copy()
ret.dark_room_logic = world.dark_room_logic.copy()
ret.restrict_dungeon_item_on_boss = world.restrict_dungeon_item_on_boss.copy()
ret.game = world.game.copy()
ret.completion_condition = world.completion_condition.copy()
for player in world.alttp_player_ids:
if world.mode[player] != 'inverted':
create_regions(ret, player)
else:
create_inverted_regions(ret, player)
create_shops(ret, player)
create_dungeons(ret, player)
for player in world.hk_player_ids:
hk_create_regions(ret, player)
copy_dynamic_regions_and_locations(world, ret)
# copy bosses
for dungeon in world.dungeons:
for level, boss in dungeon.bosses.items():
ret.get_dungeon(dungeon.name, dungeon.player).bosses[level] = boss
for shop in world.shops:
copied_shop = ret.get_region(shop.region.name, shop.region.player).shop
copied_shop.inventory = copy.copy(shop.inventory)
# connect copied world
for region in world.regions:
copied_region = ret.get_region(region.name, region.player)
copied_region.is_light_world = region.is_light_world
copied_region.is_dark_world = region.is_dark_world
for exit in copied_region.exits:
old_connection = world.get_entrance(exit.name, exit.player).connected_region
exit.connect(ret.get_region(old_connection.name, old_connection.player))
# fill locations
for location in world.get_locations():
if location.item is not None:
item = Item(location.item.name, location.item.advancement, location.item.code, player = location.item.player)
ret.get_location(location.name, location.player).item = item
item.location = ret.get_location(location.name, location.player)
item.world = ret
item.type = location.item.type
item.game = location.item.game
if location.event:
ret.get_location(location.name, location.player).event = True
if location.locked:
ret.get_location(location.name, location.player).locked = True
# copy remaining itempool. No item in itempool should have an assigned location
for old_item in world.itempool:
item = Item(old_item.name, old_item.advancement, old_item.code, player = old_item.player)
item.type = old_item.type
ret.itempool.append(item)
for old_item in world.precollected_items:
item = Item(old_item.name, old_item.advancement, old_item.code, player = old_item.player)
item.type = old_item.type
ret.push_precollected(item)
# copy progress items in state
ret.state.prog_items = world.state.prog_items.copy()
ret.state.stale = {player: True for player in range(1, world.players + 1)}
for player in world.alttp_player_ids:
set_rules(ret, player)
for player in world.hk_player_ids:
set_hk_rules(ret, player)
return ret
def copy_dynamic_regions_and_locations(world, ret):
for region in world.dynamic_regions:
new_reg = Region(region.name, region.type, region.hint_text, region.player)
ret.regions.append(new_reg)
ret.initialize_regions([new_reg])
ret.dynamic_regions.append(new_reg)
# Note: ideally exits should be copied here, but the current use case (Take anys) do not require this
if region.shop:
new_reg.shop = region.shop.__class__(new_reg, region.shop.room_id, region.shop.shopkeeper_config,
region.shop.custom, region.shop.locked, region.shop.sram_offset)
ret.shops.append(new_reg.shop)
for location in world.dynamic_locations:
new_reg = ret.get_region(location.parent_region.name, location.parent_region.player)
new_loc = ALttPLocation(location.player, location.name, location.address, location.crystal, location.hint_text, new_reg)
# todo: this is potentially dangerous. later refactor so we
# can apply dynamic region rules on top of copied world like other rules
new_loc.access_rule = location.access_rule
new_loc.always_allow = location.always_allow
new_loc.item_rule = location.item_rule
new_reg.locations.append(new_loc)
ret.clear_location_cache()
def create_playthrough(world):
"""Destructive to the world it is run on."""
"""Destructive to the world while it is run, damage gets repaired afterwards."""
# get locations containing progress items
prog_locations = {location for location in world.get_filled_locations() if location.item.advancement}
state_cache = [None]
@@ -701,7 +538,8 @@ def create_playthrough(world):
while sphere_candidates:
state.sweep_for_events(key_only=True)
# build up spheres of collection radius. Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
sphere = {location for location in sphere_candidates if state.can_reach(location)}
@@ -732,7 +570,8 @@ def create_playthrough(world):
to_delete = set()
for location in sphere:
# we remove the item at location and check if game is still beatable
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name, location.item.player)
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
location.item.player)
old_item = location.item
location.item = None
if world.can_beat_game(state_cache[num]):
@@ -777,7 +616,8 @@ def create_playthrough(world):
collection_spheres.append(sphere)
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere), len(required_locations))
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
len(sphere), len(required_locations))
if not sphere:
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
@@ -794,16 +634,25 @@ def create_playthrough(world):
pathpairs = zip_longest(pathsiter, pathsiter)
return list(pathpairs)
world.spoiler.paths = dict()
for player in range(1, world.players + 1):
world.spoiler.paths.update({ str(location) : get_path(state, location.parent_region) for sphere in collection_spheres for location in sphere if location.player == player})
if player in world.alttp_player_ids:
world.spoiler.paths = {}
topology_worlds = (player for player in world.player_ids if world.worlds[player].topology_present)
for player in topology_worlds:
world.spoiler.paths.update(
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
sphere if location.player == player})
if player in world.get_game_players("A Link to the Past"):
for path in dict(world.spoiler.paths).values():
if any(exit == 'Pyramid Fairy' for (_, exit) in path):
if world.mode[player] != 'inverted':
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = get_path(state, world.get_region('Big Bomb Shop', player))
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = get_path(state,
world.get_region(
'Big Bomb Shop',
player))
else:
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = get_path(state, world.get_region('Inverted Big Bomb Shop', player))
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = get_path(state,
world.get_region(
'Inverted Big Bomb Shop',
player))
# we can finally output our playthrough
world.spoiler.playthrough = {"0": sorted([str(item) for item in world.precollected_items if item.advancement])}

View File

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

View File

@@ -1,223 +0,0 @@
import os
import subprocess
import sys
import threading
import concurrent.futures
import argparse
import logging
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 Patch import create_patch_file
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_spoiler = multi_mystery_options["zip_spoiler"]
zip_multidata = multi_mystery_options["zip_multidata"]
zip_format = multi_mystery_options["zip_format"]
# zip_password = multi_mystery_options["zip_password"] not at this time
player_name = multi_mystery_options["player_name"]
meta_file_path = multi_mystery_options["meta_file_path"]
weights_file_path = multi_mystery_options["weights_file_path"]
pre_roll = multi_mystery_options["pre_roll"]
teams = multi_mystery_options["teams"]
rom_file = options["lttp_options"]["rom_file"]
host = options["server_options"]["host"]
port = options["server_options"]["port"]
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
if not os.path.exists(enemizer_path):
feedback(
f"Enemizer not found at {enemizer_path}, please adjust the path in MultiMystery.py's config or put Enemizer in the default location.")
if not os.path.exists(rom_file):
feedback(f"Base rom is expected as {rom_file} in the Multiworld root folder please place/rename it there.")
player_files = []
os.makedirs(player_files_path, exist_ok=True)
for file in os.listdir(player_files_path):
lfile = file.lower()
if lfile.endswith(".yaml") and lfile != meta_file_path.lower() and lfile != weights_file_path.lower():
player_files.append(file)
logging.info(f"Found player's file {file}.")
player_string = ""
for i, file in enumerate(player_files, 1):
player_string += f"--p{i} \"{os.path.join(player_files_path, file)}\" "
if os.path.exists("ArchipelagoMystery.exe"):
basemysterycommand = "ArchipelagoMystery.exe" # compiled windows
elif os.path.exists("ArchipelagoMystery"):
basemysterycommand = "ArchipelagoMystery" # compiled linux
else:
basemysterycommand = f"py -{py_version} Mystery.py" # source
weights_file_path = os.path.join(player_files_path, weights_file_path)
if os.path.exists(weights_file_path):
target_player_count = max(len(player_files), target_player_count)
else:
target_player_count = len(player_files)
if target_player_count == 0:
feedback(f"No player files found. Please put them in a {player_files_path} folder.")
else:
logging.info(f"{target_player_count} Players found.")
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}\""
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.")
seedname = ""
for segment in text.split():
if segment.startswith("M"):
seedname = segment
break
multidataname = f"AP_{seedname}.archipelago"
spoilername = f"AP_{seedname}_Spoiler.txt"
romfilename = ""
if player_name:
for file in os.listdir(output_path):
if player_name in file:
import MultiClient
import asyncio
asyncio.run(MultiClient.run_game(os.path.join(output_path, file)))
break
if any((zip_roms, zip_multidata, zip_spoiler, zip_diffs)):
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_{seedname}.{typical_zip_ending}")
logging.info(f"Creating zipfile {zipname}")
ipv4 = (host if host else get_public_ipv4()) + ":" + str(port)
def _handle_sfc_file(file: str):
if zip_roms:
pack_file(file)
if zip_roms == 2 and player_name.lower() not in file.lower():
remove_zipped_file(file)
def _handle_diff_file(file: str):
if zip_diffs > 0:
pack_file(file)
if zip_diffs == 2:
remove_zipped_file(file)
with concurrent.futures.ThreadPoolExecutor() as pool:
futures = []
with zipfile.ZipFile(zipname, "w", compression=compression, compresslevel=9) as zf:
for file in os.listdir(output_path):
if seedname 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))
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,25 +25,21 @@ import prompt_toolkit
from prompt_toolkit.patch_stdout import patch_stdout
from fuzzywuzzy import process as fuzzy_process
from worlds.alttp import Items, Regions
from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_item_name_to_id, \
lookup_any_location_id_to_name, lookup_any_location_name_to_id
from worlds.AutoWorld import AutoWorldRegister
proxy_worlds = {name: world(None, 0) for name, world in AutoWorldRegister.world_types.items()}
from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_location_id_to_name
import Utils
from Utils import get_item_name_from_id, get_location_name_from_address, \
_version_tuple, restricted_loads, Version
from NetUtils import Node, Endpoint, ClientStatus, NetworkItem, decode
from Utils import get_item_name_from_id, get_location_name_from_id, \
version_tuple, restricted_loads, Version
from NetUtils import Node, Endpoint, ClientStatus, NetworkItem, decode, NetworkPlayer
colorama.init()
lttp_console_names = frozenset(set(Items.item_table) | set(Items.item_name_groups) | set(Regions.lookup_name_to_id))
all_items = frozenset(lookup_any_item_name_to_id)
all_locations = frozenset(lookup_any_location_name_to_id)
all_console_names = frozenset(all_items | all_locations)
class Client(Endpoint):
version = Version(0, 0, 0)
tags: typing.List[str] = []
def __init__(self, socket: websockets.server.WebSocketServerProtocol, ctx: Context):
def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
super().__init__(socket)
self.auth = False
self.name = None
@@ -54,6 +50,7 @@ class Client(Endpoint):
self.messageprocessor = client_message_processor(ctx, self)
self.ctx = weakref.ref(ctx)
team_slot = typing.Tuple[int, int]
class Context(Node):
simple_options = {"hint_cost": int,
@@ -74,11 +71,12 @@ class Context(Node):
self.data_filename = None
self.save_filename = None
self.saving = False
self.player_names = {}
self.connect_names = {} # names of slots clients can connect to
self.player_names: typing.Dict[team_slot, str] = {}
self.player_name_lookup: typing.Dict[str, team_slot] = {}
self.connect_names = {} # names of slots clients can connect to
self.allow_forfeits = {}
self.remote_items = set()
self.locations = {}
self.locations: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int]]] = {}
self.host = host
self.port = port
self.server_password = server_password
@@ -86,21 +84,21 @@ class Context(Node):
self.server = None
self.countdown_timer = 0
self.received_items = {}
self.name_aliases: typing.Dict[typing.Tuple[int, int], str] = {}
self.name_aliases: typing.Dict[team_slot, str] = {}
self.location_checks = collections.defaultdict(set)
self.hint_cost = hint_cost
self.location_check_points = location_check_points
self.hints_used = collections.defaultdict(int)
self.hints: typing.Dict[typing.Tuple[int, int], typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
self.forfeit_mode: str = forfeit_mode
self.remaining_mode: str = remaining_mode
self.item_cheat = item_cheat
self.running = True
self.client_activity_timers: typing.Dict[
typing.Tuple[int, int], datetime.datetime] = {} # datetime of last new item check
team_slot, datetime.datetime] = {} # datetime of last new item check
self.client_connection_timers: typing.Dict[
typing.Tuple[int, int], datetime.datetime] = {} # datetime of last connection
self.client_game_state: typing.Dict[typing.Tuple[int, int], int] = collections.defaultdict(int)
team_slot, datetime.datetime] = {} # datetime of last connection
self.client_game_state: typing.Dict[team_slot, int] = collections.defaultdict(int)
self.er_hint_data: typing.Dict[int, typing.Dict[int, str]] = {}
self.auto_shutdown = auto_shutdown
self.commandprocessor = ServerCommandProcessor(self)
@@ -110,7 +108,14 @@ class Context(Node):
self.auto_saver_thread = None
self.save_dirty = False
self.tags = ['AP']
self.minimum_client_versions: typing.Dict[typing.Tuple[int, int], Utils.Version] = {}
self.games: typing.Dict[int, str] = {}
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
self.seed_name = ""
def get_hint_cost(self, slot):
if self.hint_cost:
return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0
def load(self, multidatapath: str, use_embedded_server_options: bool = False):
with open(multidatapath, 'rb') as f:
@@ -119,43 +124,55 @@ class Context(Node):
self._load(self._decompress(data), use_embedded_server_options)
self.data_filename = multidatapath
def _decompress(self, data: bytes) -> dict:
@staticmethod
def _decompress(data: bytes) -> dict:
format_version = data[0]
if format_version != 1:
raise Exception("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: dict, use_embedded_server_options: bool):
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > Utils._version_tuple:
raise RuntimeError(f"Supplied Multidata requires a server of at least version {mdata_ver},"
f"however this server is of version {Utils._version_tuple}")
clients_ver = decoded_obj["minimum_versions"].get("clients", [])
if mdata_ver > Utils.version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
f"however this server is of version {Utils.version_tuple}")
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {}
for team, player, version in clients_ver:
self.minimum_client_versions[team, player] = Utils.Version(*version)
for player, version in clients_ver.items():
self.minimum_client_versions[player] = Utils.Version(*version)
for team, names in enumerate(decoded_obj['names']):
for player, name in enumerate(names, 1):
self.player_names[(team, player)] = name
self.player_names[team, player] = name
self.player_name_lookup[name] = team, player
self.seed_name = decoded_obj["seed_name"]
self.connect_names = decoded_obj['connect_names']
self.remote_items = decoded_obj['remote_items']
self.locations = decoded_obj['locations']
self.slot_data = decoded_obj['slot_data']
self.er_hint_data = {int(player): {int(address): name for address, name in loc_data.items()}
for player, loc_data in decoded_obj["er_hint_data"].items()}
self.games = decoded_obj["games"]
# award remote-items start inventory:
for team in range(len(decoded_obj['names'])):
for slot, item_codes in decoded_obj["precollected_items"].items():
if slot in self.remote_items:
self.received_items[team, slot] = [NetworkItem(item_code, -2, 0) for item_code in item_codes]
for slot, hints in decoded_obj["precollected_hints"].items():
self.hints[team, slot].update(hints)
if use_embedded_server_options:
server_options = decoded_obj.get("server_options", {})
self._set_options(server_options)
def get_players_package(self):
return [(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()]
def _set_options(self, server_options: dict):
for key, value in server_options.items():
data_type = self.simple_options.get(key, None)
if data_type is not None:
if value not in {False, True, None}: # some can be boolean OR text, such as password
if value not in {False, True, None}: # some can be boolean OR text, such as password
try:
value = data_type(value)
except Exception as e:
@@ -181,7 +198,7 @@ class Context(Node):
return False
def _save(self, exit_save:bool=False) -> bool:
def _save(self, exit_save: bool = False) -> bool:
try:
encoded_save = pickle.dumps(self.get_save())
with open(self.save_filename, "wb") as f:
@@ -225,44 +242,50 @@ class Context(Node):
import atexit
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:
self.recheck_hints()
d = {
"rom_names": list(self.connect_names.items()),
"received_items": tuple((k, v) for k, v in self.received_items.items()),
"hints_used": tuple((key, value) for key, value in self.hints_used.items()),
"hints": tuple(
(key, list(hint.re_check(self, key[0]) for hint in value)) for key, value in self.hints.items()),
"location_checks": tuple((key, tuple(value)) for key, value in self.location_checks.items()),
"name_aliases": tuple((key, value) for key, value in self.name_aliases.items()),
"client_game_state": tuple((key, value) for key, value in self.client_game_state.items()),
"connect_names": self.connect_names,
"received_items": self.received_items,
"hints_used": dict(self.hints_used),
"hints": dict(self.hints),
"location_checks": dict(self.location_checks),
"name_aliases": self.name_aliases,
"client_game_state": dict(self.client_game_state),
"client_activity_timers": tuple(
(key, value.timestamp()) for key, value in self.client_activity_timers.items()),
"client_connection_timers": tuple(
(key, value.timestamp()) for key, value in self.client_connection_timers.items()),
}
return d
def set_save(self, savedata: dict):
if self.connect_names != savedata["connect_names"]:
raise Exception("This savegame does not appear to match the loaded multiworld.")
self.received_items = savedata["received_items"]
self.hints_used.update(savedata["hints_used"])
self.hints.update(savedata["hints"])
received_items = {tuple(k): [NetworkItem(*i) for i in v] for k, v in savedata["received_items"]}
self.received_items = received_items
self.hints_used.update({tuple(key): value for key, value in savedata["hints_used"]})
self.hints.update(
{tuple(key): set(NetUtils.Hint(*hint) for hint in value) for key, value in savedata["hints"]})
self.name_aliases.update({tuple(key): value for key, value in savedata["name_aliases"]})
self.client_game_state.update({tuple(key): value for key, value in savedata["client_game_state"]})
self.name_aliases.update(savedata["name_aliases"])
self.client_game_state.update(savedata["client_game_state"])
self.client_connection_timers.update(
{tuple(key): datetime.datetime.fromtimestamp(value, datetime.timezone.utc) for key, value
in savedata["client_connection_timers"]})
self.client_activity_timers.update(
{tuple(key): datetime.datetime.fromtimestamp(value, datetime.timezone.utc) for key, value
in savedata["client_activity_timers"]})
self.location_checks.update({tuple(key): set(value) for key, value in savedata["location_checks"]})
self.location_checks.update(savedata["location_checks"])
logging.info(f'Loaded save file with {sum([len(p) for p in received_items.values()])} received items '
f'for {len(received_items)} players')
logging.info(f'Loaded save file with {sum([len(p) for p in self.received_items.values()])} received items '
f'for {len(self.received_items)} players')
def get_aliased_name(self, team: int, slot: int):
if (team, slot) in self.name_aliases:
@@ -303,16 +326,20 @@ class Context(Node):
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint]):
cmd = ctx.dumper([{"cmd": "Hint", "hints" : hints}])
commands = ctx.dumper([hint.as_network_message() for hint in hints])
concerns = collections.defaultdict(list)
for hint in hints:
net_msg = hint.as_network_message()
concerns[hint.receiving_player].append(net_msg)
if not hint.local:
concerns[hint.finding_player].append(net_msg)
for text in (format_hint(ctx, team, hint) for hint in hints):
logging.info("Notice (Team #%d): %s" % (team + 1, text))
for client in ctx.endpoints:
if client.auth and client.team == team:
asyncio.create_task(ctx.send_encoded_msgs(client, cmd))
asyncio.create_task(ctx.send_encoded_msgs(client, commands))
client_hints = concerns[client.slot]
if client_hints:
asyncio.create_task(ctx.send_msgs(client, client_hints))
def update_aliases(ctx: Context, team: int, client: typing.Optional[Client] = None):
@@ -331,18 +358,22 @@ async def server(websocket, path, ctx: Context):
ctx.endpoints.append(client)
try:
logging.info("Incoming")
if ctx.log_network:
logging.info("Incoming connection")
await on_client_connected(ctx, client)
logging.info("Sent Room Info")
if ctx.log_network:
logging.info("Sent Room Info")
async for data in websocket:
logging.info(data)
if ctx.log_network:
logging.info(f"Incoming message: {data}")
for msg in decode(data):
await process_client_cmd(ctx, client, msg)
except Exception as e:
if not isinstance(e, websockets.WebSocketException):
logging.exception(e)
finally:
logging.info("Disconnected")
if ctx.log_network:
logging.info("Disconnected")
await ctx.disconnect(client)
@@ -350,17 +381,22 @@ async def on_client_connected(ctx: Context, client: Client):
await ctx.send_msgs(client, [{
'cmd': 'RoomInfo',
'password': ctx.password is not None,
'players': [(client.team, client.slot, ctx.name_aliases.get((client.team, client.slot), client.name)) for client
in ctx.endpoints if client.auth],
'players': [
NetworkPlayer(client.team, client.slot, ctx.name_aliases.get((client.team, client.slot), client.name),
client.name) for client
in ctx.endpoints if client.auth],
# tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate.
'tags': ctx.tags,
'version': Utils._version_tuple,
'version': Utils.version_tuple,
'forfeit_mode': ctx.forfeit_mode,
'remaining_mode': ctx.remaining_mode,
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points,
'datapackage_version': network_data_package["version"]
'datapackage_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
}])
@@ -378,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)
async def on_client_left(ctx: Context, client: Client):
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
ctx.notify_all("%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1))
ctx.notify_all(
"%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1))
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
@@ -420,34 +458,30 @@ def get_received_items(ctx: Context, team: int, player: int) -> typing.List[Netw
return ctx.received_items.setdefault((team, player), [])
def tuplize_received_items(items):
return [NetworkItem(item.item, item.location, item.player) for item in items]
def send_new_items(ctx: Context):
for client in ctx.endpoints:
if client.auth: # can't send to disconnected client
if client.auth: # can't send to disconnected client
items = get_received_items(ctx, client.team, client.slot)
if len(items) > client.send_index:
asyncio.create_task(ctx.send_msgs(client, [{
"cmd": "ReceivedItems",
"index": client.send_index,
"items": tuplize_received_items(items)[client.send_index:]}]))
"items": items[client.send_index:]}]))
client.send_index = len(items)
def forfeit_player(ctx: Context, team: int, slot: int):
# register any locations that are in the multidata
all_locations = {location_id for location_id, location_slot in ctx.locations if location_slot == slot}
all_locations = set(ctx.locations[slot])
ctx.notify_all("%s (Team #%d) has forfeited" % (ctx.player_names[(team, slot)], team + 1))
register_location_checks(ctx, team, slot, all_locations)
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
items = []
for (location, location_slot) in ctx.locations:
if location_slot == slot and location not in ctx.location_checks[team, slot]:
items.append(ctx.locations[location, slot][0]) # item ID
for location_id in ctx.locations[slot]:
if location_id not in ctx.location_checks[team, slot]:
items.append(ctx.locations[slot][location_id][0]) # item ID
return sorted(items)
@@ -456,15 +490,15 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
if new_locations:
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
for location in new_locations:
if (location, slot) in ctx.locations:
item_id, target_player = ctx.locations[(location, slot)]
if location in ctx.locations[slot]:
item_id, target_player = ctx.locations[slot][location]
new_item = NetworkItem(item_id, location, slot)
if target_player != slot or slot in ctx.remote_items:
get_received_items(ctx, team, target_player).append(new_item)
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id),
ctx.player_names[(team, target_player)], get_location_name_from_address(location)))
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id),
ctx.player_names[(team, target_player)], get_location_name_from_id(location)))
info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text])
@@ -483,60 +517,63 @@ def notify_team(ctx: Context, team: int, text: str):
ctx.broadcast_team(team, [['Print', {"text": text}]])
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
hints = []
seeked_item_id = lookup_any_item_name_to_id[item]
for check, result in ctx.locations.items():
item_id, receiving_player = result
if receiving_player == slot and item_id == seeked_item_id:
location_id, finding_player = check
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance))
seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item]
for finding_player, check_data in ctx.locations.items():
for location_id, result in check_data.items():
item_id, receiving_player = result
if receiving_player == slot and item_id == seeked_item_id:
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance))
return hints
def collect_hints_location(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
hints = []
seeked_location = Regions.lookup_name_to_id[location]
for check, result in ctx.locations.items():
location_id, finding_player = check
if finding_player == slot and location_id == seeked_location:
item_id, receiving_player = result
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance))
break # each location has 1 item
return hints
seeked_location: int = proxy_worlds[ctx.games[slot]].location_name_to_id[location]
item_id, receiving_player = ctx.locations[slot].get(seeked_location, (None, None))
if item_id:
found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance)]
return []
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
f"{lookup_any_item_id_to_name[hint.item]} is " \
f"at {get_location_name_from_address(hint.location)} " \
f"at {get_location_name_from_id(hint.location)} " \
f"in {ctx.player_names[team, hint.finding_player]}'s World"
if hint.entrance:
text += f" at {hint.entrance}"
return text + (". (found)" if hint.found else ".")
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
parts = []
NetUtils.add_json_text(parts, net_item.player, type=NetUtils.JSONTypes.player_id)
NetUtils.add_json_text(parts, " sent ")
NetUtils.add_json_text(parts, net_item.item, type=NetUtils.JSONTypes.item_id)
NetUtils.add_json_text(parts, " to ")
NetUtils.add_json_text(parts, receiving_player, type=NetUtils.JSONTypes.player_id)
if net_item.player == receiving_player:
NetUtils.add_json_text(parts, " found their ")
NetUtils.add_json_text(parts, net_item.item, type=NetUtils.JSONTypes.item_id)
else:
NetUtils.add_json_text(parts, " sent ")
NetUtils.add_json_text(parts, net_item.item, type=NetUtils.JSONTypes.item_id)
NetUtils.add_json_text(parts, " to ")
NetUtils.add_json_text(parts, receiving_player, type=NetUtils.JSONTypes.player_id)
NetUtils.add_json_text(parts, " (")
NetUtils.add_json_text(parts, net_item.location, type=NetUtils.JSONTypes.location_id)
NetUtils.add_json_text(parts, ")")
return {"cmd": "PrintJSON", "data": parts, "type": "ItemSend",
"receiving": receiving_player, "sending": net_item.player}
"receiving": receiving_player,
"item": net_item}
def get_intended_text(input_text: str, possible_answers: typing.Iterable[str]= all_console_names) -> typing.Tuple[str, bool, str]:
def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bool, str]:
picks = fuzzy_process.extract(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
@@ -661,11 +698,12 @@ class CommonCommandProcessor(CommandProcessor):
"""List all current options. Warning: lists password."""
self.output("Current options:")
for option in self.ctx.simple_options:
if option == "server_password" and self.marker == "!": #Do not display the server password to the client.
self.output(f"Option server_password is set to {('*' * random.randint(4,16))}")
if option == "server_password" and self.marker == "!": # Do not display the server password to the client.
self.output(f"Option server_password is set to {('*' * random.randint(4, 16))}")
else:
self.output(f"Option {option} is set to {getattr(self.ctx, option)}")
class ClientMessageProcessor(CommonCommandProcessor):
marker = "!"
@@ -692,11 +730,14 @@ class ClientMessageProcessor(CommonCommandProcessor):
"""Allow remote administration of the multiworld server"""
output = f"!admin {command}"
if output.lower().startswith("!admin login"): # disallow others from seeing the supplied password, whether or not it is correct.
if output.lower().startswith(
"!admin login"): # disallow others from seeing the supplied password, whether or not it is correct.
output = f"!admin login {('*' * random.randint(4, 16))}"
elif output.lower().startswith("!admin /option server_password"): # disallow others from knowing what the new remote administration password is.
elif output.lower().startswith(
"!admin /option server_password"): # disallow others from knowing what the new remote administration password is.
output = f"!admin /option server_password {('*' * random.randint(4, 16))}"
self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + output) # Otherwise notify the others what is happening.
self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team,
self.client.slot) + ': ' + output) # Otherwise notify the others what is happening.
if not self.ctx.server_password:
self.output("Sorry, Remote administration is disabled")
@@ -704,7 +745,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
if not command:
if self.is_authenticated():
self.output("Usage: !admin [Server command].\nUse !admin /help for help.\nUse !admin logout to log out of the current session.")
self.output(
"Usage: !admin [Server command].\nUse !admin /help for help.\nUse !admin logout to log out of the current session.")
else:
self.output("Usage: !admin login [password]")
return True
@@ -764,7 +806,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.remaining_mode == "enabled":
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(Items.lookup_id_to_name.get(item_id, "unknown item")
self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item")
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
@@ -777,7 +819,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(Items.lookup_id_to_name.get(item_id, "unknown item")
self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item")
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
@@ -787,14 +829,13 @@ class ClientMessageProcessor(CommonCommandProcessor):
"Sorry, !remaining requires you to have beaten the game on this server")
return False
def _cmd_missing(self) -> bool:
"""List all missing location checks from the server's perspective"""
locations = get_missing_checks(self.ctx, self.client)
if locations:
texts = [f'Missing: {get_item_name_from_id(location)}\n' for location in locations]
texts = [f'Missing: {get_location_name_from_id(location)}' for location in locations]
texts.append(f"Found {len(locations)} missing location checks")
self.ctx.notify_client_multiple(self.client, texts)
else:
@@ -823,11 +864,15 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_getitem(self, item_name: str) -> bool:
"""Cheat in an item, if it is enabled on this server"""
if self.ctx.item_cheat:
item_name, usable, response = get_intended_text(item_name, Items.item_table.keys())
world = proxy_worlds[self.ctx.games[self.client.slot]]
item_name, usable, response = get_intended_text(item_name,
world.item_names)
if usable:
new_item = NetworkItem(Items.item_table[item_name][2], -1, self.client.slot)
new_item = NetworkItem(world.create_item(item_name).code, -1, self.client.slot)
get_received_items(self.ctx, self.client.team, self.client.slot).append(new_item)
self.ctx.notify_all('Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team, self.client.slot))
self.ctx.notify_all(
'Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team,
self.client.slot))
send_new_items(self.ctx)
return True
else:
@@ -842,7 +887,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
"""Use !hint {item_name/location_name}, for example !hint Lamp or !hint Link's House. """
points_available = get_client_points(self.ctx, self.client)
if not item_or_location:
self.output(f"A hint costs {self.ctx.hint_cost} points. "
self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. "
f"You have {points_available} points.")
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
@@ -850,20 +895,21 @@ class ClientMessageProcessor(CommonCommandProcessor):
notify_hints(self.ctx, self.client.team, list(hints))
return True
else:
item_name, usable, response = get_intended_text(item_or_location)
world = proxy_worlds[self.ctx.games[self.client.slot]]
item_name, usable, response = get_intended_text(item_or_location, world.all_names)
if usable:
if item_name in Items.hint_blacklist:
if item_name in world.hint_blacklist:
self.output(f"Sorry, \"{item_name}\" is marked as non-hintable.")
hints = []
elif item_name in Items.item_name_groups:
elif item_name in world.item_name_groups:
hints = []
for item in Items.item_name_groups[item_name]:
for item in world.item_name_groups[item_name]:
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
elif item_name in lookup_any_item_name_to_id: # item name
elif item_name in world.item_names: # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, item_name)
else: # location name
hints = collect_hints_location(self.ctx, self.client.team, self.client.slot, item_name)
cost = self.ctx.get_hint_cost(self.client.slot)
if hints:
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
old_hints = set(hints) - new_hints
@@ -877,8 +923,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
if not not_found_hints: # everything's been found, no need to pay
can_pay = 1000
elif self.ctx.hint_cost:
can_pay = points_available // self.ctx.hint_cost
elif cost:
can_pay = points_available // cost
else:
can_pay = 1000
@@ -904,7 +950,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
else:
self.output(f"You can't afford the hint. "
f"You have {points_available} points and need at least "
f"{self.ctx.hint_cost}")
f"{self.ctx.get_hint_cost(self.client.slot)}")
notify_hints(self.ctx, self.client.team, hints)
self.ctx.save()
return True
@@ -919,43 +965,39 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_checked_checks(ctx: Context, client: Client) -> typing.List[int]:
return [location_id for
location_id, slot in ctx.locations if
slot == client.slot and
location_id in ctx.locations[client.slot] if
location_id in ctx.location_checks[client.team, client.slot]]
def get_missing_checks(ctx: Context, client: Client) -> typing.List[int]:
return [location_id for
location_id, slot in ctx.locations if
slot == client.slot and
location_id in ctx.locations[client.slot] if
location_id not in ctx.location_checks[client.team, client.slot]]
def get_client_points(ctx: Context, client: Client) -> int:
return (ctx.location_check_points * len(ctx.location_checks[client.team, client.slot]) -
ctx.hint_cost * ctx.hints_used[client.team, client.slot])
ctx.get_hint_cost(client.slot) * ctx.hints_used[client.team, client.slot])
async def process_client_cmd(ctx: Context, client: Client, args: dict):
try:
cmd:str = args["cmd"]
cmd: str = args["cmd"]
except:
logging.exception(f"Could not get command from {args}")
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
"text": f"Could not get command from {args} at `cmd`"}])
raise
if type(cmd) is not str:
await ctx.send_msgs(client, [{"cmd": "InvalidCmd", "text": f"Command should be str, got {type(cmd)}"}])
return
if args is not None and type(args) != dict:
await ctx.send_msgs(client, [{"cmd": "InvalidArguments",
'text': f'Expected Optional[dict], got {type(args)} for {cmd}'}])
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
"text": f"Command should be str, got {type(cmd)}"}])
return
if cmd == 'Connect':
if not args or 'password' not in args or type(args['password']) not in [str, type(None)] or \
'game' not in args:
await ctx.send_msgs(client, [{'cmd': 'InvalidArguments', 'text': 'Connect'}])
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", 'text': 'Connect'}])
return
errors = set()
@@ -967,11 +1009,16 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
errors.add('InvalidSlot')
else:
team, slot = ctx.connect_names[args['name']]
game = ctx.games[slot]
if args['game'] != game:
errors.add('InvalidSlot')
# this can only ever be 0 or 1 elements
clients = [c for c in ctx.endpoints if c.auth and c.slot == slot and c.team == team]
if clients:
# likely same player with a "ghosted" slot. We bust the ghost.
if "uuid" in args and ctx.client_ids[team, slot] == args["uuid"]:
await ctx.send_msgs(clients[0], [{"cmd": "Print", "text": "You are getting kicked "
"by yourself reconnecting."}])
await clients[0].socket.close() # we have to await the DC of the ghost, so not to create data pasta
client.name = ctx.player_names[(team, slot)]
client.team = team
@@ -982,17 +1029,15 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
client.name = ctx.player_names[(team, slot)]
client.team = team
client.slot = slot
minver = Utils.Version(*(ctx.minimum_client_versions.get((team, slot), (0,0,0))))
minver = ctx.minimum_client_versions[slot]
if minver > args['version']:
errors.add('IncompatibleVersion')
if ctx.compatibility == 1 and "AP" not in args['tags']:
errors.add('IncompatibleVersion')
#only exact version match allowed
elif ctx.compatibility == 0 and args['version'] != _version_tuple:
# only exact version match allowed
if ctx.compatibility == 0 and args['version'] != version_tuple:
errors.add('IncompatibleVersion')
if errors:
logging.info(f"A client connection was refused due to: {errors}")
logging.info(f"A client connection was refused due to: {errors}, the sent connect information was {args}.")
await ctx.send_msgs(client, [{"cmd": "ConnectionRefused", "errors": list(errors)}])
else:
ctx.client_ids[client.team, client.slot] = args["uuid"]
@@ -1004,25 +1049,37 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
"team": client.team, "slot": client.slot,
"players": ctx.get_players_package(),
"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, {})
}]
items = get_received_items(ctx, client.team, client.slot)
if items:
reply.append({"cmd": 'ReceivedItems', "index": 0, "items": tuplize_received_items(items)})
reply.append({"cmd": 'ReceivedItems', "index": 0, "items": items})
client.send_index = len(items)
await ctx.send_msgs(client, reply)
await on_client_joined(ctx, client)
elif cmd == "GetDataPackage":
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": network_data_package}])
exclusions = set(args.get("exclusions", []))
if exclusions:
games = {name: game_data for name, game_data in network_data_package["games"].items()
if name not in exclusions}
package = network_data_package.copy()
package["games"] = games
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": package}])
else:
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": network_data_package}])
elif client.auth:
if cmd == 'Sync':
items = get_received_items(ctx, client.team, client.slot)
if items:
client.send_index = len(items)
await ctx.send_msgs(client, [{"cmd": "ReceivedItems","index": 0,
"items": tuplize_received_items(items)}])
await ctx.send_msgs(client, [{"cmd": "ReceivedItems", "index": 0,
"items": items}])
elif cmd == 'LocationChecks':
register_location_checks(ctx, client.team, client.slot, args["locations"])
@@ -1030,20 +1087,12 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
elif cmd == 'LocationScouts':
locs = []
for location in args["locations"]:
if type(location) is not int or 0 >= location > len(Regions.location_table):
await ctx.send_msgs(client, [{"cmd": "InvalidArguments", "text": 'LocationScouts'}])
if type(location) is not int or location not in lookup_any_location_id_to_name:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts'}])
return
loc_name = list(Regions.location_table.keys())[location - 1]
target_item, target_player = ctx.locations[(Regions.location_table[loc_name][0], client.slot)]
target_item, target_player = ctx.locations[client.slot][location]
locs.append(NetworkItem(target_item, location, target_player))
replacements = {'SmallKey': 0xA2, 'BigKey': 0x9D, 'Compass': 0x8D, 'Map': 0x7D}
item_type = [i[1] for i in Items.item_table.values() if type(i[2]) is int and i[2] == target_item]
if item_type:
target_item = replacements.get(item_type[0], target_item)
locs.append([target_item, location, target_player])
# logging.info(f"{client.name} in team {client.team+1} scouted {', '.join([l[0] for l in locs])}")
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'StatusUpdate':
@@ -1051,11 +1100,12 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if cmd == 'Say':
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
await ctx.send_msgs(client, [{"cmd": "InvalidArguments", "text" : 'Say'}])
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'Say'}])
return
client.messageprocessor(args["text"])
def update_client_status(ctx: Context, client: Client, new_status: ClientStatus):
current = ctx.client_game_state[client.team, client.slot]
if current != ClientStatus.CLIENT_GOAL: # can't undo goal completion
@@ -1067,6 +1117,7 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
ctx.client_game_state[client.team, client.slot] = new_status
class ServerCommandProcessor(CommonCommandProcessor):
def __init__(self, ctx: Context):
self.ctx = ctx
@@ -1174,7 +1225,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
self.ctx.allow_forfeits[(team, slot)] = False
self.output(f"Player {player_name} has to follow the server restrictions on use of the !forfeit command.")
self.output(
f"Player {player_name} has to follow the server restrictions on use of the !forfeit command.")
return True
self.output(f"Could not find player {player_name} to forbid the !forfeit command for.")
@@ -1184,17 +1236,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
"""Sends an item to the specified player"""
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
team, slot = self.ctx.player_name_lookup[seeked_player]
item = " ".join(item_name)
item, usable, response = get_intended_text(item, all_items)
world = proxy_worlds[self.ctx.games[slot]]
item, usable, response = get_intended_text(item, world.item_names)
if usable:
for client in self.ctx.endpoints:
if client.name == seeked_player:
new_item = NetworkItem(lookup_any_item_name_to_id[item], -1, client.slot)
get_received_items(self.ctx, client.team, client.slot).append(new_item)
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' +
self.ctx.get_aliased_name(client.team, client.slot))
send_new_items(self.ctx)
return True
new_item = NetworkItem(world.item_name_to_id[item], -1, 0)
get_received_items(self.ctx, team, slot).append(new_item)
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' +
self.ctx.get_aliased_name(team, slot))
send_new_items(self.ctx)
return True
else:
self.output(response)
return False
@@ -1206,27 +1258,27 @@ class ServerCommandProcessor(CommonCommandProcessor):
"""Send out a hint for a player's item or location to their team"""
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
for (team, slot), name in self.ctx.player_names.items():
if name == seeked_player:
item = " ".join(item_or_location)
item, usable, response = get_intended_text(item)
if usable:
if item in Items.item_name_groups:
hints = []
for item in Items.item_name_groups[item]:
hints.extend(collect_hints(self.ctx, team, slot, item))
elif item in all_items: # item name
hints = collect_hints(self.ctx, team, slot, item)
else: # location name
hints = collect_hints_location(self.ctx, team, slot, item)
if hints:
notify_hints(self.ctx, team, hints)
else:
self.output("No hints found.")
return True
else:
self.output(response)
return False
team, slot = self.ctx.player_name_lookup[seeked_player]
item = " ".join(item_or_location)
world = proxy_worlds[self.ctx.games[slot]]
item, usable, response = get_intended_text(item, world.all_names)
if usable:
if item in world.item_name_groups:
hints = []
for item in world.item_name_groups[item]:
hints.extend(collect_hints(self.ctx, team, slot, item))
elif item in world.item_names: # item name
hints = collect_hints(self.ctx, team, slot, item)
else: # location name
hints = collect_hints_location(self.ctx, team, slot, item)
if hints:
notify_hints(self.ctx, team, hints)
else:
self.output("No hints found.")
return True
else:
self.output(response)
return False
else:
self.output(response)
@@ -1254,6 +1306,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
f"{', '.join(known)}")
return False
async def console(ctx: Context):
session = prompt_toolkit.PromptSession()
while ctx.running:
@@ -1269,11 +1322,11 @@ async def console(ctx: Context):
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
defaults = Utils.get_options()["server_options"]
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
parser.add_argument('--host', default=defaults["host"])
parser.add_argument('--port', default=defaults["port"], type=int)
parser.add_argument('--server_password', default=defaults["server_password"])
parser.add_argument('--password', default=defaults["password"])
parser.add_argument('--multidata', default=defaults["multidata"])
parser.add_argument('--savefile', default=defaults["savefile"])
parser.add_argument('--disable_save', default=defaults["disable_save"], action='store_true')
parser.add_argument('--loglevel', default=defaults["loglevel"],
@@ -1309,6 +1362,7 @@ def parse_args() -> argparse.Namespace:
#1 -> recommended for friendly racing, tries to block third party clients
#0 -> recommended for tournaments to force a level playing field, only allow an exact version match
""")
parser.add_argument('--log_network', default=defaults["log_network"], action="store_true")
args = parser.parse_args()
return args
@@ -1339,13 +1393,13 @@ async def auto_shutdown(ctx, to_cancel=None):
async def main(args: argparse.Namespace):
logging.basicConfig(force = True,
logging.basicConfig(force=True,
format='[%(asctime)s] %(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
args.hint_cost, not args.disable_item_cheat, args.forfeit_mode, args.remaining_mode,
args.auto_shutdown, args.compatibility)
ctx.log_network = args.log_network
data_filename = args.multidata
try:
@@ -1354,7 +1408,20 @@ async def main(args: argparse.Namespace):
import tkinter.filedialog
root = tkinter.Tk()
root.withdraw()
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago"),))
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago *.zip"),))
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)

View File

@@ -9,6 +9,7 @@ import websockets
from Utils import Version
class JSONMessagePart(typing.TypedDict, total=False):
text: str
# optional
@@ -18,7 +19,6 @@ class JSONMessagePart(typing.TypedDict, total=False):
found: bool
class ClientStatus(enum.IntEnum):
CLIENT_UNKNOWN = 0
CLIENT_CONNECTED = 5
@@ -61,10 +61,12 @@ _encode = JSONEncoder(
def encode(obj):
return _encode(_scan_for_TypedTuples(obj))
def get_any_version(data: dict) -> Version:
data = {key.lower(): value for key, value in data.items()} # .NET version classes have capitalized keys
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
whitelist = {"NetworkPlayer": NetworkPlayer,
"NetworkItem": NetworkItem,
}
@@ -73,6 +75,7 @@ custom_hooks = {
"Version": get_any_version
}
def _object_hook(o: typing.Any) -> typing.Any:
if isinstance(o, dict):
hook = custom_hooks.get(o.get("class", None), None)
@@ -82,7 +85,7 @@ def _object_hook(o: typing.Any) -> typing.Any:
if cls:
for key in tuple(o):
if key not in cls._fields:
del(o[key])
del (o[key])
return cls(**o)
return o
@@ -99,30 +102,39 @@ class Node:
def __init__(self):
self.endpoints = []
super(Node, self).__init__()
self.log_network = 0
def broadcast_all(self, msgs):
msgs = self.dumper(msgs)
for endpoint in self.endpoints:
asyncio.create_task(self.send_encoded_msgs(endpoint, msgs))
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]):
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed:
return
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
if not endpoint.socket or not endpoint.socket.open:
return False
msg = self.dumper(msgs)
try:
await endpoint.socket.send(msg)
except websockets.ConnectionClosed:
logging.exception(f"Exception during send_msgs, could not send {msg}")
await self.disconnect(endpoint)
else:
if self.log_network:
logging.info(f"Outgoing message: {msg}")
return True
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str):
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed:
return
async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool:
if not endpoint.socket or not endpoint.socket.open:
return False
try:
await endpoint.socket.send(msg)
except websockets.ConnectionClosed:
logging.exception("Exception during send_msgs")
logging.exception("Exception during send_encoded_msgs")
await self.disconnect(endpoint)
else:
if self.log_network:
logging.info(f"Outgoing message: {msg}")
return True
async def disconnect(self, endpoint):
if endpoint in self.endpoints:
@@ -144,11 +156,16 @@ class HandlerMeta(type):
handlers = attrs["handlers"] = {}
trigger: str = "_handle_"
for base in bases:
handlers.update(base.commands)
handlers.update(base.handlers)
handlers.update({handler_name[len(trigger):]: method for handler_name, method in attrs.items() if
handler_name.startswith(trigger)})
orig_init = attrs.get('__init__', None)
if not orig_init:
for base in bases:
orig_init = getattr(base, '__init__', None)
if orig_init:
break
def __init__(self, *args, **kwargs):
# turn functions into bound methods
@@ -160,6 +177,7 @@ class HandlerMeta(type):
attrs['__init__'] = __init__
return super(HandlerMeta, mcs).__new__(mcs, name, bases, attrs)
class JSONTypes(str, enum.Enum):
color = "color"
text = "text"
@@ -171,6 +189,7 @@ class JSONTypes(str, enum.Enum):
location_id = "location_id"
entrance_name = "entrance_name"
class JSONtoTextParser(metaclass=HandlerMeta):
def __init__(self, ctx):
self.ctx = ctx
@@ -229,6 +248,11 @@ class JSONtoTextParser(metaclass=HandlerMeta):
return self._handle_color(node)
class RawJSONtoTextParser(JSONtoTextParser):
def _handle_color(self, node: JSONMessagePart):
return self._handle_text(node)
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
'blue_bg': 44, 'purple_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
@@ -274,7 +298,7 @@ class Hint(typing.NamedTuple):
add_json_text(parts, " is at ")
add_json_text(parts, self.location, type="location_id")
add_json_text(parts, " in ")
add_json_text(parts, self.finding_player, type ="player_id")
add_json_text(parts, self.finding_player, type="player_id")
if self.entrance:
add_json_text(parts, "'s World at ")
add_json_text(parts, self.entrance, type="entrance_name")
@@ -285,4 +309,10 @@ class Hint(typing.NamedTuple):
else:
add_json_text(parts, ".")
return {"cmd": "PrintJSON", "data": parts, "type": "hint"}
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
"receiving": self.receiving_player,
"item": NetworkItem(self.item, self.location, self.finding_player)}
@property
def local(self):
return self.receiving_player == self.finding_player

View File

@@ -1,14 +1,16 @@
from __future__ import annotations
import typing
import random
class AssembleOptions(type):
def __new__(cls, name, bases, attrs):
def __new__(mcs, name, bases, attrs):
options = attrs["options"] = {}
name_lookup = attrs["name_lookup"] = {}
for base in bases:
options.update(base.options)
name_lookup.update(name_lookup)
if hasattr(base, "options"):
options.update(base.options)
name_lookup.update(name_lookup)
new_options = {name[7:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("option_")}
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
@@ -17,12 +19,21 @@ class AssembleOptions(type):
# apply aliases, without name_lookup
options.update({name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")})
return super(AssembleOptions, cls).__new__(cls, 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):
value: int
name_lookup: typing.Dict[int, str]
default = 0
def __repr__(self):
return f"{self.__class__.__name__}({self.get_option_name()})"
@@ -47,6 +58,7 @@ class Option(metaclass=AssembleOptions):
class Toggle(Option):
option_false = 0
option_true = 1
default = 0
def __init__(self, value: int):
self.value = value
@@ -86,6 +98,9 @@ class Toggle(Option):
def get_option_name(self):
return bool(self.value)
class DefaultOnToggle(Toggle):
default = 1
class Choice(Option):
def __init__(self, value: int):
self.value: int = value
@@ -100,142 +115,102 @@ class Choice(Option):
f'known options are {", ".join(f"{option}" for option in cls.name_lookup.values())}')
@classmethod
def from_any(cls, data: typing.Any):
return cls.from_text(data)
def from_any(cls, data: typing.Any) -> Choice:
if type(data) == int and data in cls.options.values():
return cls(data)
return cls.from_text(str(data))
class Logic(Choice):
option_no_glitches = 0
option_minor_glitches = 1
option_overworld_glitches = 2
option_no_logic = 4
alias_owg = 2
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 Objective(Choice):
option_crystals = 0
# option_pendants = 1
option_triforce_pieces = 2
option_pedestal = 3
option_bingo = 4
class OptionNameSet(Option):
default = frozenset()
def __init__(self, value: typing.Set[str]):
self.value: typing.Set[str] = value
@classmethod
def from_text(cls, text: str) -> OptionNameSet:
return cls({option.strip() for option in text.split(",")})
@classmethod
def from_any(cls, data: typing.Any) -> OptionNameSet:
if type(data) == set:
return cls(data)
return cls.from_text(str(data))
class OptionDict(Option):
default = {}
def __init__(self, value: typing.Dict[str, typing.Any]):
self.value: typing.Dict[str, typing.Any] = value
@classmethod
def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict:
if type(data) == dict:
return cls(data)
else:
raise NotImplementedError(f"Cannot Convert from non-dictionary, got {type(data)}")
def get_option_name(self):
return str(self.value)
local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
class Goal(Choice):
option_kill_ganon = 0
option_kill_ganon_and_gt_agahnim = 1
option_hand_in = 2
class Accessibility(Choice):
option_locations = 0
option_items = 1
option_beatable = 2
class Crystals(Choice):
# can't use IntEnum since there's also random
option_0 = 0
option_1 = 1
option_2 = 2
option_3 = 3
option_4 = 4
option_5 = 5
option_6 = 6
option_7 = 7
option_random = -1
class WorldState(Choice):
option_standard = 1
option_open = 0
option_inverted = 2
class Bosses(Choice):
option_vanilla = 0
option_simple = 1
option_full = 2
option_chaos = 3
option_singularity = 4
class Enemies(Choice):
option_vanilla = 0
option_shuffled = 1
option_chaos = 2
mapshuffle = Toggle
compassshuffle = Toggle
keyshuffle = Toggle
bigkeyshuffle = Toggle
hints = Toggle
RandomizeDreamers = Toggle
RandomizeSkills = Toggle
RandomizeCharms = Toggle
RandomizeKeys = Toggle
RandomizeGeoChests = Toggle
RandomizeMaskShards = Toggle
RandomizeVesselFragments = Toggle
RandomizeCharmNotches = Toggle
RandomizePaleOre = Toggle
RandomizeRancidEggs = Toggle
RandomizeRelics = Toggle
RandomizeMaps = Toggle
RandomizeStags = Toggle
RandomizeGrubs = Toggle
RandomizeWhisperingRoots = Toggle
RandomizeRocks = Toggle
RandomizeSoulTotems = Toggle
RandomizePalaceTotems = Toggle
RandomizeLoreTablets = Toggle
RandomizeLifebloodCocoons = Toggle
RandomizeFlames = Toggle
hollow_knight_randomize_options: typing.Dict[str, Option] = {
"RandomizeDreamers": RandomizeDreamers,
"RandomizeSkills": RandomizeSkills,
"RandomizeCharms": RandomizeCharms,
"RandomizeKeys": RandomizeKeys,
"RandomizeGeoChests": RandomizeGeoChests,
"RandomizeMaskShards": RandomizeMaskShards,
"RandomizeVesselFragments": RandomizeVesselFragments,
"RandomizeCharmNotches": RandomizeCharmNotches,
"RandomizePaleOre": RandomizePaleOre,
"RandomizeRancidEggs": RandomizeRancidEggs,
"RandomizeRelics": RandomizeRelics,
"RandomizeMaps": RandomizeMaps,
"RandomizeStags": RandomizeStags,
"RandomizeGrubs": RandomizeGrubs,
"RandomizeWhisperingRoots": RandomizeWhisperingRoots,
"RandomizeRocks": RandomizeRocks,
"RandomizeSoulTotems": RandomizeSoulTotems,
"RandomizePalaceTotems": RandomizePalaceTotems,
"RandomizeLoreTablets": RandomizeLoreTablets,
"RandomizeLifebloodCocoons": RandomizeLifebloodCocoons,
"RandomizeFlames": RandomizeFlames
}
hollow_knight_skip_options: typing.Dict[str, type(Option)] = {
"MILDSKIPS": Toggle,
"SPICYSKIPS": Toggle,
"FIREBALLSKIPS": Toggle,
"ACIDSKIPS": Toggle,
"SPIKETUNNELS": Toggle,
"DARKROOMS": Toggle,
"CURSED": Toggle,
"SHADESKIPS": Toggle,
}
hollow_knight_options: typing.Dict[str, Option] = {**hollow_knight_randomize_options, **hollow_knight_skip_options}
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.logic = Logic.from_text("no_logic")
test.mapshuffle = mapshuffle.from_text("ON")

View File

@@ -12,7 +12,7 @@ from typing import Tuple, Optional
import Utils
from worlds.alttp.Rom import JAP10HASH
current_patch_version = 1
current_patch_version = 2
def get_base_rom_path(file_name: str = "") -> str:
@@ -43,9 +43,9 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
def generate_yaml(patch: bytes, metadata: Optional[dict] = None) -> bytes:
patch = yaml.dump({"meta": metadata,
"patch": patch,
"game": "alttp",
"compatible_version": 1,
"game": "A Link to the Past",
# minimum version of patch system expected for patching to be successful
"compatible_version": 1,
"version": current_patch_version,
"base_checksum": JAP10HASH})
return patch.encode(encoding="utf-8-sig")
@@ -58,10 +58,13 @@ def generate_patch(rom: bytes, metadata: Optional[dict] = None) -> bytes:
return generate_yaml(patch, metadata)
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None) -> str:
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
player: int = 0, player_name: str = "") -> str:
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
"player_id": player,
"player_name": player_name}
bytes = generate_patch(load_bytes(rom_file_to_patch),
{
"server": server}) # allow immediate connection to server in multiworld. Empty string otherwise
meta)
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + ".apbp"
write_lzma(bytes, target)
return target

View File

@@ -1,12 +1,14 @@
# [Archipelago](https://archipelago.gg) ![Discord Shield](https://discordapp.com/api/guilds/731205301247803413/widget.png?style=shield) | [Install](https://github.com/Berserker66/MultiWorld-Utilities/releases)
# [Archipelago](https://archipelago.gg) ![Discord Shield](https://discordapp.com/api/guilds/731205301247803413/widget.png?style=shield) | [Install](https://github.com/ArchipelagoMW/Archipelago/releases)
Archipelago provides a generic framework for developing multiworld capability for game randomizers. In all cases, presently, Archipelago is also the randomizer itself.
Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past
* Factorio
* Minecraft
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial).
Downloads can be found at [Releases](https://github.com/Berserker66/MultiWorld-Utilities/releases), including compiled
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
windows binaries.
## History
@@ -25,7 +27,7 @@ We recognize that there is a strong community of incredibly smart people that ha
Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEntranceRandomizer (this project has a long legacy of its own, please check it out linked above) on January 12, 2020. The repository was then named to _MultiWorld-Utilities_ to better encompass its intended function. As Archipelago matured, then known as "Berserker's MultiWorld" by some, we found it necessary to transform our repository into a root level repository (as opposed to a 'forked repo') and change the name (which came later) to better reflect our project.
## Running Archipelago
For most people all you need to do is head over to the [releases](https://github.com/Berserker66/MultiWorld-Utilities/releases) page then download and run the appropriate installer. The installers function on Windows only.
For most people all you need to do is head over to the [releases](https://github.com/ArchipelagoMW/Archipelago/releases) page then download and run the appropriate installer. The installers function on Windows only.
If you are running Archipelago from a non-Windows system then the likely scenario is that you are comfortable running source code directly. Please see our wiki page on [running Archipelago from source](https://github.com/Berserker66/MultiWorld-Utilities/wiki/Running-from-source).

184
Utils.py
View File

@@ -12,8 +12,9 @@ class Version(typing.NamedTuple):
minor: int
build: int
__version__ = "0.0.2"
_version_tuple = tuplize_version(__version__)
__version__ = "0.1.5"
version_tuple = tuplize_version(__version__)
import builtins
import os
@@ -22,6 +23,7 @@ import sys
import pickle
import functools
import io
import collections
from yaml import load, dump, safe_load
@@ -52,7 +54,6 @@ def snes_to_pc(value):
def parse_player_names(names, players, teams):
names = tuple(n for n in (n.strip() for n in names.split(",")) if n)
if len(names) != len(set(names)):
import collections
name_counter = collections.Counter(names)
raise ValueError(f"Duplicate Player names is not supported, "
f'found multiple "{name_counter.most_common(1)[0][0]}".')
@@ -68,7 +69,22 @@ def parse_player_names(names, players, teams):
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)
@@ -76,7 +92,7 @@ def local_path(*path):
if local_path.cached_path:
return os.path.join(local_path.cached_path, *path)
elif is_bundled():
elif is_frozen():
if hasattr(sys, "_MEIPASS"):
# we are running in a PyInstaller bundle
local_path.cached_path = sys._MEIPASS # pylint: disable=protected-access,no-member
@@ -84,9 +100,13 @@ def local_path(*path):
# cx_Freeze
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
else:
# we are running in a normal Python environment
import __main__
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
if hasattr(__main__, "__file__"):
# we are running in a normal Python environment
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
else:
# pray
local_path.cached_path = os.path.abspath(".")
return os.path.join(local_path.cached_path, *path)
@@ -114,20 +134,10 @@ def open_file(filename):
subprocess.call([open_command, filename])
def close_console():
if sys.platform == 'win32':
# windows
import ctypes.wintypes
try:
ctypes.windll.kernel32.FreeConsole()
except Exception:
pass
parse_yaml = safe_load
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
@cache_argsless
def get_public_ipv4() -> str:
import socket
import urllib.request
@@ -143,7 +153,7 @@ def get_public_ipv4() -> str:
pass # we could be offline, in a local game, so no point in erroring out
return ip
@cache_argsless
def get_public_ipv6() -> str:
import socket
import urllib.request
@@ -156,70 +166,55 @@ def get_public_ipv6() -> str:
pass # we could be offline, in a local game, or ipv6 may not be available
return ip
@cache_argsless
def get_default_options() -> dict:
if not hasattr(get_default_options, "options"):
# Refer to host.yaml for comments as to what all these options mean.
options = {
"general_options": {
"output_path": "output",
},
"factorio_options": {
"executable": "factorio\\bin\\x64\\factorio",
"script-output": "factorio\\script-output",
},
"lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"qusb2snes": "QUsb2Snes\\QUsb2Snes.exe",
"rom_start": True,
# Refer to host.yaml for comments as to what all these options mean.
options = {
"general_options": {
"output_path": "output",
},
"factorio_options": {
"executable": "factorio\\bin\\x64\\factorio",
},
"lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"sni": "SNI",
"rom_start": True,
},
"server_options": {
"host": None,
"port": 38281,
"password": None,
"multidata": None,
"savefile": None,
"disable_save": False,
"loglevel": "info",
"server_password": None,
"disable_item_cheat": False,
"location_check_points": 1,
"hint_cost": 1000,
"forfeit_mode": "goal",
"remaining_mode": "goal",
"auto_shutdown": 0,
"compatibility": 2,
},
"multi_mystery_options": {
"teams": 1,
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
"player_files_path": "Players",
"players": 0,
"weights_file_path": "weights.yaml",
"meta_file_path": "meta.yaml",
"pre_roll": False,
"player_name": "",
"create_spoiler": 1,
"zip_roms": 0,
"zip_diffs": 2,
"zip_spoiler": 0,
"zip_multidata": 1,
"zip_format": 1,
"glitch_triforce_room": 1,
"race": 0,
"cpu_threads": 0,
"max_attempts": 0,
"take_first_working": False,
"keep_all_seeds": False,
"log_output_path": "Output Logs",
"log_level": None,
"plando_options": "bosses",
}
},
"server_options": {
"host": None,
"port": 38281,
"password": None,
"multidata": None,
"savefile": None,
"disable_save": False,
"loglevel": "info",
"server_password": None,
"disable_item_cheat": False,
"location_check_points": 1,
"hint_cost": 10,
"forfeit_mode": "goal",
"remaining_mode": "goal",
"auto_shutdown": 0,
"compatibility": 2,
"log_network": 0
},
"generator": {
"teams": 1,
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
"player_files_path": "Players",
"players": 0,
"weights_file_path": "weights.yaml",
"meta_file_path": "meta.yaml",
"spoiler": 2,
"glitch_triforce_room": 1,
"race": 0,
"plando_options": "bosses",
}
}
get_default_options.options = options
return get_default_options.options
return options
blacklisted_options = {"multi_mystery_options.cpu_threads",
@@ -249,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)
return dest
@cache_argsless
def get_options() -> dict:
if not hasattr(get_options, "options"):
locations = ("options.yaml", "host.yaml",
@@ -267,14 +262,14 @@ def get_options() -> dict:
return get_options.options
def get_item_name_from_id(code):
def get_item_name_from_id(code: int) -> str:
from worlds import lookup_any_item_id_to_name
return lookup_any_item_id_to_name.get(code, f'Unknown item (ID:{code})')
def get_location_name_from_address(address):
def get_location_name_from_id(code: int) -> str:
from worlds import lookup_any_location_id_to_name
return lookup_any_location_id_to_name.get(address, f'Unknown location (ID:{address})')
return lookup_any_location_id_to_name.get(code, f'Unknown location (ID:{code})')
def persistent_store(category: str, key: typing.Any, value: typing.Any):
@@ -341,12 +336,12 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
f"Enter yes, no or never: ")
if adjust_wanted and adjust_wanted.startswith("y"):
if hasattr(adjuster_settings, "sprite_pool"):
from Adjuster import AdjusterWorld
from LttPAdjuster import AdjusterWorld
adjuster_settings.world = AdjusterWorld(getattr(adjuster_settings, "sprite_pool"))
adjusted = True
import Adjuster
_, romfile = Adjuster.adjust(adjuster_settings)
import LttPAdjuster
_, romfile = LttPAdjuster.adjust(adjuster_settings)
if hasattr(adjuster_settings, "world"):
delattr(adjuster_settings, "world")
@@ -363,7 +358,7 @@ def get_adjuster_settings(romfile: str) -> typing.Tuple[str, bool]:
return romfile, adjusted
return romfile, False
@cache_argsless
def get_unique_identifier():
uuid = persistent_load().get("client", {}).get("uuid", None)
if uuid:
@@ -385,9 +380,14 @@ class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus"}:
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint"}:
import NetUtils
return getattr(NetUtils, name)
if module == "Options":
import Options
obj = getattr(Options, name)
if issubclass(obj, Options.Option):
return obj
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))
@@ -395,4 +395,10 @@ class RestrictedUnpickler(pickle.Unpickler):
def restricted_loads(s):
"""Helper function analogous to pickle.loads()."""
return RestrictedUnpickler(io.BytesIO(s)).load()
return RestrictedUnpickler(io.BytesIO(s)).load()
class KeyedDefaultDict(collections.defaultdict):
def __missing__(self, key):
self[key] = value = self.default_factory(key)
return value

View File

@@ -2,22 +2,30 @@ import os
import multiprocessing
import logging
import ModuleUpdate
ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt"))
ModuleUpdate.update()
# in case app gets imported by something like gunicorn
import Utils
Utils.local_path.cached_path = os.path.dirname(__file__)
from WebHostLib import app as raw_app
from waitress import serve
from WebHostLib.models import db
from WebHostLib.autolauncher import autohost
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files
configpath = "config.yaml"
configpath = os.path.abspath("config.yaml")
def get_app():
app = raw_app
if os.path.exists(configpath):
import yaml
with open(configpath) as c:
app.config.update(yaml.safe_load(c))
app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}")
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)
@@ -28,7 +36,9 @@ if __name__ == "__main__":
multiprocessing.freeze_support()
multiprocessing.set_start_method('spawn')
logging.basicConfig(format='[%(asctime)s] %(message)s', level=logging.INFO)
update_sprites_lttp()
app = get_app()
create_options_files()
if app.config["SELFLAUNCH"]:
autohost(app.config)
if app.config["SELFHOST"]: # using WSGI, you just want to run get_app()

View File

@@ -3,10 +3,10 @@ import uuid
import base64
import socket
import jinja2.exceptions
from pony.flask import Pony
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from flask_caching import Cache
from flaskext.autoversion import Autoversion
from flask_compress import Compress
from .models import *
@@ -46,10 +46,8 @@ app.config["PONY"] = {
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "simple"
app.config["JSON_AS_ASCII"] = False
app.config["PATCH_TARGET"] = "archipelago.gg"
app.autoversion = True
av = Autoversion(app)
cache = Cache(app)
Compress(app)
@@ -77,6 +75,58 @@ def register_session():
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):
return render_template('404.html'), 404
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>')
def tutorial(game, file, lang):
return render_template("tutorial.html", game=game, file=file, lang=lang)
@@ -87,13 +137,8 @@ def tutorial_landing():
return render_template("tutorialLanding.html")
@app.route('/player-settings')
def player_settings_simple():
return render_template("playerSettings.html")
@app.route('/weighted-settings')
def player_settings():
def weighted_settings():
return render_template("weightedSettings.html")
@@ -131,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")
@app.route('/hosted/<suuid:room>', methods=['GET', 'POST'])
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def hostRoom(room: UUID):
room = Room.get(id=room)
if room is None:
@@ -147,6 +192,9 @@ def hostRoom(room: UUID):
return render_template("hostRoom.html", room=room)
@app.route('/hosted/<suuid:room>', methods=['GET', 'POST'])
def hostRoomRedirect(room: UUID):
return redirect(url_for("hostRoom", room=room))
@app.route('/favicon.ico')
def favicon():
@@ -156,4 +204,5 @@ def favicon():
from WebHostLib.customserver import run_server_process
from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it
app.register_blueprint(api.api_endpoints)

View File

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

View File

@@ -1,4 +1,6 @@
import json
import pickle
from uuid import UUID
from . import api_endpoints
@@ -46,7 +48,7 @@ def generate_api():
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=pickle.dumps({"race": race}), state=STATE_QUEUED,
meta=json.dumps({"race": race}), state=STATE_QUEUED,
owner=session["_id"])
commit()
return {"text": f"Generation of seed {gen.id} started successfully.",
@@ -58,6 +60,7 @@ def generate_api():
return {"text": "Uncaught Exception:" + str(e)}, 500
@api_endpoints.route('/status/<suuid:seed>')
def wait_seed_api(seed: UUID):
seed_id = seed

View File

@@ -1,21 +1,28 @@
from __future__ import annotations
import logging
import json
import multiprocessing
from datetime import timedelta, datetime
import concurrent.futures
import sys
import typing
import time
import os
from pony.orm import db_session, select, commit
from Utils import restricted_loads
class CommonLocker():
"""Uses a file lock to signal that something is already running"""
def __init__(self, lockname: str):
lock_folder = "file_locks"
def __init__(self, lockname: str, folder=None):
if folder:
self.lock_folder = folder
os.makedirs(self.lock_folder, exist_ok=True)
self.lockname = lockname
self.lockfile = f"./{self.lockname}.lck"
self.lockfile = os.path.join(self.lock_folder, f"{self.lockname}.lck")
class AlreadyRunningException(Exception):
@@ -23,8 +30,6 @@ class AlreadyRunningException(Exception):
if sys.platform == 'win32':
import os
class Locker(CommonLocker):
def __enter__(self):
try:
@@ -43,6 +48,7 @@ if sys.platform == 'win32':
else: # unix
import fcntl
class Locker(CommonLocker):
def __enter__(self):
try:
@@ -78,14 +84,21 @@ def handle_generation_failure(result: BaseException):
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
options = generation.options
logging.info(f"Generating {generation.id} for {len(options)} players")
meta = generation.meta
pool.apply_async(gen_game, (options,),
{"race": meta["race"], "sid": generation.id, "owner": generation.owner},
handle_generation_success, handle_generation_failure)
generation.state = STATE_STARTED
try:
meta = json.loads(generation.meta)
options = restricted_loads(generation.options)
logging.info(f"Generating {generation.id} for {len(options)} players")
pool.apply_async(gen_game, (options,),
{"race": meta["race"],
"sid": generation.id,
"owner": generation.owner},
handle_generation_success, handle_generation_failure)
except:
generation.state = STATE_ERROR
commit()
raise
else:
generation.state = STATE_STARTED
def init_db(pony_config: dict):
@@ -138,6 +151,7 @@ multiworlds = {}
guardians = concurrent.futures.ThreadPoolExecutor(2, thread_name_prefix="Guardian")
class MultiworldInstance():
def __init__(self, room: Room, config: dict):
self.room_id = room.id
@@ -162,7 +176,7 @@ class MultiworldInstance():
self.process = None
def _collect(self):
self.process.join() # wait for process to finish
self.process.join() # wait for process to finish
self.process = None
self.guardian = None

View File

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

View File

@@ -9,13 +9,13 @@ import socket
import threading
import time
import random
import zlib
import pickle
from .models import *
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
from Utils import get_public_ipv4, get_public_ipv6, parse_yaml
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads
class CustomClientMessageProcessor(ClientMessageProcessor):
@@ -27,7 +27,7 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
self.ctx.save()
self.output(f"Registered Twitch Stream https://www.twitch.tv/{user}")
return True
elif platform.lower().startswith("y"): # youtube
elif platform.lower().startswith("y"): # youtube
self.ctx.video[self.client.team, self.client.slot] = "Youtube", user
self.ctx.save()
self.output(f"Registered Youtube Stream for {user}")
@@ -81,16 +81,16 @@ class WebHostContext(Context):
def init_save(self, enabled: bool = True):
self.saving = enabled
if self.saving:
existing_savegame = Room.get(id=self.room_id).multisave
if existing_savegame:
self.set_save(existing_savegame)
savegame_data = Room.get(id=self.room_id).multisave
if savegame_data:
self.set_save(restricted_loads(Room.get(id=self.room_id).multisave))
self._start_async_saving()
threading.Thread(target=self.listen_to_db_commands, daemon=True).start()
@db_session
def _save(self, exit_save:bool = False) -> bool:
room = Room.get(id=self.room_id)
room.multisave = self.get_save()
room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
room.last_activity = datetime.utcnow()
@@ -104,6 +104,7 @@ class WebHostContext(Context):
def get_random_port():
return random.randint(49152, 65535)
def run_server_process(room_id, ponyconfig: dict):
# establish DB connection for multidata and multisave
db.bind(**ponyconfig)
@@ -144,7 +145,9 @@ def run_server_process(room_id, ponyconfig: dict):
await ctx.shutdown_task
logging.info("Shutting down")
asyncio.run(main())
from .autolauncher import Locker
with Locker(room_id):
asyncio.run(main())
from WebHostLib import LOGS_FOLDER

View File

@@ -2,12 +2,12 @@ from flask import send_file, Response
from pony.orm import select
from Patch import update_patch_data
from WebHostLib import app, Patch, Room, Seed
from WebHostLib import app, Slot, Room, Seed
import zipfile
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
def download_patch(room_id, patch_id):
patch = Patch.get(id=patch_id)
patch = Slot.get(id=patch_id)
if not patch:
return "Patch not found"
else:
@@ -16,7 +16,7 @@ def download_patch(room_id, patch_id):
room = Room.get(id=room_id)
last_port = room.last_port
patch_data = update_patch_data(patch.data, server=f"{app.config['HOSTNAME']}:{last_port}")
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
patch_data = io.BytesIO(patch_data)
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}.apbp"
@@ -30,8 +30,9 @@ def download_spoiler(seed_id):
@app.route("/dl_raw_patch/<suuid:seed_id>/<int:player_id>")
def download_raw_patch(seed_id, player_id: int):
patch = select(patch for patch in Patch if
patch.player_id == player_id and patch.seed.id == seed_id).first()
seed = Seed.get(id=seed_id)
patch = select(patch for patch in seed.slots if
patch.player_id == player_id).first()
if not patch:
return "Patch not found"
@@ -43,3 +44,25 @@ def download_raw_patch(seed_id, player_id: int):
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](seed_id)}.apbp"
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
@app.route("/slot_file/<suuid:seed_id>/<int:player_id>")
def download_slot_file(seed_id, player_id: int):
seed = Seed.get(id=seed_id)
slot_data: Slot = select(patch for patch in seed.slots if
patch.player_id == player_id).first()
if not slot_data:
return "Slot Data not found"
else:
import io
if slot_data.game == "Minecraft":
fname = f"AP_{app.jinja_env.filters['suuid'](seed_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist():
if name.endswith("info.json"):
fname = name.rsplit("/", 1)[0]+".zip"
else:
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)

View File

@@ -1,6 +1,7 @@
import os
import tempfile
import random
import json
from collections import Counter
from flask import request, flash, redirect, url_for, session, render_template
@@ -8,7 +9,7 @@ from flask import request, flash, redirect, url_for, session, render_template
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from Main import get_seed, seeddigits
from Mystery import handle_name
from Generate import handle_name
import pickle
from .models import *
@@ -39,7 +40,7 @@ def generate(race=False):
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=pickle.dumps({"race": race}), state=STATE_QUEUED,
meta=json.dumps({"race": race}), state=STATE_QUEUED,
owner=session["_id"])
commit()
@@ -79,8 +80,6 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.progression_balancing = {}
erargs.create_diff = True
name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):
@@ -94,10 +93,6 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
erargs.names = ",".join(erargs.name[i] for i in range(1, playercount + 1))
del (erargs.name)
erargs.skip_progression_balancing = {player: not balanced for player, balanced in
erargs.progression_balancing.items()}
del (erargs.progression_balancing)
ERmain(erargs, seed)
return upload_to_db(target.name, owner, sid, race)
@@ -107,7 +102,11 @@ def gen_game(gen_options, race=False, owner=None, sid=None):
gen = Generation.get(id=sid)
if gen is not None:
gen.state = STATE_ERROR
gen.meta = (e.__class__.__name__ + ": "+ str(e)).encode()
meta = json.loads(gen.meta)
meta["error"] = (e.__class__.__name__ + ": "+ str(e))
gen.meta = json.dumps(meta)
commit()
raise
@@ -122,12 +121,12 @@ def wait_seed(seed: UUID):
if not generation:
return "Generation not found."
elif generation.state == STATE_ERROR:
return render_template("seedError.html", seed_error=generation.meta.decode())
return render_template("seedError.html", seed_error=generation.meta)
return render_template("waitSeed.html", seed_id=seed_id)
def upload_to_db(folder, owner, sid, race:bool):
patches = set()
slots = set()
spoiler = ""
multidata = None
@@ -137,8 +136,8 @@ def upload_to_db(folder, owner, sid, race:bool):
player_text = file.split("_P", 1)[1]
player_name = player_text.split("_", 1)[1].split(".", 1)[0]
player_id = int(player_text.split(".", 1)[0].split("_", 1)[0])
patches.add(Patch(data=open(file, "rb").read(),
player_id=player_id, player_name = player_name))
slots.add(Slot(data=open(file, "rb").read(),
player_id=player_id, player_name = player_name, game = "A Link to the Past"))
elif file.endswith(".txt"):
spoiler = open(file, "rt", encoding="utf-8-sig").read()
elif file.endswith(".archipelago"):
@@ -146,12 +145,12 @@ def upload_to_db(folder, owner, sid, race:bool):
if multidata:
with db_session:
if sid:
seed = Seed(multidata=multidata, spoiler=spoiler, patches=patches, owner=owner,
id=sid, meta={"tags": ["generated"]})
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner,
id=sid, meta=json.dumps({"race": race, "tags": ["generated"]}))
else:
seed = Seed(multidata=multidata, spoiler=spoiler, patches=patches, owner=owner,
meta={"tags": ["generated"]})
for patch in patches:
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner,
meta=json.dumps({"race": race, "tags": ["generated"]}))
for patch in slots:
patch.seed = seed
if sid:
gen = Generation.get(id=sid)

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

View File

@@ -9,12 +9,13 @@ STATE_STARTED = 1
STATE_ERROR = -1
class Patch(db.Entity):
class Slot(db.Entity):
id = PrimaryKey(int, auto=True)
player_id = Required(int)
player_name = Required(str, 16)
data = Required(bytes, lazy=True)
data = Optional(bytes, lazy=True)
seed = Optional('Seed')
game = Required(str)
class Room(db.Entity):
@@ -37,9 +38,9 @@ class Seed(db.Entity):
multidata = Required(bytes, lazy=True)
owner = Required(UUID, index=True)
creation_time = Required(datetime, default=lambda: datetime.utcnow())
patches = Set(Patch)
slots = Set(Slot)
spoiler = Optional(LongStr, lazy=True)
meta = Required(Json, lazy=True, default=lambda: {}) # additional meta information/tags
meta = Required(str, default=lambda: "{\"race\": false}") # additional meta information/tags
class Command(db.Entity):
@@ -51,6 +52,6 @@ class Command(db.Entity):
class Generation(db.Entity):
id = PrimaryKey(UUID, default=uuid4)
owner = Required(UUID)
options = Required(Json, lazy=True)
meta = Required(Json, lazy=True)
options = Required(buffer, lazy=True)
meta = Required(str, default=lambda: "{\"race\": false}")
state = Required(int, default=0, index=True)

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

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

View File

@@ -1,5 +1,13 @@
let gameName = null;
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
createDefaultSettings(results[0]);
buildUI(results[0]);
@@ -11,24 +19,13 @@ window.addEventListener('load', () => {
document.getElementById('generate-game').addEventListener('click', () => generateGame());
// Name input field
const playerSettings = JSON.parse(localStorage.getItem('playerSettings'));
const playerSettings = JSON.parse(localStorage.getItem(gameName));
const nameInput = document.getElementById('player-name');
nameInput.addEventListener('keyup', (event) => updateSetting(event));
nameInput.addEventListener('keyup', (event) => updateBaseSetting(event));
nameInput.value = playerSettings.name;
// Sprite options
const spriteData = JSON.parse(results[1]);
const spriteSelect = document.getElementById('sprite');
spriteData.sprites.forEach((sprite) => {
if (sprite.name.trim().length === 0) { return; }
const option = document.createElement('option');
option.setAttribute('value', sprite.name.trim());
if (playerSettings.rom.sprite === sprite.name.trim()) { option.selected = true; }
option.innerText = sprite.name;
spriteSelect.appendChild(option);
});
}).catch((error) => {
console.error(error);
const 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)); }
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();
});
const createDefaultSettings = (settingData) => {
if (!localStorage.getItem('playerSettings')) {
const newSettings = {};
for (let roSetting of Object.keys(settingData.readOnly)){
newSettings[roSetting] = settingData.readOnly[roSetting];
}
for (let generalOption of Object.keys(settingData.generalOptions)){
newSettings[generalOption] = settingData.generalOptions[generalOption];
if (!localStorage.getItem(gameName)) {
const newSettings = {
[gameName]: {},
};
for (let baseOption of Object.keys(settingData.baseOptions)){
newSettings[baseOption] = settingData.baseOptions[baseOption];
}
for (let gameOption of Object.keys(settingData.gameOptions)){
newSettings[gameOption] = settingData.gameOptions[gameOption].defaultValue;
newSettings[gameName][gameOption] = settingData.gameOptions[gameOption].defaultValue;
}
newSettings.rom = {};
for (let romOption of Object.keys(settingData.romOptions)){
newSettings.rom[romOption] = settingData.romOptions[romOption].defaultValue;
}
localStorage.setItem('playerSettings', JSON.stringify(newSettings));
localStorage.setItem(gameName, JSON.stringify(newSettings));
}
};
@@ -77,20 +69,10 @@ const buildUI = (settingData) => {
});
document.getElementById('game-options-left').appendChild(buildOptionsTable(leftGameOpts));
document.getElementById('game-options-right').appendChild(buildOptionsTable(rightGameOpts));
// ROM Options
const leftRomOpts = {};
const rightRomOpts = {};
Object.keys(settingData.romOptions).forEach((key, index) => {
if (index < Object.keys(settingData.romOptions).length / 2) { leftRomOpts[key] = settingData.romOptions[key]; }
else { rightRomOpts[key] = settingData.romOptions[key]; }
});
document.getElementById('rom-options-left').appendChild(buildOptionsTable(leftRomOpts, true));
document.getElementById('rom-options-right').appendChild(buildOptionsTable(rightRomOpts, true));
};
const buildOptionsTable = (settings, romOpts = false) => {
const currentSettings = JSON.parse(localStorage.getItem('playerSettings'));
const currentSettings = JSON.parse(localStorage.getItem(gameName));
const table = document.createElement('table');
const tbody = document.createElement('tbody');
@@ -122,7 +104,7 @@ const buildOptionsTable = (settings, romOpts = false) => {
}
select.appendChild(option);
});
select.addEventListener('change', (event) => updateSetting(event));
select.addEventListener('change', (event) => updateGameSetting(event));
tdr.appendChild(select);
tr.appendChild(tdr);
tbody.appendChild(tr);
@@ -132,20 +114,22 @@ const buildOptionsTable = (settings, romOpts = false) => {
return table;
};
const updateSetting = (event) => {
const options = JSON.parse(localStorage.getItem('playerSettings'));
if (event.target.getAttribute('data-romOpt')) {
options.rom[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
const updateBaseSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName));
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value);
localStorage.setItem(gameName, JSON.stringify(options));
};
const updateGameSetting = (event) => {
const options = JSON.parse(localStorage.getItem(gameName));
options[gameName][event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value, 10);
} else {
options[event.target.getAttribute('data-key')] = isNaN(event.target.value) ?
event.target.value : parseInt(event.target.value, 10);
}
localStorage.setItem('playerSettings', JSON.stringify(options));
localStorage.setItem(gameName, JSON.stringify(options));
};
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"; }
const yamlText = jsyaml.safeDump(settings, { noCompatMode: true }).replaceAll(/'(\d+)':/g, (x, y) => `${y}:`);
download(`${document.getElementById('player-name').value}.yaml`, yamlText);
@@ -164,25 +148,20 @@ const download = (filename, text) => {
const generateGame = (raceMode = false) => {
axios.post('/api/generate', {
weights: { player: localStorage.getItem('playerSettings') },
presetData: { player: localStorage.getItem('playerSettings') },
weights: { player: localStorage.getItem(gameName) },
presetData: { player: localStorage.getItem(gameName) },
playerCount: 1,
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = 'Something went wrong and your game could not be generated.';
if (error.response.data.text) {
userMessage.innerText += ' ' + error.response.data.text;
}
userMessage.classList.add('visible');
window.scrollTo(0, 0);
console.error(error);
});
};
const fetchSpriteData = () => new Promise((resolve, reject) => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
if (ajax.status !== 200) {
reject('Unable to fetch sprite data.');
return;
}
resolve(ajax.responseText);
};
ajax.open('GET', `${window.location.origin}/static/static/spriteData.json`, true);
ajax.send();
});

View File

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

View File

@@ -0,0 +1,49 @@
# Factorio Randomizer Setup Guide
## Required Software
### Server Host
- [Factorio](https://factorio.com)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
### Players
- [Factorio](https://factorio.com)
## General Concept
One Server Host exists per Factorio World in an Archipelago Multiworld, any number of modded Factorio players can then connect to that world. From the view of Archipelago, this Factorio host is a client.
## Installation Procedures
### Dedicated Server Setup
You need a dedicated isolated Factorio installation that the FactorioClient can take control over, if you intend to both emit a world and play, you need to follow both this setup and the player setup.
This requires two Factorio installations. The easiest and cheapest way to do so is to either buy or register a Factorio on factorio.com, which allows you to download as many Factorio games as you want.
1. Download the latest Factorio from https://factorio.com/download for your system, for Windows the recommendation is "win64-manual".
2. Make sure the Factorio you play and the Factorio you use for hosting do not share paths. If you downloaded the "manual" version, this is already the case, otherwise, go into the hosting Factorio's folder and put the following text into its `config-path.cfg`:
```ini
config-path=__PATH__executable__/../../config
use-system-read-write-data-directories=false
```
3. Navigate to where you installed Archipelago and open the host.yaml file as text. Find the entry `executable` under `factorio_options` and set it to point to your Factorio. If you put Factorio into your Archipelago folder, this would already match.
### Player Setup
- Manually install the AP mod for the correct world you want to join, then use Factorio's built-in multiplayer.
## Joining a MultiWorld Game
1. Install the generated Factorio AP Mod (would be in <Factorio Directory>/Mods after step 2 of Setup)
2. Run FactorioClient, it should launch a Factorio server, which you can control with `/factorio <original factorio commands>`,
* It should start up, create a world and become ready for Factorio connections.
3. In FactorioClient, do `/connect <Archipelago Server Address>` to join that multiworld. You can find further commands with `/help` as well as `!help` once connected.
* / commands are run on your local client, ! commands are requests for the AP server
* Players should be able to connect to your Factorio Server and begin playing.
4. You can join yourself by connecting to address `localhost`, other people will need to connect to your IP
and you may need to port forward for the Factorio Server for those connections.

View File

@@ -0,0 +1,136 @@
# Minecraft Randomizer Setup Guide
## Required Software
### Server Host
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.16.5.html)
- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
### Players
- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition)
## Installation Procedures
### Dedicated Server Setup
Only one person has to do this setup and host a dedicated server for everyone else playing to connect to.
1. Download the 1.16.5 **Minecraft Forge** installer from the link above, making sure to download the most recent recommended version.
2. Run the `forge-1.16.5-xx.x.x-installer.jar` file and choose **install server**.
- On this page you will also choose where to install the server to remember this directory it's important in the next step.
3. Navigate to where you installed the server and open `forge-1.16.5-xx.x.x.jar`
- Upon first launch of the server it will close and ask you to accept Minecraft's EULA. There will be a new file called `eula.txt` that contains a link to Minecraft's EULA, and a line that you need to change to `eula=true` to accept Minecraft's EULA.
- This will create the appropriate directories for you to place the files in the following step.
4. Place the `aprandomizer-x.x.x.jar` from the link above file into the `mods` folder of the above installation of your forge server.
- Once again run the server, it will load up and generate the required directory `APData` for when you are ready to play a game!
### Basic Player Setup
- Purchase and install Minecraft from the above link.
**You're Done**.
Players only need to have a Vanilla unmodified version of Minecraft to play!
### Advanced Player Setup
***This is not required to play a randomized minecraft game.***
however this recommended as it helps make the experience more enjoyable.
#### Recomended Mods
- [JourneyMap](https://www.curseforge.com/minecraft/mc-mods/journeymap) (Minimap)
1. Install and run Minecraft from the link above at least once.
2. Run the `forge-1.16.5-xx.x.x-installer.jar` file and choose **install client**.
- Start Minecraft forge at least once to create the directories needed for the next steps.
3. Navigate to your minecraft install directory and place desired mods `.jar` file the in the `mods` directory.
- The default install directories are as follows.
- Windows `%APPDATA%\.minecraft\mods`
- macOS `~/Library/Application Support/minecraft/mods`
- Linux `~/.minecraft/mods`
## Configuring your YAML file
### What is a YAML file and why do I need one?
Your YAML file contains a set of configuration options which provide the generator with information about how
it should generate your game. Each player of a multiworld will provide their own YAML file. This setup allows
each player to enjoy an experience customized for their taste, and different players in the same multiworld
can all have different options.
### Where do I get a YAML file?
A basic minecraft yaml will look like this.
```yaml
description: Template Name
# Your name in-game. Spaces will be replaced with underscores and
# there is a 16 character limit
name: YourName
game: Minecraft
# Shared Options supported by all games:
accessibility: locations
progression_balancing: on
# Minecraft Specific Options
# Number of advancements required (out of 92 total) to spawn the
# Ender Dragon and complete the game.
advancement_goal:
few: 0 #30
normal: 1 #50
many: 0 #70
# Modifies the level of items logically required for exploring
# dangerous areas and fighting bosses.
combat_difficulty:
easy: 0
normal: 1
hard: 0
# Junk-fills certain RNG-reliant or tedious advancements with XP rewards.
include_hard_advancements:
on: 0
off: 1
# Junk-fills extremely difficult advancements;
# this is only How Did We Get Here? and Adventuring Time.
include_insane_advancements:
on: 0
off: 1
# Some advancements require defeating the Ender Dragon;
# this will junk-fill them so you won't have to finish to send some items.
include_postgame_advancements:
on: 0
off: 1
#enables shuffling of villages, outposts, fortresses, bastions, and end cities.
shuffle_structures:
on: 1
off: 0
```
## Joining a MultiWorld Game
### Obtain your Minecraft data file
**Only one yaml file needs to be submitted per minecraft world regardless of how many players play on it.**
When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that
is done, the host will provide you with either a link to download your data file, or with a zip file containing
everyone's data files. Your data file should have a `.apmc` extension.
Put your data file in your forge servers `APData` folder. Make sure to remove any previous data file that was in there
previously.
### Connect to the MultiServer
After having placed your data file in the `APData` folder, start the Forge server and make sure you have OP
status by typing `/op YourMinecraftUsername` in the forge server console then connecting in your Minecraft client.
Once in game type `/connect <AP-Address> (Port) (Password)` where `<AP-Address>` is the address of the
Archipelago server. `(Port)` is only required if the Archipelago server is not using the default port of 38281. `(Password)`
is only required if the Archipleago server you are using has a password set.
### Play the game
When the console tells you that you have joined the room, you're ready to begin playing. Congratulations
on successfully joining a multiworld game! At this point any additional minecraft players may connect to your
forge server.

View File

@@ -0,0 +1,121 @@
# Guia instalación de Minecraft Randomizer
## Software Requerido
### Servidor
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.16.5.html)
- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
### Jugadores
- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition)
## Procedimiento de instalación
### Instalación de servidor dedicado
Solo una persona ha de realizar este proceso y hospedar un servidor dedicado para que los demas jueguen conectandose a él.
1. Descarga el instalador de **Minecraft Forge** 1.16.15 desde el enlace proporcionado, siempre asegurandose de bajar la version mas reciente.
2. Ejecuta el fichero `forge-1.16.5-xx.x.x-installer.jar` y elije **install server**.
- En esta pagina elegiras ademas donde instalar el servidor, importante recordar esta localización en el siguiente paso.
3. Navega al directorio donde hayas instalado el servidor y abre `forge-1.16.5-xx.x.x.jar`
- La primera vez que lances el servidor se cerrara (o no aparecerá nada en absoluto), debería haber un fichero nuevo en el directorio llamado `eula.txt`, el cual que contiene un enlace al EULA de minecraft, cambia la linea a `eula=true` para aceptar el EULA y poder utilizar el software de servidor.
- Esto creara la estructura de directorios apropiada para el siguiente paso
4. Coloca el fichero `aprandomizer-x.x.x.jar` del segundo enlace en el directorio `mods`
- Cuando se ejecute el servidor de nuevo, generara el directorio `APData` que se necesitara para jugar
### Instalación basica para jugadores
- Compra e instala Minecraft a traves del tercer enlace.
**Y listo!**.
Los jugadores solo necesitan una version no modificada de Minecraft para jugar!
### Instalación avanzada para jugadores
***Esto no es requerido para jugar a minecraft randomizado.***
Sin embargo lo recomendamos porque hace la experiencia mas llevadera.
#### Recomended Mods
- [JourneyMap](https://www.curseforge.com/minecraft/mc-mods/journeymap) (Minimap)
1. Instala y ejecuta Minecraft al menos una vez.
2. Ejecuta el fichero `forge-1.16.5-xx.x.x-installer.jar` y elige **install client**.
- Ejecuta Minecraft forge al menos una vez para generar los directorios necesarios para el siguiente paso.
3. Navega a la carpeta de instalación de Minecraft y colocal los mods que quieras en el directorio `mods`
- Los directorios por defecto de instalación son:
- Windows `%APPDATA%\.minecraft\mods`
- macOS `~/Library/Application Support/minecraft/mods`
- Linux `~/.minecraft/mods`
## Configura tu fichero YAML
### Que es un fichero YAML y potque necesito uno?
Tu fichero YAML contiene un numero de opciones que proveen al generador con informacion sobre como debe generar tu juego.
Cada jugador de un multiworld entregara u propio fichero YAML.
Esto permite que cada jugador disfrute de una experiencia personalizada a su gusto y diferentes jugadores dentro del mismo multiworld
pueden tener diferentes opciones
### Where do I get a YAML file?
Un fichero basico yaml para minecraft tendra este aspecto.
```yaml
# Usado para describir tu yaml. Util si tienes multiples ficheros
description: Template Name
# Tu nombre en el juego. Los espacios son reemplazados por guiones bajos, limitado a 16 caracteres
name: YourName
game: Minecraft
accessibility: locations
# Recomendado no activar esto ya que el pool de objetos de Minecraft es bastante escueto, ademas hay muchas maneras alternativas de obtener los objetivos de Minecraft.
progression_balancing: off
# Cuantos avances se necesitan para hacer aparecer el Ender Dragon y acabar el juego. few = 30, normal = 50 , many = 70
advancement_goal:
few: 0
normal: 1
many: 0
# Modifica el nivel de objetos lógicamente requeridos para explorar areas peligrosas y pelear contra jefes.
combat_difficulty:
easy: 0
normal: 1
hard: 0
# Avances que sean tediosos o basados en suerte tendran simplemente experiencia o cosas no necesarias
include_hard_advancements:
on: 0
off: 1
# Los avances extremadamente difíciles no seran requeridos; esto afecta a How Did We Get Here? y Adventuring Time.
include_insane_advancements:
on: 0
off: 1
# Los avances posteriores a Ender Dragon no tendrán objetos necesarios para que otros jugadores en el caso de un MW acaben su partida.
include_postgame_advancements:
on: 0
off: 1
# Actualmente desactivado; permite la mezcla de pueblos, puestos, fortalezas, bastiones y cuidades.
shuffle_structures:
on: 0
off: 1
```
## Unirse a un juego MultiWorld
### Obten tu ficheros de datos Minecraft
**Solo un fichero yaml es necesario por mundo minecraft, sin importar el numero de jugadores que jueguen en el.**
Cuando te unes a un juego multiworld, se te pedirá que entregues tu fichero YAML a quien sea que hospede el juego multiworld (no confundir con hospedar el mundo minecraft).
Una vez la generación acabe, el anfitrión te dará un enlace a tu fichero de datos o un zip con los ficheros de todos.
Tu fichero de datos tiene una extensión `.apmc`.
Pon tu fichero de datos en el directorio `APData` de tu forge server. Asegurate de eliminar los que hubiera anteriormente
### Conectar al multiserver
Despues de poner tu fichero en el directorio `APData`, arranca el Forge server y asegurate que tienes el estado OP
tecleando `/op TuUsuarioMinecraft` en la consola del servidor y entonces conectate con tu cliente Minecraft.
Una vez en juego introduce `/connect <AP-Address> (<Password>)` donde `<AP-Address>` es la dirección del servidor
Archipelago. `(<Password>)`
solo se necesita si el servidor Archipleago tiene un password activo.
### Jugar al juego
Cuando la consola te diga que te has unido a la sala, estas lista/o para empezar a jugar. Felicidades
por unirte exitosamente a un juego multiworld! Llegados a este punto cualquier jugador adicional puede conectarse a tu servidor forge.

View File

@@ -0,0 +1,114 @@
# Minecraft Randomizer Uppsättningsguide
## Nödvändig Mjukvara
### Server Värd
- [Minecraft Forge](https://files.minecraftforge.net/net/minecraftforge/forge/index_1.16.5.html)
- [Minecraft Archipelago Randomizer Mod](https://github.com/KonoTyran/Minecraft_AP_Randomizer/releases)
### Spelare
- [Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition)
## Installationsprocedurer
### Tillägnad
Bara en person behöver göra denna uppsättning och vara värd för en server för alla andra spelare att koppla till.
1. Ladda ner 1.16.5 **Minecraft Forge** installeraren från länken ovanför och se till att ladda ner den senaste rekommenderade versionen.
2. Kör `forge-1.16.5-xx.x.x-installer.jar` filen och välj **installera server**.
- På denna sida kommer du också välja vart du ska installera servern för att komma ihåg denna katalog. Detta är viktigt för nästa steg.
3. Navigera till vart du har installerat servern och öppna `forge-1.16.5-xx.x.x-installer.jar`
- Under första serverstart så kommer den att stängas ner och fråga dig att acceptera Minecrafts EULA. En ny fil kommer skapas vid namn `eula.txt` som har en länk till Minecrafts EULA, och en linje som du behöver byta till `eula=true` för att acceptera Minecrafts EULA.
- Detta kommer skapa de lämpliga katalogerna för dig att placera filerna i de följande steget.
4. Placera `aprandomizer-x.x.x.jar` länken ovanför i `mods` mappen som ligger ovanför installationen av din forge server.
- Kör servern igen. Den kommer ladda up och generera den nödvändiga katalogen `APData` för när du är redo att spela!
### Grundläggande Spelaruppsättning
- Köp och installera Minecraft från länken ovanför.
**Du är klar**.
Andra spelare behöver endast ha en 'Vanilla' omodifierad version av Minecraft för att kunna spela!
### Avancerad Spelaruppsättning
***Detta är inte nödvändigt för att spela ett slumpmässigt Minecraftspel.***
Dock så är det rekommenderat eftersom det hjälper att göra upplevelsen mer trevligt.
#### Rekommenderade Moddar
- [JourneyMap](https://www.curseforge.com/minecraft/mc-mods/journeymap) (Minimap)
1. Installera och Kör Minecraft från länken ovanför minst en gång.
2. Kör `forge-1.16.5-xx.x.x-installer.jar` filen och välj **installera klient**.
- Starta Minecraft Forge minst en gång för att skapa katalogerna som behövs för de nästa stegen.
3. Navigera till din Minecraft installationskatalog och placera de önskade moddarna med `.jar` i `mods` -katalogen.
- Standardinstallationskatalogerna är som följande;
- Windows `%APPDATA%\.minecraft\mods`
- macOS `~/Library/Application Support/minecraft/mods`
- Linux `~/.minecraft/mods`
## Konfigurera Din YAML-fil
### Vad är en YAML-fil och varför behöver jag en?
Din YAML-fil behåller en uppsättning av konfigurationsalternativ som ger generatorn med information om hur
den borde generera ditt spel. Varje spelare i en multivärld kommer behöva ge deras egen YAML-fil. Denna uppsättning tillåter
varje spelare att an njuta av en upplevelse anpassade för deras smaker, och olika spelare i samma multivärld
kan ha helt olika alternativ.
### Vart kan jag få tag i en YAML-fil?
En grundläggande Minecraft YAML kommer se ut så här.
```yaml
description: Template Name
# Ditt spelnamn. Mellanslag kommer bli omplacerad med understräck och det är en 16-karaktärsgräns.
name: YourName
game: Minecraft
accessibility: locations
progression_balancing: off
advancement_goal:
few: 0
normal: 1
many: 0
combat_difficulty:
easy: 0
normal: 1
hard: 0
include_hard_advancements:
on: 0
off: 1
include_insane_advancements:
on: 0
off: 1
include_postgame_advancements:
on: 0
off: 1
shuffle_structures:
on: 1
off: 0
```
För mer detaljer om vad varje inställning gör, kolla standardinställningen `PlayerSettings.yaml` som kommer med Archipelago-installationen.
## Gå med i ett Multivärld-spel
### Skaffa din Minecraft data-fil
**Endast en YAML-fil behöver användats per Minecraft-värld oavsett hur många spelare det är som spelar.**
När du går med it ett Multivärld spel så kommer du bli ombedd att lämna in din YAML-fil till personen som värdar. När detta
är klart så kommer värden att ge dig antingen en länk till att ladda ner din data-fil, eller mer en zip-fil som innehåller allas data-filer.
Din data-fil borde ha en `.apmc` -extension.
Lägg din data-fil i dina forge-servrar `APData` -mapp. Se till att ta bort alla tidigare data-filer som var i där förut.
### Koppla till Multiservern
Efter du har placerat din data-fil i `APData` -mappen, starta forge-servern och se till att you har OP-status
genom att skriva `/op DittAnvändarnamn` i forger-serverns konsol innan du kopplar dig till din Minecraft klient.
När du är inne i spelet, skriv `/connect <AP-Address> (<Lösenord>)` där `<AP-Address>` är addressen av
Archipelago-servern. `(<Lösenord>)` är endast nödvändigt om Archipelago-servern som du använder har ett tillsatt lösenord.
### Spela spelet
När konsolen har informerat att du har gått med i rummet så är du redo att börja spela. Grattis
att du har lykats med att gått med i ett Multivärld-spel! Vid detta tillfälle, alla ytterligare Minecraft-spelare må koppla
in till din forge-server.

View File

@@ -85,5 +85,59 @@
]
}
]
},
{
"gameTitle": "Factorio",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago Factorio software on your computer.",
"files": [
{
"language": "English",
"filename": "factorio/setup_en.md",
"link": "factorio/setup/en",
"authors": [
"Berserker"
]
}
]
}
]
},
{
"gameTitle": "Minecraft",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago Minecraft software on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "minecraft/minecraft_en.md",
"link": "minecraft/minecraft/en",
"authors": [
"Kono Tyran"
]
},
{
"language": "Spanish",
"filename": "minecraft/minecraft_es.md",
"link": "minecraft/minecraft/es",
"authors": [
"Edos"
]
},
{
"language": "Swedish",
"filename": "minecraft/minecraft_sv.md",
"link": "minecraft/minecraft/sv",
"authors": [
"Albinum"
]
}
]
}
]
}
]

View File

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

View File

@@ -7,7 +7,7 @@
</div>
## Required Software
- [MultiWorld Utilities](https://github.com/Berserker66/MultiWorld-Utilities/releases)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Included in the above Utilities)
- Hardware or software capable of loading and playing SNES ROM files
- An emulator capable of running Lua scripts
@@ -21,7 +21,7 @@
### Windows Setup
1. Download and install the MultiWorld Utilities from the link above, making sure to install the most recent version.
**The file is located in the assets section at the bottom of the version information**. If you intend to play normal
multiworld games, you want `Setup.BerserkerMultiWorld.exe`
multiworld games, you want `Setup.Archipelago.exe`
- If you intend to play the doors variant of multiworld, you will want to download the alternate doors file.
- During the installation process, you will be asked to browse for your Japanese 1.0 ROM file. If you have
installed this software before and are simply upgrading now, you will not be prompted to locate your
@@ -144,7 +144,7 @@ on successfully joining a multiworld game!
## Hosting a MultiWorld game
The recommended way to host a game is to use the hosting service provided on
[the website](https://berserkermulti.world/generate). The process is relatively simple:
[the website](/generate). The process is relatively simple:
1. Collect YAML files from your players.
2. Create a zip file containing your players' YAML files.

View File

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

View File

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

View File

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

View File

@@ -93,7 +93,7 @@ const fetchSpriteData = () => new Promise((resolve, reject) => {
}
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();
});
@@ -446,7 +446,7 @@ const buildSpritePicker = (spriteData) => {
let spriteGifFile = sprite.file.split('.');
spriteGifFile.pop();
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('alt', sprite.name);
@@ -473,5 +473,14 @@ const generateGame = (raceMode = false) => {
race: raceMode ? '1' : '0',
}).then((response) => {
window.location.href = response.data.url;
}).catch((error) => {
const userMessage = document.getElementById('user-message');
userMessage.innerText = 'Something went wrong and your game could not be generated.';
if (error.response.data.text) {
userMessage.innerText += ' ' + error.response.data.text;
}
userMessage.classList.add('visible');
window.scrollTo(0, 0);
console.error(error);
});
};

View File

@@ -1,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

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