Compare commits

...

168 Commits
0.1.9 ... 0.2.0

Author SHA1 Message Date
Fabian Dill
97f6003582 MultiServer: fix legacy argument passing in websockets 2021-11-15 20:55:21 +01:00
Fabian Dill
bd8e1f6531 Setup: prevent clicking next when no rom file is selected. 2021-11-14 23:14:52 +01:00
Fabian Dill
3658c9f8e3 Setup: use GetSNESMD5OfFile more 2021-11-14 22:45:49 +01:00
Fabian Dill
6a912c128d Setup: use GetSNESMD5OfFile (by Black Sliver) 2021-11-14 22:37:27 +01:00
Fabian Dill
71f30b72f4 SNIClient: patch and launch SoE 2021-11-14 21:14:22 +01:00
Fabian Dill
2dc8b77ddc Patch: consolidate some if trees 2021-11-14 21:03:17 +01:00
Fabian Dill
16cd2760a4 Super Metroid: more path fixes 2021-11-14 20:51:17 +01:00
black-sliver
55bfc71269 SoE: produce useful error if ROM does not exist 2021-11-14 15:42:22 +00:00
Fabian Dill
d623cd5ce0 Factorio: fix coop sync printing desync detected 2021-11-14 16:04:44 +01:00
Jarno Westhof
4bbf8858b0 Fixed missing newline 2021-11-14 14:24:55 +00:00
Jarno Westhof
5626ff1582 Fixed some routing logic + make two checks more easily available 2021-11-14 14:24:55 +00:00
Fabian Dill
28f5236719 OoT: fix link in english guide 2021-11-14 15:24:01 +01:00
Fabian Dill
4cd9711de3 Super Metroid: fix some file paths 2021-11-14 05:27:03 +01:00
Fabian Dill
2ffa0d0e7f Utils: ignore SSL Cert when getting IP 2021-11-13 23:14:26 +01:00
Fabian Dill
586af0de1d SNIClient: remove some debug stuff before release 2021-11-13 23:05:39 +01:00
Fabian Dill
fc3b8c40be WebHost: handle SM and SoE 2021-11-13 20:52:30 +01:00
Fabian Dill
c178006acc Readme: add new games 2021-11-13 16:35:24 +01:00
Fabian Dill
4e43166e1f Setup: consolidate some SNES rom handling 2021-11-13 16:32:19 +01:00
lordlou
452026165f [SM] added support for more than 255 players (will print Archipelago for higher player number) (#130)
* added support for more than 255 players (will print Archipelago for higher player number)
2021-11-13 15:40:20 +01:00
Fabian Dill
82b8b313f0 Setup: add Secret of Evermore 2021-11-13 03:33:25 +01:00
Fabian Dill
b529f95798 Merge pull request #121 from black-sliver/soe
Added Secret of Evermore support
2021-11-12 23:54:39 +00:00
Fabian Dill
2d55cf4bbf Merge branch 'main' into soe 2021-11-12 23:47:34 +00:00
black-sliver
62e0e0bb55 SoE: update pyevermizer to 0.39.1
* Fix softlock when talking to drain guy again
* Disable receiving items while screen is fading (avoids crashes while closing fullscreen windows)
2021-11-13 00:42:40 +01:00
Fabian Dill
83a40d4394 Setup: delete LttPClient 2021-11-12 23:47:52 +01:00
Fabian Dill
4937156021 Setup: revamp for SNIClient and Super Metroid 2021-11-12 23:43:22 +01:00
black-sliver
24596899c9 SoE doc: change apclient link to http:// for now 2021-11-12 21:53:43 +01:00
CaitSith2
cd3f0eabfb Actually require military science pack for rocket silo on military or higher. 2021-11-12 08:31:46 -08:00
espeon65536
34af785e87 OoT: fixed a bug where free_scarecrow and entrance shuffles could not be rolled together 2021-11-12 16:23:37 +00:00
CaitSith2
34cfe7d1df Fix error in SNIClient 2021-11-12 06:48:23 -08:00
Fabian Dill
ca8f6c2439 Post-Merge Cleanup #2 2021-11-12 14:58:48 +01:00
Fabian Dill
4a8ba0575f Post-Merge Cleanup 2021-11-12 14:36:34 +01:00
lordlou
77ec8d4141 Added Super Metroid support (#46)
Varia Randomizer based implementation
LttPClient -> SNIClient
2021-11-12 14:00:11 +01:00
espeon65536
61ae51b30c OoT ER: Interior and Overworld Entrance Shuffle (#128)
* OoT: add ER retry functionality and custom get_all_state
This all_state does not have events, because they need to be gathered in the world.

* OoT: reenable Interior and Overworld entrance shuffle
2021-11-12 13:58:22 +01:00
CaitSith2
f26d2d5f20 Fix task issues 2021-11-11 12:32:42 -08:00
Fabian Dill
fd07bc3f2c LttP: DeathLink: fix an if tree derp 2021-11-11 21:10:26 +01:00
CaitSith2
8316a1902d Move death link byte to sram 2021-11-11 12:07:17 -08:00
Fabian Dill
650fd5d792 LttP: refine DeathLink handling. 2021-11-11 16:09:08 +01:00
Fabian Dill
82d3e4bc92 Docs: document "Archipelago" special IDs 2021-11-11 11:48:09 +01:00
espeon65536
8eb1f0258c OoT Entrance Randomizer (#125)
Add options:
    "shuffle_grotto_entrances": GrottoEntrances,
    "shuffle_dungeon_entrances": DungeonEntrances,
    "owl_drops": OwlDrops,
    "warp_songs": WarpSongs,
    "spawn_positions": SpawnPositions,
Add Logic Trick:
    "Skip King Zora as Adult with Nothing"
2021-11-11 10:42:08 +01:00
espeon65536
80c86f34a4 Fix get_item command in OOTWorld
Was relying on self.nonadvancement_items, now checks if that attribute is present
2021-11-11 09:28:24 +00:00
black-sliver
3ed7b9f60c SoE: reword webhost doc
Thanks Fainspirit
2021-11-11 09:06:22 +01:00
Fabian Dill
77c18ac819 GenericWorld: implement create_item in case a Spectator ever tries to use !getitem. 2021-11-11 00:23:07 +01:00
black-sliver
0d6c23e4f2 SoE: add documentation to webhost 2021-11-11 00:12:31 +01:00
Fabian Dill
ec9ef21cc0 Tests: add create_item test 2021-11-11 00:06:51 +01:00
Fabian Dill
43323e59ce Logging Revamp 2021-11-10 15:35:43 +01:00
black-sliver
9ada4df151 SoE: include base_checksum in apbp 2021-11-10 09:17:27 +01:00
Fabian Dill
d42d77d3d3 Clients: consolidate argument parsing 2021-11-09 12:53:05 +01:00
Fabian Dill
2007549e01 MultiServer: move PrintJSONMessagePart's found to PrintJSON 2021-11-08 19:13:13 +01:00
Hussein Farran
987bbc761a Add found to PrintJSON packet. 2021-11-08 13:10:17 -05:00
CaitSith2
0b096528d4 implement science-not-invited filtering/scaling if that mod is installed
(Max count of research will be set to 10,000 * player_tech_cost) so as to not have an unreasonable amount.  Also,  other player installed mods, and even the infinite techs will have the max science pack level applied to them.)
2021-11-08 10:04:58 -08:00
Fabian Dill
fa56541b3a CommonClient: explicitly set logging handlers, and explicitly set them to unicode. 2021-11-08 18:57:03 +01:00
Hussein Farran
beb15aa99a Update network protocol.md 2021-11-08 12:48:17 -05:00
Fabian Dill
ca9bf48ffa Network: document ConnectUpdate 2021-11-08 16:58:41 +01:00
Fabian Dill
b9941e40c1 LttP: Allow DeathLink to be adjusted post-gen 2021-11-08 16:34:54 +01:00
Fabian Dill
e8639988ce MultiServer: original_cmd to InvalidPacket 2021-11-08 16:07:37 +01:00
black-sliver
c32f3d6e96 SoE: data_version bump, disable topology, clean up 2021-11-07 23:36:06 +01:00
espeon65536
60697cc8ba OoT: add ROM flag for death_link 2021-11-07 22:07:41 +00:00
espeon65536
c0d3f140f3 OoT: add description for website 2021-11-07 17:30:55 +00:00
espeon65536
d5934a88a7 OoT: ASM modifications to allow for more than 255 players 2021-11-07 17:30:55 +00:00
espeon65536
db2731dfb7 OoT: create OOTWorld.hint_rng earlier in generate_output
Otherwise the generator crashes when trying to make Ganondorf's text with hints off.
2021-11-07 17:30:55 +00:00
espeon65536
97ee73d79f OoT: add DeathLink option 2021-11-07 17:30:55 +00:00
espeon65536
48ce19a923 OoT: add theoretical support for more than 255 players 2021-11-07 17:30:55 +00:00
espeon65536
4f28c3fa46 Add documentation to LogicTricks option 2021-11-07 17:30:55 +00:00
black-sliver
449f4ee92f SoE: apply cut slot name to multidata 2021-11-07 15:56:43 +01:00
black-sliver
79041bdf21 update host.yaml for SoE 2021-11-07 15:43:07 +01:00
black-sliver
655d14ed6e SoE: implement everything else 2021-11-07 15:39:58 +01:00
black-sliver
5d0d9c2890 allow requirements to point to urls 2021-11-07 15:39:58 +01:00
black-sliver
f10163e7d2 SoE: implement logic 2021-11-07 15:39:58 +01:00
Fabian Dill
666e3b5333 MultiServer: add JSONMessagePart["player"] 2021-11-07 14:42:05 +01:00
Fabian Dill
2b124aaff4 MultiServer: add time to RoomInfo 2021-11-07 11:37:58 +01:00
zig-for
00d62fc23f Fix "running from source" link 2021-11-07 08:41:38 +00:00
espeon65536
aa87b78dde Overpowered is no longer hard, instead requires Bastion Remnant + iron pick + basic combat to get gold blocks 2021-11-06 19:59:49 +00:00
espeon65536
6c71bd40fb Minecraft: give client the correct number of required egg shards 2021-11-06 19:59:49 +00:00
CaitSith2
ed40043448 Pick recipe with lowest energy cost for ingredient. 2021-11-06 11:49:03 -07:00
Fabian Dill
5cf7e6e24b DeathLink: add support for the cause field #2 2021-11-06 16:17:10 +01:00
Fabian Dill
720ef936da DeathLink: add support for the cause field 2021-11-06 11:19:59 +01:00
Jarno Westhof
30755b2067 Use base DeathLink option 2021-11-06 10:04:21 +00:00
Jarno Westhof
04f67c114e Routing logic fix for underwater check 2021-11-06 10:04:21 +00:00
Jarno Westhof
ea707a0bc5 [TimeSpinner] Serverside DeathLink + Spoiler log extension 2021-11-06 10:04:21 +00:00
Fabian Dill
f43475f33b MultiServer: declare spectators as default goal-finished 2021-11-06 08:19:10 +01:00
Fabian Dill
739d4d0038 Setup: prepare for Python 3.10 2021-11-04 16:48:02 +01:00
Fabian Dill
e756a77c70 MultiServer: implement Tracker tag
Docs: add InvalidPacket
Docs: add known Tags
Docs: add DeathLink
LttPClient: potentially fix DeathLink chaining
2021-11-04 13:23:13 +01:00
Fabian Dill
bcfa5d0a7e MultiServer: remove accidental loop from !status 2021-11-04 09:01:14 +01:00
Fabian Dill
45f92536a6 MultiServer: add !status command 2021-11-04 08:57:27 +01:00
Fabian Dill
6b0b78d8e0 LttPClient: remove accidental logger remnant #2 2021-11-03 23:27:09 +01:00
Fabian Dill
c336cdc5df LttPClient: remove accidental logger remnant 2021-11-03 23:18:59 +01:00
Fabian Dill
6ea8d07c8f WebHost: /api generate add missing hint_cost and forfeit_mode 2021-11-03 22:38:29 +01:00
Fabian Dill
5c25a08dc1 LttPClient: warn when connection is not made to SNI 2021-11-03 19:58:40 +01:00
Fabian Dill
fe7f109127 WebHost: fix CWE-79/CWE-116 2021-11-03 09:33:47 +01:00
Adam Ziegler
583819c4ae LttP, beemizer: support fine-tuned trap replacements (#113)
* update beemizer logic to separate replacement chance and single vs trap chance

* convert beemizer options to new style
2021-11-03 06:34:11 +01:00
Sandra
cb8da2e757 Marks player names with a pair of asterisks if they have completed their goal. 2021-11-03 04:56:54 +00:00
alwaysintreble
fdc96115e4 Created a general triggers and plando guide for Archipelago. (#101) 2021-11-03 05:55:50 +01:00
Fabian Dill
e019ec5ff7 AutoWorld: add spoiler hooks
Factorio: Move Recipes to new spoiler hooks
2021-11-02 12:29:29 +01:00
Fabian Dill
e4838f6d2b LttPClient: add snes write command 2021-11-02 11:12:13 +01:00
espeon65536
10837e75b2 Minecraft: make A Furious Cocktail hard, Free the End postgame 2021-11-02 05:37:40 +00:00
Fabian Dill
46590c3163 CommonClient.py UI: Server bar: allow connecting via pressing enter 2021-11-01 21:43:17 +01:00
Fabian Dill
e64d5c5f17 Network: implement new packet: ConnectUpdate 2021-11-01 20:00:55 +01:00
Fabian Dill
0e0cc0ad16 LttP: Implement DeathLink 2021-11-01 19:37:47 +01:00
Fabian Dill
8ff01ca979 CommonClient.py UI: log full traceback 2021-11-01 06:40:37 +01:00
CaitSith2
9508a9afc6 Fix leaving the window entirely leaves the server label hover text up. 2021-10-31 08:07:37 -07:00
Fabian Dill
704a0e3078 minor cleanup 2021-10-30 07:52:03 +02:00
Fabian Dill
9bf9f2c611 CommonClient.py: keep track of everyone's games. 2021-10-30 07:33:05 +02:00
Fabian Dill
71c869e65b CommonClient.py UI: add version info to Title 2021-10-29 15:19:10 +02:00
Chris Wilson
2897fa4003 Include references to LttPClient in the LttP tutorial 2021-10-29 04:41:56 -04:00
Fabian Dill
7f020857d1 CommonClient.py UI: Add info on "Server:" label hover
CommonClient.py UI: prevent freeze if UI is closed while waiting on text user input
2021-10-29 10:03:51 +02:00
Jarno Westhof
2217a9304d Fixed v card not getting marked
Changed order of A-D cards
2021-10-29 08:03:49 +00:00
Jarno Westhof
5a389b4855 [Timespinner] made method names lowercase + removed commented out code 2021-10-29 08:03:49 +00:00
Jarno Westhof
bdb9b7803c Added timespinner tracker 2021-10-29 08:03:49 +00:00
Jarno Westhof
4622b3fe36 Fixed bug with items variable 2021-10-29 08:03:49 +00:00
Jarno Westhof
402afd15db Split of trackers into game specific parts 2021-10-29 08:03:49 +00:00
Kyle Franz
82aca3bce4 Fix TR small key getting shuffled away 2021-10-26 16:54:42 +00:00
Chris Wilson
756c6554c9 Update Factorio tutorial 2021-10-25 21:32:58 -04:00
Chris Wilson
3b9753aaf4 Add /info page for Minecraft 2021-10-25 17:48:45 -04:00
Fabian Dill
4472ef20fe Factorio: add DeathLink option 2021-10-25 09:58:08 +02:00
Fabian Dill
c152790011 MultiServer: fix a refactor mistake 2021-10-25 08:24:32 +02:00
Fabian Dill
4e3b8a5178 MultiServer: allow sending another Connect, to update tags, uuid, team etc. 2021-10-25 06:57:06 +02:00
Fabian Dill
375a0ff208 Options: verify starting inventory counts are positive for more than just Factorio 2021-10-25 04:13:25 +02:00
Fabian Dill
57831f0eba FactorioClient: address some common issues 2021-10-24 23:22:06 +02:00
Hussein Farran
c9a3f67121 Update network protocol.md 2021-10-22 19:57:32 -04:00
Fabian Dill
6af1f98c88 CommonClient.py UI: add progressbar representing % of checks done.
CommonClient.py UI: add Commands button that points out /help and !help
CommonClient.py: track permissions
CommonClient.py: track missing locations and checked locations in lib
2021-10-22 05:25:09 +02:00
Fabian Dill
8e35372aad Network: add RoomInfo -> Games
Allows clients to only download relevant parts of the datapackage, or to keep ID lookups per-game, and for Bounce to tell if there will be a receiving end.
2021-10-22 04:46:00 +02:00
Fabian Dill
0f4d285223 TextClient UI: hide panel selection when there's only one panel to select.
CommonClient: remove "/connect " if it was accidentally copy-pasted into server bar.
2021-10-22 00:37:20 +02:00
Fabian Dill
192e592cda Docs: coop 2021-10-21 23:07:39 +02:00
Fabian Dill
1c2c1f286f Some cleanup 2021-10-21 21:06:38 +02:00
Fabian Dill
6e25af9493 LttPClient: fix missed ROM_PLAYER_LIMIT 2021-10-21 20:55:01 +02:00
Fabian Dill
050927008a Tests: add "EmptyStateCanReachSomething" 2021-10-21 20:23:13 +02:00
Fabian Dill
2fe5459c56 Core & LttP: remove 255 player limit 2021-10-21 08:15:47 +02:00
Fabian Dill
8fbbaf7fcb LttPClient: try to find linux SNI executable 2021-10-21 06:43:42 +02:00
Hussein Farran
2f5bdc5cf9 Merge pull request #98 from black-sliver/doc-update
add world api documentation
2021-10-20 19:41:39 -04:00
CaitSith2
17833a0bfc documentation corrections 2021-10-20 12:13:25 -07:00
Fabian Dill
f4e71df946 Requirements: update time! 2021-10-20 20:03:56 +02:00
Fabian Dill
be070b79af MultiServer: add !checked command, as it may be useful for coop. 2021-10-20 19:58:07 +02:00
Hussein Farran
ef8eefd3b4 Create en_Risk of Rain 2.md 2021-10-20 06:34:54 +00:00
Fabian Dill
83f46f6b2b Readme: fix tutorials page link 2021-10-20 08:31:00 +02:00
Fabian Dill
6b4bdf569c MultiServer: coop support
Just connect multiple clients to the same slot
2021-10-20 05:56:28 +02:00
Fabian Dill
7a9f6e2a8e Factorio: Prevent invalid item counts in start items. 2021-10-19 23:23:48 +02:00
Fabian Dill
ce95ff65bd CommonClient: give UI a server connect bar 2021-10-19 05:38:17 +02:00
Fabian Dill
28e724da98 WebHostLib.options: move to makedirs instead of mkdir. 2021-10-19 02:50:18 +02:00
Fabian Dill
a43b027cde Subnautica: add an install guide 2021-10-19 02:41:40 +02:00
Fabian Dill
4b5e36ebf2 FactorioClient: >< 2021-10-19 01:49:51 +02:00
Fabian Dill
89c05cfcae FactorioClient: Fix bridge not sending, and limit bridge to run up to once a second.
Setup: Fix LttP Adjuster needs to be installed with generator/lttp
MultiServer: fix duplicate !forfeits
2021-10-19 01:47:11 +02:00
Fabian Dill
f8569db21b Merge remote-tracking branch 'Archipelago/main' into Archipelago_Main 2021-10-18 22:58:45 +02:00
Fabian Dill
34eba2655e MultiServer: add !collect and collect_mode
CommonClient: make missing and checked location lookups faster
FactorioClient: implement reverse grant technologies for collect/forfeit/coop
2021-10-18 22:58:29 +02:00
Chris Wilson
1625860bd9 Add /info page for Super Metroid 2021-10-18 09:06:24 -04:00
Chris Wilson
f3ddfb96f3 Add Super Metroid setup guide, update LttP setup guide 2021-10-18 09:02:52 -04:00
Fabian Dill
66e198cbb6 Merge branch 'rip_compat' into Archipelago_Main
# Conflicts:
#	MultiServer.py
2021-10-18 08:18:28 +02:00
Vince Lund
33c747a881 Accidently changed variable name 2021-10-18 06:11:25 +00:00
Vince Lund
20d61d14e0 Fixed some spelling 2021-10-18 06:11:25 +00:00
Fabian Dill
833de94ab0 Generate: You can now have triggers in a game section that get run after common triggers and after the game is selected. Their format is the same but they can't overwrite game. 2021-10-17 20:53:06 +02:00
Fabian Dill
c8d6250ada WebHost: set default upload limit to 64 MB, as OoT is chonkers.
WebHost: rename .multidata to .archipelago in a missed flash message
WebHost: correctly parse Factorio slot names with "-"
2021-10-16 20:11:26 +02:00
Fabian Dill
d38e1185bb Setup: auto include auto generated yaml files 2021-10-16 19:40:58 +02:00
Fabian Dill
fdb8ae0cb5 FactorioClient: Warn user about the dangers of AppData
Factorio: improve setup guide somewhat
2021-10-16 19:40:27 +02:00
black-sliver
d79acef59e api.md: update precollected for commit# e66a2a7 2021-10-10 18:39:03 +02:00
black-sliver
2f04b93fdb api.md: add set Location.event in location skeleton 2021-10-10 14:03:33 +02:00
black-sliver
818e99b39d api.md: add exclusions to create_items, fix bug in generate_output 2021-10-10 13:09:18 +02:00
black-sliver
652c9943c2 api.md: add to the list of requirements 2021-10-09 14:35:08 +02:00
black-sliver
9f62575abe api.md: add data_version, clarify ids, add precollected_items 2021-10-09 14:29:52 +02:00
black-sliver
2fd87f703e api.md: fix more stuff based on comments 2021-10-09 13:00:50 +02:00
black-sliver
0376705e47 api.md: change 'Your World' based on suggestions 2021-10-09 11:28:15 +02:00
black-sliver
f1fddac655 api.md: add item groups, fix typo, reformat long lines 2021-10-09 11:06:41 +02:00
black-sliver
317f7116c4 api.md: Reword some things based on @Ijwu's suggestions 2021-10-09 02:05:55 +02:00
black-sliver
bf8e99140e api.md: Apply second batch of suggestions from code review
Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2021-10-09 01:15:35 +02:00
black-sliver
6c949c3a52 api.md: Apply first batch of suggestions from code review
Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2021-10-09 00:49:47 +02:00
black-sliver
87ceef230f api.md: remove useless \s, fix mixin example 2021-10-08 00:39:16 +02:00
black-sliver
a06e81a0ba api.md: add logic and output, fixed some typos, added some typos 2021-10-08 00:25:31 +02:00
black-sliver
59e87e0d27 api.md: fix Item.advancement description 2021-10-07 19:53:19 +02:00
black-sliver
76d1460d0f add api.md work-in-progress v3 2021-10-07 19:41:29 +02:00
Fabian Dill
f56bf0db73 MultiServer: remove legacy datapackage keys
MultiServer: remove warning about legacy datapackage use
MultiServer: remove legacy permission flags
Options: add "random" option to all Choices
LttP: remove random special handling from HeartColor
2021-09-30 13:22:25 +02:00
242 changed files with 56464 additions and 8024 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@
*_Spoiler.txt
*.bmbp
*.apbp
*.apm3
*.apmc
*.apz5
*.pyc

View File

@@ -103,7 +103,8 @@ class MultiWorld():
set_player_attr('boss_shuffle', 'none')
set_player_attr('enemy_health', 'default')
set_player_attr('enemy_damage', 'default')
set_player_attr('beemizer', 0)
set_player_attr('beemizer_total_chance', 0)
set_player_attr('beemizer_trap_chance', 0)
set_player_attr('escape_assist', [])
set_player_attr('open_pyramid', False)
set_player_attr('treasure_hunt_icon', 'Triforce Piece')
@@ -1177,6 +1178,7 @@ class Spoiler():
return json.dumps(out)
def to_file(self, filename):
from worlds.AutoWorld import call_all, call_single, call_stage
self.parse_data()
def bool_to_text(variable: Union[bool, str]) -> str:
@@ -1198,6 +1200,7 @@ class Spoiler():
Utils.__version__, self.world.seed))
outfile.write('Filling Algorithm: %s\n' % self.world.algorithm)
outfile.write('Players: %d\n' % self.world.players)
call_stage(self.world, "write_spoiler_header", outfile)
for player in range(1, self.world.players + 1):
if self.world.players > 1:
@@ -1211,6 +1214,7 @@ class Spoiler():
if options:
for f_option, option in options.items():
write_option(f_option, option)
call_single(self.world, "write_spoiler_header", player, outfile)
if player in self.world.get_game_players("A Link to the Past"):
outfile.write('%s%s\n' % ('Hash: ', self.hashes[player]))
@@ -1245,7 +1249,6 @@ class Spoiler():
outfile.write('Boss shuffle: %s\n' % self.world.boss_shuffle[player])
outfile.write('Enemy health: %s\n' % self.world.enemy_health[player])
outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player])
outfile.write('Beemizer: %s\n' % self.world.beemizer[player])
outfile.write('Prize shuffle %s\n' %
self.world.shuffle_prizes[player])
if self.entrances:
@@ -1260,13 +1263,8 @@ class Spoiler():
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_name(player)
for recipe in self.world.worlds[player].custom_recipes.values():
outfile.write(f"\n{recipe.name} ({name}): {recipe.ingredients} -> {recipe.products}")
call_all(self.world, "write_spoiler", outfile)
outfile.write('\n\nLocations:\n\n')
outfile.write('\n'.join(
@@ -1307,6 +1305,7 @@ class Spoiler():
path_listings.append("{}\n {}".format(location, "\n => ".join(path_lines)))
outfile.write('\n'.join(path_listings))
call_all(self.world, "write_spoiler_end", outfile)
seeddigits = 20

View File

@@ -1,14 +1,18 @@
from __future__ import annotations
import logging
import typing
import asyncio
import urllib.parse
import sys
import os
import typing
import time
import websockets
import Utils
if __name__ == "__main__":
Utils.init_logging("TextClient")
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
from Utils import Version
@@ -16,10 +20,8 @@ from worlds import network_data_package, AutoWorldRegister
logger = logging.getLogger("Client")
gui_enabled = Utils.is_frozen() or "--nogui" not in sys.argv
log_folder = Utils.local_path("logs")
os.makedirs(log_folder, exist_ok=True)
# without terminal we have to use gui mode
gui_enabled = not sys.stdout or "--nogui" not in sys.argv
class ClientCommandProcessor(CommandProcessor):
@@ -55,6 +57,9 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_missing(self) -> bool:
"""List all missing location checks, from your local game state"""
if not self.ctx.game:
self.output("No game set, cannot determine missing checks.")
return False
count = 0
checked_count = 0
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
@@ -91,6 +96,7 @@ class ClientCommandProcessor(CommandProcessor):
class CommonContext():
tags: typing.Set[str] = {"AP"}
starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay
command_processor: int = ClientCommandProcessor
@@ -105,6 +111,13 @@ class CommonContext():
self.server_task = None
self.server: typing.Optional[Endpoint] = None
self.server_version = Version(0, 0, 0)
self.hint_cost: typing.Optional[int] = None
self.games: typing.Dict[int, str] = {}
self.permissions = {
"forfeit": "disabled",
"collect": "disabled",
"remaining": "disabled",
}
# own state
self.finished_game = False
@@ -114,16 +127,18 @@ class CommonContext():
self.auth = None
self.seed_name = None
self.locations_checked: typing.Set[int] = set()
self.locations_checked: typing.Set[int] = set() # local state
self.locations_scouted: typing.Set[int] = set()
self.items_received = []
self.missing_locations: typing.List[int] = []
self.checked_locations: typing.List[int] = []
self.missing_locations: typing.Set[int] = set()
self.checked_locations: typing.Set[int] = set() # server state
self.locations_info = {}
self.input_queue = asyncio.Queue()
self.input_requests = 0
self.last_death_link: float = time.time() # last send/received death link on AP layer
# game state
self.player_names: typing.Dict[int: str] = {0: "Archipelago"}
self.exit_event = asyncio.Event()
@@ -136,6 +151,12 @@ class CommonContext():
# execution
self.keep_alive_task = asyncio.create_task(keep_alive(self))
@property
def total_locations(self) -> typing.Optional[int]:
"""Will return None until connected."""
if self.checked_locations or self.missing_locations:
return len(self.checked_locations | self.missing_locations)
async def connection_closed(self):
self.auth = None
self.items_received = []
@@ -146,6 +167,7 @@ class CommonContext():
self.server = None
self.server_task = None
# noinspection PyAttributeOutsideInit
def set_getters(self, data_package: dict, network=False):
if not network: # local data; check if newer data was already downloaded
local_package = Utils.persistent_load().get("datapackage", {}).get("latest", {})
@@ -230,6 +252,36 @@ class CommonContext():
"""For custom package handling in subclasses."""
pass
def update_permissions(self, permissions: typing.Dict[str, int]):
for permission_name, permission_flag in permissions.items():
try:
flag = Permission(permission_flag)
logger.info(f"{permission_name.capitalize()} permission: {flag.name}")
self.permissions[permission_name] = flag.name
except Exception as e: # safeguard against permissions that may be implemented in the future
logger.exception(e)
def on_deathlink(self, data: dict):
"""Gets dispatched when a new DeathLink is triggered by another linked player."""
self.last_death_link = max(data["time"], self.last_death_link)
text = data.get("cause", "")
if text:
logger.info(f"DeathLink: {text}")
else:
logger.info(f"DeathLink: Received from {data['source']}")
async def send_death(self, death_text: str = ""):
logger.info("Sending death to your friends...")
self.last_death_link = time.time()
await self.send_msgs([{
"cmd": "Bounce", "tags": ["DeathLink"],
"data": {
"time": self.last_death_link,
"source": self.player_names[self.slot],
"cause": death_text
}
}])
async def keep_alive(ctx: CommonContext, seconds_between_checks=100):
"""some ISPs/network configurations drop TCP connections if no payload is sent (ignore TCP-keep-alive)
@@ -319,10 +371,9 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
if args['password']:
logger.info('Password required')
for permission_name, permission_flag in args.get("permissions", {}).items():
flag = Permission(permission_flag)
logger.info(f"{permission_name.capitalize()} permission: {flag.name}")
ctx.update_permissions(args.get("permissions", {}))
if "games" in args:
ctx.games = {x: game for x, game in enumerate(args["games"], start=1)}
logger.info(
f"A !hint costs {args['hint_cost']}% of your total location count as points"
f" and you get {args['location_check_points']}"
@@ -389,8 +440,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
# This list is used to only send to the server what is reported as ACTUALLY Missing.
# This also serves to allow an easy visual of what locations were already checked previously
# when /missing is used for the client side view of what is missing.
ctx.missing_locations = args["missing_locations"]
ctx.checked_locations = args["checked_locations"]
ctx.missing_locations = set(args["missing_locations"])
ctx.checked_locations = set(args["checked_locations"])
elif cmd == 'ReceivedItems':
start_index = args["index"]
@@ -419,6 +470,12 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.consume_players_package(args["players"])
if "hint_points" in args:
ctx.hint_points = args['hint_points']
if "checked_locations" in args:
checked = set(args["checked_locations"])
ctx.checked_locations |= checked
ctx.missing_locations -= checked
if "permissions" in args:
ctx.update_permissions(args["permissions"])
elif cmd == 'Print':
ctx.on_print(args)
@@ -430,7 +487,10 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
elif cmd == "Bounced":
pass
tags = args.get("tags", [])
# we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this
if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]:
ctx.on_deathlink(args["data"])
else:
logger.debug(f"unknown command {cmd}")
@@ -459,20 +519,22 @@ async def console_loop(ctx: CommonContext):
logger.exception(e)
def init_logging(name: str):
if gui_enabled:
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO,
filename=os.path.join(log_folder, f"{name}.txt"), filemode="w", force=True)
else:
logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO, force=True)
logging.getLogger().addHandler(logging.FileHandler(os.path.join(log_folder, f"{name}.txt"), "w"))
def get_base_parser(description=None):
import argparse
parser = argparse.ArgumentParser(description=description)
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 sys.stdout: # If terminal output exists, offer gui-less mode
parser.add_argument('--nogui', default=False, action='store_true', help="Turns off Client GUI.")
return parser
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
init_logging("TextClient")
class TextContext(CommonContext):
tags = {"AP", "IgnoreGame"}
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(TextContext, self).server_auth(password_requested)
@@ -482,10 +544,15 @@ if __name__ == '__main__':
await self.send_msgs([{"cmd": 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': ['AP', 'IgnoreGame'],
'tags': self.tags,
'uuid': Utils.get_unique_identifier(), 'game': self.game
}])
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.games.get(self.slot, None)
async def main(args):
ctx = TextContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
@@ -515,15 +582,9 @@ if __name__ == '__main__':
if input_task:
input_task.cancel()
import argparse
import colorama
parser = argparse.ArgumentParser(description="Gameless Archipelago Client, for text interfaction.")
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.")
parser = get_base_parser(description="Gameless Archipelago Client, for text interfaction.")
args, rest = parser.parse_known_args()
colorama.init()

View File

@@ -5,23 +5,25 @@ import json
import string
import copy
import subprocess
import factorio_rcon
import time
import random
import factorio_rcon
import colorama
import asyncio
from queue import Queue
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
init_logging
from MultiServer import mark_raw
import Utils
import random
if __name__ == "__main__":
Utils.init_logging("FactorioClient")
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
get_base_parser
from MultiServer import mark_raw
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
from worlds.factorio import Factorio
init_logging("FactorioClient")
class FactorioCommandProcessor(ClientCommandProcessor):
ctx: FactorioContext
@@ -52,9 +54,11 @@ class FactorioContext(CommonContext):
def __init__(self, server_address, password):
super(FactorioContext, self).__init__(server_address, password)
self.send_index = 0
self.send_index: int = 0
self.rcon_client = None
self.awaiting_bridge = False
self.write_data_path = None
self.death_link_tick: int = 0 # last send death link on Factorio layer
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
async def server_auth(self, password_requested: bool = False):
@@ -73,13 +77,13 @@ class FactorioContext(CommonContext):
'password': self.password,
'name': self.auth,
'version': Utils.version_tuple,
'tags': ['AP'],
'tags': self.tags,
'uuid': Utils.get_unique_identifier(),
'game': "Factorio"
}])
def on_print(self, args: dict):
logger.info(args["text"])
super(FactorioContext, self).on_print(args)
if self.rcon_client:
self.print_to_game(args['text'])
@@ -91,16 +95,21 @@ class FactorioContext(CommonContext):
@property
def savegame_name(self) -> str:
return f"AP_{self.seed_name}_{self.auth}.zip"
return f"AP_{self.seed_name}_{self.auth}_Save.zip"
def print_to_game(self, text):
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
f"{text}")
def on_deathlink(self, data: dict):
if self.rcon_client:
self.rcon_client.send_command(f"/ap-deathlink {data['source']}")
super(FactorioContext, self).on_deathlink(data)
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
if cmd in {"Connected", "RoomUpdate"}:
# catch up sync anything that is already cleared.
if args["checked_locations"]:
if "checked_locations" in args and args["checked_locations"]:
self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for
item_name in args["checked_locations"]})
@@ -108,9 +117,11 @@ class FactorioContext(CommonContext):
async def game_watcher(ctx: FactorioContext):
bridge_logger = logging.getLogger("FactorioWatcher")
from worlds.factorio.Technologies import lookup_id_to_name
next_bridge = time.perf_counter() + 1
try:
while not ctx.exit_event.is_set():
if ctx.awaiting_bridge and ctx.rcon_client:
if ctx.awaiting_bridge and ctx.rcon_client and time.perf_counter() > next_bridge:
next_bridge = time.perf_counter() + 1
ctx.awaiting_bridge = False
data = json.loads(ctx.rcon_client.send_command("/ap-sync"))
if data["slot_name"] != ctx.auth:
@@ -134,7 +145,12 @@ async def game_watcher(ctx: FactorioContext):
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)
death_link_tick = data.get("death_link_tick", 0)
if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick
await ctx.send_death()
await asyncio.sleep(0.1)
except Exception as e:
logging.exception(e)
@@ -222,6 +238,10 @@ 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"]
# 0.2.0 addition, not present earlier
death_link = bool(info.get("death_link", False))
if death_link:
ctx.tags.add("DeathLink")
async def factorio_spinup_server(ctx: FactorioContext) -> bool:
@@ -250,6 +270,12 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
if "Loading mod AP-" in msg and msg.endswith("(data.lua)"):
parts = msg.split()
ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split(".")))
elif "Write data path: " in msg:
ctx.write_data_path = Utils.get_text_between(msg, "Write data path: ", " [")
if "AppData" in ctx.write_data_path:
logger.warning("It appears your mods are loaded from Appdata, "
"this can lead to problems with multiple Factorio instances. "
"If this is the case, you will get a file locked error running Factorio.")
if not rcon_client and "Starting RCON interface at IP ADDR:" in msg:
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
if ctx.mod_version == ctx.__class__.mod_version:
@@ -327,17 +353,11 @@ class FactorioJSONtoTextParser(JSONtoTextParser):
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 = get_base_parser(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()

View File

@@ -56,7 +56,6 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
logging.warning(
f'Not all items placed. Game beatable anyway. (Could not place {item_to_place})')
continue
raise FillError(f'No more spots to place {item_to_place}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')

View File

@@ -12,6 +12,7 @@ import ModuleUpdate
ModuleUpdate.update()
import Utils
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
@@ -37,16 +38,16 @@ def mystery_argparse():
parser.add_argument('--player_files_path', default=defaults["player_files_path"],
help="Input directory for player files.")
parser.add_argument('--seed', help='Define seed number to generate.', type=int)
parser.add_argument('--multi', default=defaults["players"], type=lambda value: min(max(int(value), 1), 255))
parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1))
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('--lttp_rom', default=options["lttp_options"]["rom_file"], help="Path to the 1.0 JP LttP Baserom.")
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"], help="Path to the 1.0 JP SM Baserom.")
parser.add_argument('--enemizercli', default=defaults["enemizer_path"])
parser.add_argument('--outputpath', default=options["general_options"]["output_path"])
parser.add_argument('--race', action='store_true', default=defaults["race"])
parser.add_argument('--meta_file_path', default=defaults["meta_file_path"])
parser.add_argument('--log_output_path', help='Path to store output log')
parser.add_argument('--log_level', default='info', help='Sets log level')
parser.add_argument('--yaml_output', default=0, type=lambda value: min(max(int(value), 0), 255),
parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0),
help='Output rolled mystery results to yaml up to specified number (made for async multiworld)')
parser.add_argument('--plando', default=defaults["plando_options"],
help='List of options that can be set manually. Can be combined, for example "bosses, items"')
@@ -125,20 +126,10 @@ def main(args=None, callback=ERmain):
erargs.outputname = seed_name
erargs.outputpath = args.outputpath
# set up logger
if args.log_level:
erargs.loglevel = args.log_level
loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[
erargs.loglevel]
Utils.init_logging(f"Generate_{seed}.txt", loglevel=args.log_level)
if args.log_output_path:
os.makedirs(args.log_output_path, exist_ok=True)
logging.basicConfig(format='%(message)s', level=loglevel, force=True,
filename=os.path.join(args.log_output_path, f"{seed}.log"))
else:
logging.basicConfig(format='%(message)s', level=loglevel, force=True)
erargs.rom = args.rom
erargs.lttp_rom = args.lttp_rom
erargs.sm_rom = args.sm_rom
erargs.enemizercli = args.enemizercli
settings_cache = {k: (roll_settings(v, args.plando) if args.samesettings else None)
@@ -359,10 +350,10 @@ def roll_linked_options(weights: dict) -> dict:
return weights
def roll_triggers(weights: dict) -> dict:
def roll_triggers(weights: dict, triggers: list) -> dict:
weights = weights.copy() # make sure we don't write back to other weights sets in same_settings
weights["_Generator_Version"] = "Archipelago" # Some means for triggers to know if the seed is on main or doors.
for i, option_set in enumerate(weights["triggers"]):
for i, option_set in enumerate(triggers):
try:
currently_targeted_weights = weights
category = option_set.get("option_category", None)
@@ -455,7 +446,7 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
weights = roll_linked_options(weights)
if "triggers" in weights:
weights = roll_triggers(weights)
weights = roll_triggers(weights, weights["triggers"])
requirements = weights.get("requires", {})
if requirements:
@@ -480,15 +471,21 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
if option_key in weights:
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
ret.name = get_choice('name', weights)
for option_key, option in Options.common_options.items():
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
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]
if "triggers" in game_weights:
weights = roll_triggers(weights, game_weights["triggers"])
game_weights = weights[ret.game]
ret.name = get_choice('name', weights)
for option_key, option in Options.common_options.items():
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
if ret.game in AutoWorldRegister.world_types:
for option_key, option in world_type.options.items():
handle_option(ret, game_weights, option_key, option)
@@ -628,8 +625,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.enemy_health = get_choice_legacy('enemy_health', weights)
ret.beemizer = int(get_choice_legacy('beemizer', weights, 0))
ret.timer = {'none': False,
None: False,
False: False,

View File

@@ -15,7 +15,7 @@ from argparse import Namespace
from concurrent.futures import as_completed, ThreadPoolExecutor
from glob import glob
from tkinter import Tk, Frame, Label, StringVar, Entry, filedialog, messagebox, Button, LEFT, X, TOP, LabelFrame, \
IntVar, Checkbutton, E, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
IntVar, Checkbutton, E, W, OptionMenu, Toplevel, BOTTOM, RIGHT, font as font, PhotoImage
from urllib.parse import urlparse
from urllib.request import urlopen
@@ -51,6 +51,7 @@ def main():
(default: %(default)s)
''')
parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true')
parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true')
parser.add_argument('--disablemusic', help='Disables game music.', action='store_true')
parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?',
choices=['normal', 'hide_goal', 'hide_required', 'hide_both'],
@@ -152,7 +153,8 @@ def adjust(args):
world = getattr(args, "world")
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world)
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
deathlink=args.deathlink)
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
rom.write_to_file(path)
@@ -205,6 +207,7 @@ def adjustGUI():
guiargs.quickswap = bool(rom_vars.quickSwapVar.get())
guiargs.music = bool(rom_vars.MusicVar.get())
guiargs.reduceflashing = bool(rom_vars.disableFlashingVar.get())
guiargs.deathlink = bool(rom_vars.DeathLinkVar.get())
guiargs.rom = romVar2.get()
guiargs.baserom = romVar.get()
guiargs.sprite = rom_vars.sprite
@@ -272,17 +275,18 @@ def update_sprites(task, on_finish=None):
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if filename not in current_sprites]
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
filename not in current_sprites]
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
except Exception as e:
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
type(e).__name__, e)
successful = False
task.queue_event(finished)
return
def dl(sprite_url, filename):
target = os.path.join(sprite_dir, filename)
with urlopen(sprite_url) as response, open(target, 'wb') as out:
@@ -291,7 +295,6 @@ def update_sprites(task, on_finish=None):
def rem(sprite):
os.remove(os.path.join(sprite_dir, sprite))
with ThreadPoolExecutor() as pool:
dl_tasks = []
rem_tasks = []
@@ -313,7 +316,7 @@ def update_sprites(task, on_finish=None):
except Exception as e:
logging.exception(e)
resultmessage = "Error downloading sprite. Not all sprites updated.\n\n%s: %s" % (
type(e).__name__, e)
type(e).__name__, e)
successful = False
for rem_task in as_completed(rem_tasks):
@@ -324,7 +327,7 @@ def update_sprites(task, on_finish=None):
except Exception as e:
logging.exception(e)
resultmessage = "Error removing obsolete sprite. Not all sprites updated.\n\n%s: %s" % (
type(e).__name__, e)
type(e).__name__, e)
successful = False
if successful:
@@ -362,7 +365,7 @@ class BackgroundTask(object):
event = self.queue.get_nowait()
event()
if self.running:
#if self is no longer running self.window may no longer be valid
# if self is no longer running self.window may no longer be valid
self.window.update_idletasks()
except queue.Empty:
pass
@@ -420,6 +423,7 @@ def get_rom_frame(parent=None):
romVar.set(rom)
romSelectButton['state'] = "disabled"
romSelectButton["text"] = "ROM verified"
romSelectButton = Button(romFrame, text='Select Rom', command=RomSelect)
baseRomLabel.pack(side=LEFT)
@@ -444,17 +448,21 @@ def get_rom_options_frame(parent=None):
MusicCheckbutton.grid(row=0, column=0, sticky=E)
vars.disableFlashingVar = IntVar(value=1)
disableFlashingCheckbutton = Checkbutton(romOptionsFrame, text="Disable flashing (anti-epilepsy)", variable=vars.disableFlashingVar)
disableFlashingCheckbutton.grid(row=6, column=0, sticky=E)
disableFlashingCheckbutton = Checkbutton(romOptionsFrame, text="Disable flashing (anti-epilepsy)",
variable=vars.disableFlashingVar)
disableFlashingCheckbutton.grid(row=6, column=0, sticky=W)
vars.DeathLinkVar = IntVar(value=0)
DeathLinkCheckbutton = Checkbutton(romOptionsFrame, text="DeathLink (Team Deaths)", variable=vars.DeathLinkVar)
DeathLinkCheckbutton.grid(row=7, column=0, sticky=W)
spriteDialogFrame = Frame(romOptionsFrame)
spriteDialogFrame.grid(row=0, column=1)
baseSpriteLabel = Label(spriteDialogFrame, text='Sprite:')
vars.spriteNameVar = StringVar()
vars.sprite = None
def set_sprite(sprite_param):
nonlocal vars
if isinstance(sprite_param, str):
@@ -491,7 +499,8 @@ def get_rom_options_frame(parent=None):
menuspeedLabel.pack(side=LEFT)
vars.menuspeedVar = StringVar()
vars.menuspeedVar.set('normal')
menuspeedOptionMenu = OptionMenu(menuspeedFrame, vars.menuspeedVar, 'normal', 'instant', 'double', 'triple', 'quadruple', 'half')
menuspeedOptionMenu = OptionMenu(menuspeedFrame, vars.menuspeedVar, 'normal', 'instant', 'double', 'triple',
'quadruple', 'half')
menuspeedOptionMenu.pack(side=LEFT)
heartcolorFrame = Frame(romOptionsFrame)
@@ -518,7 +527,8 @@ def get_rom_options_frame(parent=None):
owPalettesLabel.pack(side=LEFT)
vars.owPalettesVar = StringVar()
vars.owPalettesVar.set('default')
owPalettesOptionMenu = OptionMenu(owPalettesFrame, vars.owPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
owPalettesOptionMenu = OptionMenu(owPalettesFrame, vars.owPalettesVar, 'default', 'good', 'blackout', 'grayscale',
'negative', 'classic', 'dizzy', 'sick', 'puke')
owPalettesOptionMenu.pack(side=LEFT)
uwPalettesFrame = Frame(romOptionsFrame)
@@ -527,7 +537,8 @@ def get_rom_options_frame(parent=None):
uwPalettesLabel.pack(side=LEFT)
vars.uwPalettesVar = StringVar()
vars.uwPalettesVar.set('default')
uwPalettesOptionMenu = OptionMenu(uwPalettesFrame, vars.uwPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
uwPalettesOptionMenu = OptionMenu(uwPalettesFrame, vars.uwPalettesVar, 'default', 'good', 'blackout', 'grayscale',
'negative', 'classic', 'dizzy', 'sick', 'puke')
uwPalettesOptionMenu.pack(side=LEFT)
hudPalettesFrame = Frame(romOptionsFrame)
@@ -536,7 +547,8 @@ def get_rom_options_frame(parent=None):
hudPalettesLabel.pack(side=LEFT)
vars.hudPalettesVar = StringVar()
vars.hudPalettesVar.set('default')
hudPalettesOptionMenu = OptionMenu(hudPalettesFrame, vars.hudPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
hudPalettesOptionMenu = OptionMenu(hudPalettesFrame, vars.hudPalettesVar, 'default', 'good', 'blackout',
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
hudPalettesOptionMenu.pack(side=LEFT)
swordPalettesFrame = Frame(romOptionsFrame)
@@ -545,7 +557,8 @@ def get_rom_options_frame(parent=None):
swordPalettesLabel.pack(side=LEFT)
vars.swordPalettesVar = StringVar()
vars.swordPalettesVar.set('default')
swordPalettesOptionMenu = OptionMenu(swordPalettesFrame, vars.swordPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
swordPalettesOptionMenu = OptionMenu(swordPalettesFrame, vars.swordPalettesVar, 'default', 'good', 'blackout',
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
swordPalettesOptionMenu.pack(side=LEFT)
shieldPalettesFrame = Frame(romOptionsFrame)
@@ -554,7 +567,8 @@ def get_rom_options_frame(parent=None):
shieldPalettesLabel.pack(side=LEFT)
vars.shieldPalettesVar = StringVar()
vars.shieldPalettesVar.set('default')
shieldPalettesOptionMenu = OptionMenu(shieldPalettesFrame, vars.shieldPalettesVar, 'default', 'good', 'blackout', 'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
shieldPalettesOptionMenu = OptionMenu(shieldPalettesFrame, vars.shieldPalettesVar, 'default', 'good', 'blackout',
'grayscale', 'negative', 'classic', 'dizzy', 'sick', 'puke')
shieldPalettesOptionMenu.pack(side=LEFT)
spritePoolFrame = Frame(romOptionsFrame)
@@ -563,6 +577,7 @@ def get_rom_options_frame(parent=None):
vars.spritePoolCountVar = StringVar()
vars.sprite_pool = []
def set_sprite_pool(sprite_param):
nonlocal vars
operation = "add"
@@ -632,8 +647,10 @@ class SpriteSelector():
title_link.pack(side=LEFT)
title_link.bind("<Button-1>", open_custom_sprite_dir)
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir, 'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
self.icon_section(custom_frametitle, self.custom_sprite_dir, 'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
self.icon_section(custom_frametitle, self.custom_sprite_dir,
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
if not randomOnEvent:
self.sprite_pool_section(spritePool)
@@ -683,7 +700,8 @@ class SpriteSelector():
button = Checkbutton(frame, text="Bonk", command=self.update_random_button, variable=self.randomOnBonkVar)
button.pack(side=LEFT, padx=(0, 5))
button = Checkbutton(frame, text="Random", command=self.update_random_button, variable=self.randomOnRandomVar)
button = Checkbutton(frame, text="Random", command=self.update_random_button,
variable=self.randomOnRandomVar)
button.pack(side=LEFT, padx=(0, 5))
if adjuster:
@@ -805,7 +823,6 @@ class SpriteSelector():
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
def browse_for_sprite(self):
sprite = filedialog.askopenfilename(
filetypes=[("All Sprite Sources", (".zspr", ".spr", ".sfc", ".smc")),
@@ -819,7 +836,6 @@ class SpriteSelector():
self.callback(None)
self.window.destroy()
def use_default_sprite(self):
self.callback(None)
self.window.destroy()
@@ -923,7 +939,8 @@ def get_image_for_sprite(sprite, gif_only: bool = False):
gif_lsd = bytearray(7)
gif_lsd[0] = width
gif_lsd[2] = height
gif_lsd[4] = 0xF4 # 32 color palette follows. transparant + 15 for sprite + 1 for shadow=17 which rounds up to 32 as nearest power of 2
gif_lsd[
4] = 0xF4 # 32 color palette follows. transparant + 15 for sprite + 1 for shadow=17 which rounds up to 32 as nearest power of 2
gif_lsd[5] = 0 # background color is zero
gif_lsd[6] = 0 # aspect raio not specified
gif_gct = bytearray(3 * 32)
@@ -943,7 +960,8 @@ def get_image_for_sprite(sprite, gif_only: bool = False):
gif_id[7] = height
gif_id[9] = 0 # no local color table
gif_img_minimum_code_size = bytes([7]) # we choose 7 bits, so that each pixel is represented by a byte, for conviennce.
gif_img_minimum_code_size = bytes(
[7]) # we choose 7 bits, so that each pixel is represented by a byte, for conviennce.
clear = 0x80
stop = 0x81
@@ -1100,5 +1118,6 @@ class ToolTips(object):
widget.after_cancel(cls.after_id)
cls.after_id = None
if __name__ == '__main__':
main()
main()

10
Main.py
View File

@@ -34,7 +34,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
output_path.cached_path = args.outputpath
start = time.perf_counter()
# initialize the world
world = MultiWorld(args.multi)
@@ -59,7 +58,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.boss_shuffle = args.shufflebosses.copy()
world.enemy_health = args.enemy_health.copy()
world.enemy_damage = args.enemy_damage.copy()
world.beemizer = args.beemizer.copy()
world.beemizer_total_chance = args.beemizer_total_chance.copy()
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
world.timer = args.timer.copy()
world.countdown_start_time = args.countdown_start_time.copy()
world.red_clock_time = args.red_clock_time.copy()
@@ -92,7 +92,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if not cls.hidden:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} Items | "
f"{len(cls.location_names):3} Locations")
logger.info(f" Item IDs: {min(cls.item_id_to_name):{numlength}} - "
logger.info(f" Item IDs: {min(cls.item_id_to_name):{numlength}} - "
f"{max(cls.item_id_to_name):{numlength}} | "
f"Location IDs: {min(cls.location_id_to_name):{numlength}} - "
f"{max(cls.location_id_to_name):{numlength}}")
@@ -151,7 +151,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
AutoWorld.call_all(world, "pre_fill")
logger.info('Fill the world.')
logger.info(f'Filling the world with {len(world.itempool)} items.')
if world.algorithm == 'flood':
flood_items(world) # different algo, biased towards early game progress items
@@ -248,7 +248,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
import NetUtils
slot_data = {}
client_versions = {}
minimum_versions = {"server": (0, 1, 1), "clients": client_versions}
minimum_versions = {"server": (0, 1, 8), "clients": client_versions}
games = {}
for slot in world.player_ids:
client_versions[slot] = world.worlds[slot].get_required_client_version()

View File

@@ -4,8 +4,8 @@ import re
import atexit
from subprocess import Popen
from shutil import copyfile
from base64 import b64decode
from time import strftime
import logging
import requests
@@ -34,7 +34,7 @@ def prompt_yes_no(prompt):
def find_forge_jar(forge_dir):
for entry in os.scandir(forge_dir):
if ".jar" in entry.name and "forge" in entry.name:
print(f"Found forge .jar: {entry.name}")
logging.info(f"Found forge .jar: {entry.name}")
return entry.name
raise FileNotFoundError(f"Could not find forge .jar in {forge_dir}.")
@@ -47,12 +47,12 @@ def find_ap_randomizer_jar(forge_dir):
for entry in os.scandir(mods_dir):
match = ap_mod_re.match(entry.name)
if match:
print(f"Found AP randomizer mod: {match.group()}")
logging.info(f"Found AP randomizer mod: {match.group()}")
return match.group()
return None
else:
os.mkdir(mods_dir)
print(f"Created mods folder in {forge_dir}")
logging.info(f"Created mods folder in {forge_dir}")
return None
@@ -64,17 +64,17 @@ def replace_apmc_files(forge_dir, apmc_file):
copy_apmc = True
if not os.path.isdir(apdata_dir):
os.mkdir(apdata_dir)
print(f"Created APData folder in {forge_dir}")
logging.info(f"Created APData folder in {forge_dir}")
for entry in os.scandir(apdata_dir):
if entry.name.endswith(".apmc") and entry.is_file():
if not os.path.samefile(apmc_file, entry.path):
os.remove(entry.path)
print(f"Removed {entry.name} in {apdata_dir}")
logging.info(f"Removed {entry.name} in {apdata_dir}")
else: # apmc already in apdata
copy_apmc = False
if copy_apmc:
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
print(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
# Check mod version, download new mod from GitHub releases page if needed.
@@ -86,30 +86,31 @@ def update_mod(forge_dir):
if resp.status_code == 200: # OK
latest_release = resp.json()[0]
if ap_randomizer != latest_release['assets'][0]['name']:
print(f"A new release of the Minecraft AP randomizer mod was found: {latest_release['assets'][0]['name']}")
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
f"{latest_release['assets'][0]['name']}")
if ap_randomizer is not None:
print(f"Your current mod is {ap_randomizer}.")
logging.info(f"Your current mod is {ap_randomizer}.")
else:
print(f"You do not have the AP randomizer mod installed.")
logging.info(f"You do not have the AP randomizer mod installed.")
if prompt_yes_no("Would you like to update?"):
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
new_ap_mod = os.path.join(forge_dir, 'mods', latest_release['assets'][0]['name'])
print("Downloading AP randomizer mod. This may take a moment...")
logging.info("Downloading AP randomizer mod. This may take a moment...")
apmod_resp = requests.get(latest_release['assets'][0]['browser_download_url'])
if apmod_resp.status_code == 200:
with open(new_ap_mod, 'wb') as f:
f.write(apmod_resp.content)
print(f"Wrote new mod file to {new_ap_mod}")
logging.info(f"Wrote new mod file to {new_ap_mod}")
if old_ap_mod is not None:
os.remove(old_ap_mod)
print(f"Removed old mod file from {old_ap_mod}")
logging.info(f"Removed old mod file from {old_ap_mod}")
else:
print(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
print(f"Please report this issue on the Archipelago Discord server.")
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
logging.error(f"Please report this issue on the Archipelago Discord server.")
sys.exit(1)
else:
print(f"Error checking for randomizer mod updates (status code {resp.status_code}).")
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
logging.error(f"Error checking for randomizer mod updates (status code {resp.status_code}).")
logging.error(f"If this was not expected, please report this issue on the Archipelago Discord server.")
if not prompt_yes_no("Continue anyways?"):
sys.exit(0)
@@ -127,13 +128,13 @@ def check_eula(forge_dir):
text = f.read()
if 'false' in text:
# Prompt user to agree to the EULA
print("You need to agree to the Minecraft EULA in order to run the server.")
print("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
if prompt_yes_no("Do you agree to the EULA?"):
f.seek(0)
f.write(text.replace('false', 'true'))
f.truncate()
print(f"Set {eula_path} to true")
logging.info(f"Set {eula_path} to true")
else:
sys.exit(0)
@@ -152,12 +153,13 @@ def run_forge_server(forge_dir, heap_arg):
heap_arg = "-Xmx" + heap_arg
argstring = ' '.join([java_exe, heap_arg, "-jar", forge_server, "-nogui"])
print(f"Running Forge server: {argstring}")
logging.info(f"Running Forge server: {argstring}")
os.chdir(forge_dir)
return Popen(argstring)
if __name__ == '__main__':
Utils.init_logging("MinecraftClient")
parser = argparse.ArgumentParser()
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")

View File

@@ -35,18 +35,25 @@ def update(yes = False, force = False):
if not os.path.exists(path):
path = os.path.join(os.path.dirname(__file__), req_file)
with open(path) as requirementsfile:
requirements = pkg_resources.parse_requirements(requirementsfile)
for requirement in requirements:
requirement = str(requirement)
try:
pkg_resources.require(requirement)
except pkg_resources.ResolutionError:
if not yes:
import traceback
traceback.print_exc()
input(f'Requirement {requirement} is not satisfied, press enter to install it')
update_command()
return
for line in requirementsfile:
if line.startswith('https://'):
# extract name and version from url
url = line.split(';')[0]
wheel = line.split('/')[-1]
name, version, _ = wheel.split('-',2)
line = f'{name}=={version}'
requirements = pkg_resources.parse_requirements(line)
for requirement in requirements:
requirement = str(requirement)
try:
pkg_resources.require(requirement)
except pkg_resources.ResolutionError:
if not yes:
import traceback
traceback.print_exc()
input(f'Requirement {requirement} is not satisfied, press enter to install it')
update_command()
return
if __name__ == "__main__":

View File

@@ -13,9 +13,10 @@ import datetime
import threading
import random
import pickle
import itertools
import time
import ModuleUpdate
import NetUtils
ModuleUpdate.update()
@@ -25,6 +26,7 @@ import prompt_toolkit
from prompt_toolkit.patch_stdout import patch_stdout
from fuzzywuzzy import process as fuzzy_process
import NetUtils
from worlds.AutoWorld import AutoWorldRegister
proxy_worlds = {name: world(None, 0) for name, world in AutoWorldRegister.world_types.items()}
@@ -44,7 +46,6 @@ class Client(Endpoint):
def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
super().__init__(socket)
self.auth = False
self.name = None
self.team = None
self.slot = None
self.send_index = 0
@@ -52,6 +53,13 @@ class Client(Endpoint):
self.messageprocessor = client_message_processor(ctx, self)
self.ctx = weakref.ref(ctx)
@property
def name(self) -> str:
ctx = self.ctx()
if ctx:
return ctx.player_names[self.team, self.slot]
return "Deallocated"
team_slot = typing.Tuple[int, int]
@@ -66,15 +74,21 @@ class Context:
"password": str,
"forfeit_mode": str,
"remaining_mode": str,
"collect_mode": str,
"item_cheat": bool,
"compatibility": int}
# team -> slot id -> list of clients authenticated to slot.
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
locations: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int]]]
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", remaining_mode: str = "disabled",
auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2, log_network: bool = False):
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
log_network: bool = False):
super(Context, self).__init__()
self.log_network = log_network
self.endpoints = []
self.clients = {}
self.compatibility: int = compatibility
self.shutdown_task = None
self.data_filename = None
@@ -86,7 +100,8 @@ class Context:
self.allow_forfeits = {}
self.remote_items = set()
self.remote_start_inventory = set()
self.locations: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int]]] = {}
# player location_id item_id target_player_id
self.locations = {}
self.host = host
self.port = port
self.server_password = server_password
@@ -102,6 +117,7 @@ class Context:
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.collect_mode: str = collect_mode
self.item_cheat = item_cheat
self.running = True
self.client_activity_timers: typing.Dict[
@@ -166,23 +182,25 @@ class Context:
logging.info(f"Outgoing broadcast: {msg}")
return True
def broadcast_all(self, msgs):
def broadcast_all(self, msgs: typing.List[dict]):
msgs = self.dumper(msgs)
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast_team(self, team: int, msgs):
def broadcast_team(self, team: int, msgs: typing.List[dict]):
msgs = self.dumper(msgs)
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth and endpoint.team == team)
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast(self, endpoints: typing.Iterable[Endpoint], msgs):
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
msgs = self.dumper(msgs)
asyncio.create_task(self.broadcast_send_encoded_msgs(endpoints, msgs))
async def disconnect(self, endpoint):
async def disconnect(self, endpoint: Client):
if endpoint in self.endpoints:
self.endpoints.remove(endpoint)
if endpoint.slot and endpoint in self.clients[endpoint.team][endpoint.slot]:
self.clients[endpoint.team][endpoint.slot].remove(endpoint)
await on_client_disconnected(self, endpoint)
# text
@@ -239,8 +257,11 @@ class Context:
for player, version in clients_ver.items():
self.minimum_client_versions[player] = Utils.Version(*version)
self.clients = {}
for team, names in enumerate(decoded_obj['names']):
self.clients[team] = {}
for player, name in enumerate(names, 1):
self.clients[team][player] = []
self.player_names[team, player] = name
self.player_name_lookup[name] = team, player
self.seed_name = decoded_obj["seed_name"]
@@ -260,6 +281,11 @@ class Context:
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)
# declare slots without checks as done, as they're assumed to be spectators
for slot, locations in self.locations.items():
if not locations:
for team in self.clients:
self.client_game_state[team, slot] = ClientStatus.CLIENT_GOAL
if use_embedded_server_options:
server_options = decoded_obj.get("server_options", {})
self._set_options(server_options)
@@ -405,6 +431,17 @@ class Context:
else:
return self.player_names[team, slot]
def on_goal_achieved(self, client: Client):
finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \
f' has completed their goal.'
self.notify_all(finished_msg)
if "auto" in self.forfeit_mode:
forfeit_player(self, client.team, client.slot)
elif proxy_worlds[self.games[client.slot]].forced_auto_forfeit:
forfeit_player(self, client.team, client.slot)
if "auto" in self.collect_mode:
collect_player(self, client.team, client.slot)
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint]):
concerns = collections.defaultdict(list)
@@ -416,25 +453,23 @@ def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint]):
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:
client_hints = concerns[client.slot]
if client_hints:
for slot, clients in ctx.clients[team].items():
client_hints = concerns[slot]
if client_hints:
for client in clients:
asyncio.create_task(ctx.send_msgs(client, client_hints))
def update_aliases(ctx: Context, team: int, client: typing.Optional[Client] = None):
def update_aliases(ctx: Context, team: int):
cmd = ctx.dumper([{"cmd": "RoomUpdate",
"players": ctx.get_players_package()}])
if client is None:
for client in ctx.endpoints:
if client.team == team and client.auth:
asyncio.create_task(ctx.send_encoded_msgs(client, cmd))
else:
asyncio.create_task(ctx.send_encoded_msgs(client, cmd))
for clients in ctx.clients[team].values():
for client in clients:
asyncio.create_task(ctx.send_encoded_msgs(client, cmd))
async def server(websocket, path, ctx: Context):
async def server(websocket, path: str = "/", ctx: Context = None):
client = Client(websocket, ctx)
ctx.endpoints.append(client)
@@ -459,27 +494,32 @@ async def server(websocket, path, ctx: Context):
async def on_client_connected(ctx: Context, client: Client):
players = []
for team, clients in ctx.clients.items():
for slot, connected_clients in clients.items():
if connected_clients:
name = ctx.player_names[team, slot]
players.append(
NetworkPlayer(team, slot,
ctx.name_aliases.get((team, slot), name), name)
)
await ctx.send_msgs(client, [{
'cmd': 'RoomInfo',
'password': bool(ctx.password),
'players': [
NetworkPlayer(client.team, client.slot, ctx.name_aliases.get((client.team, client.slot), client.name),
client.name) for client
in ctx.endpoints if client.auth],
'players': players,
'games': [ctx.games[x] for x in range(1, len(ctx.games) + 1)],
# 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,
# TODO ~0.2.0 remove forfeit_mode and remaining_mode in favor of permissions
'forfeit_mode': ctx.forfeit_mode,
'remaining_mode': ctx.remaining_mode,
'permissions': get_permissions(ctx),
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points,
'datapackage_version': network_data_package["version"],
'datapackage_versions': {game: game_data["version"] for game, game_data
in network_data_package["games"].items()},
'seed_name': ctx.seed_name
'seed_name': ctx.seed_name,
'time': time.time(),
}])
@@ -487,6 +527,7 @@ def get_permissions(ctx) -> typing.Dict[str, Permission]:
return {
"forfeit": Permission.from_text(ctx.forfeit_mode),
"remaining": Permission.from_text(ctx.remaining_mode),
"collect": Permission.from_text(ctx.collect_mode)
}
@@ -498,14 +539,11 @@ async def on_client_disconnected(ctx: Context, client: Client):
async def on_client_joined(ctx: Context, client: Client):
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
version_str = '.'.join(str(x) for x in client.version)
verb = "tracking" if "Tracker" in client.tags else "playing"
ctx.notify_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
f"playing {ctx.games[client.slot]} has joined. "
f"{verb} {ctx.games[client.slot]} has joined. "
f"Client({version_str}), {client.tags}).")
# TODO: remove with 0.2
if client.version < Version(0, 1, 7):
ctx.notify_client(client,
"Warning: Your client's datapackage handling may be unsupported soon. (Version < 0.1.7)")
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
@@ -549,27 +587,59 @@ def get_players_string(ctx: Context):
return f'{len(auth_clients)} players of {len(ctx.player_names)} connected ' + text[:-1]
def get_status_string(ctx: Context, team: int):
text = "Player Status on your team:"
for slot in ctx.locations:
connected = len(ctx.clients[team][slot])
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
f"{goal_text} {completion_text}"
return text
def get_received_items(ctx: Context, team: int, player: int) -> typing.List[NetworkItem]:
return ctx.received_items.setdefault((team, player), [])
def send_new_items(ctx: Context):
for client in ctx.endpoints:
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": items[client.send_index:]}]))
client.send_index = len(items)
for team, clients in ctx.clients.items():
for slot, clients in clients.items():
items = get_received_items(ctx, team, slot)
for client in clients:
if len(items) > client.send_index:
asyncio.create_task(ctx.send_msgs(client, [{
"cmd": "ReceivedItems",
"index": client.send_index,
"items": items[client.send_index:]}]))
client.send_index = len(items)
def update_checked_locations(ctx: Context, team: int, slot: int):
ctx.broadcast(ctx.clients[team][slot],
[{"cmd": "RoomUpdate", "checked_locations": get_checked_checks(ctx, team, slot)}])
def forfeit_player(ctx: Context, team: int, slot: int):
# register any locations that are in the multidata
"""register any locations that are in the multidata"""
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)
update_checked_locations(ctx, team, slot)
def collect_player(ctx: Context, team: int, slot: int):
"""register any locations that are in the multidata, pointing towards this player"""
all_locations = collections.defaultdict(set)
for source_slot, location_data in ctx.locations.items():
for location_id, (item_id, target_player_id) in location_data.items():
if target_player_id == slot:
all_locations[source_slot].add(location_id)
ctx.notify_all("%s (Team #%d) has collected" % (ctx.player_names[(team, slot)], team + 1))
for source_player, location_ids in all_locations.items():
register_location_checks(ctx, team, source_player, location_ids)
update_checked_locations(ctx, team, source_player)
def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
@@ -599,10 +669,11 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
ctx.location_checks[team, slot] |= new_locations
send_new_items(ctx)
for client in ctx.endpoints:
if client.team == team and client.slot == slot:
asyncio.create_task(ctx.send_msgs(client, [{"cmd": "RoomUpdate",
"hint_points": get_client_points(ctx, client)}]))
ctx.broadcast(ctx.clients[team][slot], [{
"cmd": "RoomUpdate",
"hint_points": get_slot_points(ctx, team, slot),
"checked_locations": locations, # duplicated data, but used for coop
}])
ctx.save()
@@ -652,15 +723,15 @@ def json_format_send_event(net_item: NetworkItem, receiving_player: int):
NetUtils.add_json_text(parts, net_item.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)
NetUtils.add_json_item(parts, net_item.item, net_item.player)
else:
NetUtils.add_json_text(parts, " sent ")
NetUtils.add_json_text(parts, net_item.item, type=NetUtils.JSONTypes.item_id)
NetUtils.add_json_item(parts, net_item.item, receiving_player)
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_location(parts, net_item.location, net_item.player)
NetUtils.add_json_text(parts, ")")
return {"cmd": "PrintJSON", "data": parts, "type": "ItemSend",
@@ -874,6 +945,11 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(get_players_string(self.ctx))
return True
def _cmd_status(self) -> bool:
"""Get status information about your team."""
self.output(get_status_string(self.ctx, self.client.team))
return True
def _cmd_forfeit(self) -> bool:
"""Surrender and send your remaining items out to their recipients"""
if self.ctx.allow_forfeits.get((self.client.team, self.client.slot), False):
@@ -896,6 +972,25 @@ class ClientMessageProcessor(CommonCommandProcessor):
" You can ask the server admin for a /forfeit")
return False
def _cmd_collect(self) -> bool:
"""Send your remaining items to yourself"""
if "enabled" in self.ctx.collect_mode:
collect_player(self.ctx, self.client.team, self.client.slot)
return True
elif "disabled" in self.ctx.collect_mode:
self.output(
"Sorry, client collecting has been disabled on this server. You can ask the server admin for a /collect")
return False
else: # is auto or goal
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
collect_player(self.ctx, self.client.team, self.client.slot)
return True
else:
self.output(
"Sorry, client collecting requires you to have beaten the game on this server."
" You can ask the server admin for a /collect")
return False
def _cmd_remaining(self) -> bool:
"""List remaining items in your game, but not their location or recipient"""
if self.ctx.remaining_mode == "enabled":
@@ -927,7 +1022,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_missing(self) -> bool:
"""List all missing location checks from the server's perspective"""
locations = get_missing_checks(self.ctx, self.client)
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
if locations:
texts = [f'Missing: {get_location_name_from_id(location)}' for location in locations]
@@ -937,6 +1032,19 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output("No missing location checks found.")
return True
def _cmd_checked(self) -> bool:
"""List all done location checks from the server's perspective"""
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
if locations:
texts = [f'Checked: {get_location_name_from_id(location)}' for location in locations]
texts.append(f"Found {len(locations)} done location checks")
self.ctx.notify_client_multiple(self.client, texts)
else:
self.output("No done location checks found.")
return True
@mark_raw
def _cmd_alias(self, alias_name: str = ""):
"""Set your alias to the passed name."""
@@ -989,7 +1097,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
return True
else:
world = proxy_worlds[self.ctx.games[self.client.slot]]
item_name, usable, response = get_intended_text(input_text, world.all_names if not explicit_location else world.location_names)
item_name, usable, response = get_intended_text(input_text,
world.all_names if not explicit_location else world.location_names)
if usable:
if item_name in world.hint_blacklist:
self.output(f"Sorry, \"{item_name}\" is marked as non-hintable.")
@@ -1072,22 +1181,22 @@ class ClientMessageProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_hint_location(self, location: str = "") -> bool:
"""Use !hint_location {location_name},
for example !hint atomic-bomb to get a spoiler peek for that location.
for example !hint_location atomic-bomb to get a spoiler peek for that location.
(In the case of factorio, or any other game where item names and location names are identical,
this command must be used explicitly.)"""
return self.get_hints(location, True)
def get_checked_checks(ctx: Context, client: Client) -> typing.List[int]:
def get_checked_checks(ctx: Context, team: int, slot: int) -> typing.List[int]:
return [location_id for
location_id in ctx.locations[client.slot] if
location_id in ctx.location_checks[client.team, client.slot]]
location_id in ctx.locations[slot] if
location_id in ctx.location_checks[team, slot]]
def get_missing_checks(ctx: Context, client: Client) -> typing.List[int]:
def get_missing_checks(ctx: Context, team: int, slot: int) -> typing.List[int]:
return [location_id for
location_id in ctx.locations[client.slot] if
location_id not in ctx.location_checks[client.team, client.slot]]
location_id in ctx.locations[slot] if
location_id not in ctx.location_checks[team, slot]]
def get_client_points(ctx: Context, client: Client) -> int:
@@ -1095,24 +1204,30 @@ def get_client_points(ctx: Context, client: Client) -> int:
ctx.get_hint_cost(client.slot) * ctx.hints_used[client.team, client.slot])
def get_slot_points(ctx: Context, team: int, slot: int) -> int:
return (ctx.location_check_points * len(ctx.location_checks[team, slot]) -
ctx.get_hint_cost(slot) * ctx.hints_used[team, slot])
async def process_client_cmd(ctx: Context, client: Client, args: dict):
try:
cmd: str = args["cmd"]
except:
logging.exception(f"Could not get command from {args}")
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd", "original_cmd": None,
"text": f"Could not get command from {args} at `cmd`"}])
raise
if type(cmd) is not str:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd", "original_cmd": None,
"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': 'InvalidPacket', "type": "arguments", 'text': 'Connect'}])
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", 'text': 'Connect',
"original_cmd": cmd}])
return
errors = set()
@@ -1127,26 +1242,6 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
game = ctx.games[slot]
if "IgnoreGame" not in args["tags"] and args['game'] != game:
errors.add('InvalidGame')
# 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
client.slot = slot
else:
errors.add('SlotAlreadyTaken')
else:
client.name = ctx.player_names[(team, slot)]
client.team = team
client.slot = slot
minver = ctx.minimum_client_versions[slot]
if minver > args['version']:
errors.add('IncompatibleVersion')
# only exact version match allowed
if ctx.compatibility == 0 and args['version'] != version_tuple:
@@ -1155,26 +1250,37 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
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:
team, slot = ctx.connect_names[args['name']]
if client.auth and client.team is not None and client.slot in ctx.clients[client.team]:
ctx.clients[team][slot].remove(client) # re-auth, remove old entry
if client.team != team or client.slot != slot:
client.auth = False # swapping Team/Slot
client.team = team
client.slot = slot
minver = ctx.minimum_client_versions[slot]
if minver > args['version']:
errors.add('IncompatibleVersion')
ctx.client_ids[client.team, client.slot] = args["uuid"]
client.auth = True
ctx.clients[team][slot].append(client)
client.version = args['version']
client.tags = args['tags']
reply = [{
"cmd": "Connected",
"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),
# get is needed for old multidata that was sparsely populated
"slot_data": ctx.slot_data.get(client.slot, {})
"missing_locations": get_missing_checks(ctx, team, slot),
"checked_locations": get_checked_checks(ctx, team, slot),
"slot_data": ctx.slot_data[client.slot]
}]
items = get_received_items(ctx, client.team, client.slot)
if items:
reply.append({"cmd": 'ReceivedItems', "index": 0, "items": items})
client.send_index = len(items)
if not client.auth: # if this was a Re-Connect, don't print to console
client.auth = True
await on_client_joined(ctx, client)
await ctx.send_msgs(client, reply)
await on_client_joined(ctx, client)
elif cmd == "GetDataPackage":
exclusions = set(args.get("exclusions", []))
@@ -1188,8 +1294,23 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
else:
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": network_data_package}])
elif client.auth:
if cmd == 'Sync':
if cmd == "ConnectUpdate":
if not args:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", 'text': cmd,
"original_cmd": cmd}])
return
if "tags" in args:
old_tags = client.tags
client.tags = args["tags"]
if set(old_tags) != set(client.tags):
ctx.notify_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
f"from {old_tags} to {client.tags}.")
elif cmd == 'Sync':
items = get_received_items(ctx, client.team, client.slot)
if items:
client.send_index = len(items)
@@ -1197,14 +1318,20 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
"items": items}])
elif cmd == 'LocationChecks':
register_location_checks(ctx, client.team, client.slot, args["locations"])
if "Tracker" in client.tags:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
"text": "Trackers can't register new Location Checks",
"original_cmd": cmd}])
else:
register_location_checks(ctx, client.team, client.slot, args["locations"])
elif cmd == 'LocationScouts':
locs = []
for location in args["locations"]:
if type(location) is not int or location not in lookup_any_location_id_to_name:
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts'}])
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
"original_cmd": cmd}])
return
target_item, target_player = ctx.locations[client.slot][location]
locs.append(NetworkItem(target_item, location, target_player))
@@ -1216,7 +1343,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
elif cmd == 'Say':
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'Say'}])
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments", "text": 'Say',
"original_cmd": cmd}])
return
client.messageprocessor(args["text"])
@@ -1239,12 +1367,7 @@ 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
if new_status == ClientStatus.CLIENT_GOAL:
finished_msg = f'{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has completed their goal.'
ctx.notify_all(finished_msg)
if "auto" in ctx.forfeit_mode:
forfeit_player(ctx, client.team, client.slot)
elif proxy_worlds[ctx.games[client.slot]].forced_auto_forfeit:
forfeit_player(ctx, client.team, client.slot)
ctx.on_goal_achieved(client)
ctx.client_game_state[client.team, client.slot] = new_status
@@ -1262,20 +1385,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
def default(self, raw: str):
self.ctx.notify_all('[Server]: ' + raw)
@mark_raw
def _cmd_kick(self, player_name: str) -> bool:
"""Kick specified player from the server"""
for client in self.ctx.endpoints:
if client.auth and client.name.lower() == player_name.lower() and client.socket and not client.socket.closed:
asyncio.create_task(client.socket.close())
self.output(f"Kicked {self.ctx.get_aliased_name(client.team, client.slot)}")
if self.ctx.commandprocessor.client == client:
self.ctx.commandprocessor.client = None
return True
self.output(f"Could not find player {player_name} to kick")
return False
def _cmd_save(self) -> bool:
"""Save current state to multidata"""
if self.ctx.saving:
@@ -1324,9 +1433,21 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(response)
return False
@mark_raw
def _cmd_collect(self, player_name: str) -> bool:
"""Send out the remaining items to player."""
seeked_player = player_name.lower()
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
collect_player(self.ctx, team, slot)
return True
self.output(f"Could not find player {player_name} to collect")
return False
@mark_raw
def _cmd_forfeit(self, player_name: str) -> bool:
"""Send out the remaining items from a player's game to their intended recipients"""
"""Send out the remaining items from a player to their intended recipients"""
seeked_player = player_name.lower()
for (team, slot), name in self.ctx.player_names.items():
if name.lower() == seeked_player:
@@ -1430,7 +1551,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
return input_text
setattr(self.ctx, option_name, attrtype(option))
self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
if option_name in {"forfeit_mode", "remaining_mode"}:
if option_name in {"forfeit_mode", "remaining_mode", "collect_mode"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
return True
else:
@@ -1476,6 +1597,15 @@ def parse_args() -> argparse.Namespace:
goal: !forfeit can be used after goal completion
auto-enabled: !forfeit is available and automatically triggered on goal completion
''')
parser.add_argument('--collect_mode', default=defaults["collect_mode"], nargs='?',
choices=['auto', 'enabled', 'disabled', "goal", "auto-enabled"], help='''\
Select !collect Accessibility. (default: %(default)s)
auto: Automatic "collect" on goal completion
enabled: !collect is always available
disabled: !collect is never available
goal: !collect can be used after goal completion
auto-enabled: !collect is available and automatically triggered on goal completion
''')
parser.add_argument('--remaining_mode', default=defaults["remaining_mode"], nargs='?',
choices=['enabled', 'disabled', "goal"], help='''\
Select !remaining Accessibility. (default: %(default)s)
@@ -1526,11 +1656,11 @@ async def auto_shutdown(ctx, to_cancel=None):
async def main(args: argparse.Namespace):
logging.basicConfig(force=True,
format='[%(asctime)s] %(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
Utils.init_logging("Server", loglevel=args.loglevel.lower())
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.hint_cost, not args.disable_item_cheat, args.forfeit_mode, args.collect_mode,
args.remaining_mode,
args.auto_shutdown, args.compatibility, args.log_network)
data_filename = args.multidata
@@ -1550,7 +1680,7 @@ async def main(args: argparse.Namespace):
ctx.init_save(not args.disable_save)
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ping_timeout=None,
ping_interval=None)
ip = args.host if args.host else Utils.get_public_ipv4()
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,

View File

@@ -13,8 +13,8 @@ class JSONMessagePart(typing.TypedDict, total=False):
# optional
type: str
color: str
# mainly for items, optional
found: bool
# owning player for location/item
player: int
class ClientStatus(enum.IntEnum):
@@ -62,7 +62,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
data = obj._asdict()
data["class"] = obj.__class__.__name__
return data
if isinstance(obj, (tuple, list)):
if isinstance(obj, (tuple, list, set)):
return tuple(_scan_for_TypedTuples(o) for o in obj)
if isinstance(obj, dict):
return {key: _scan_for_TypedTuples(value) for key, value in obj.items()}
@@ -111,7 +111,6 @@ def _object_hook(o: typing.Any) -> typing.Any:
decode = JSONDecoder(object_hook=_object_hook).decode
class Endpoint:
socket: websockets.WebSocketServerProtocol
@@ -241,6 +240,14 @@ def add_json_text(parts: list, text: typing.Any, **kwargs) -> None:
parts.append({"text": str(text), **kwargs})
def add_json_item(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
parts.append({"text": str(item_id), "player": player, "type": JSONTypes.item_id, **kwargs})
def add_json_location(parts: list, item_id: int, player: int = 0, **kwargs) -> None:
parts.append({"text": str(item_id), "player": player, "type": JSONTypes.location_id, **kwargs})
class Hint(typing.NamedTuple):
receiving_player: int
finding_player: int
@@ -265,9 +272,9 @@ class Hint(typing.NamedTuple):
add_json_text(parts, "[Hint]: ")
add_json_text(parts, self.receiving_player, type="player_id")
add_json_text(parts, "'s ")
add_json_text(parts, self.item, type="item_id", found=self.found)
add_json_item(parts, self.item, self.receiving_player)
add_json_text(parts, " is at ")
add_json_text(parts, self.location, type="location_id")
add_json_location(parts, self.location, self.finding_player)
add_json_text(parts, " in ")
add_json_text(parts, self.finding_player, type="player_id")
if self.entrance:
@@ -282,7 +289,8 @@ class Hint(typing.NamedTuple):
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
"receiving": self.receiving_player,
"item": NetworkItem(self.item, self.location, self.finding_player)}
"item": NetworkItem(self.item, self.location, self.finding_player),
"found": self.found}
@property
def local(self):

View File

@@ -139,10 +139,8 @@ class Choice(Option):
@classmethod
def from_text(cls, text: str) -> Choice:
text = text.lower()
# TODO: turn on after most people have adjusted their yamls to no longer have suboptions with "random" in them
# maybe in 0.2?
# if text == "random":
# return cls(random.choice(list(cls.options.values())))
if text == "random":
return cls(random.choice(list(cls.name_lookup)))
for optionname, value in cls.options.items():
if optionname == text:
return cls(value)
@@ -264,6 +262,16 @@ class OptionDict(Option):
return item in self.value
class ItemDict(OptionDict):
# implemented by Generate
verify_item_name = True
def __init__(self, value: typing.Dict[str, int]):
if any(item_count < 1 for item_count in value.values()):
raise Exception("Cannot have non-positive item counts.")
super(ItemDict, self).__init__(value)
class OptionList(Option):
default = []
supports_weighting = False
@@ -359,7 +367,7 @@ class NonLocalItems(ItemSet):
displayname = "Not Local Items"
class StartInventory(OptionDict):
class StartInventory(ItemDict):
"""Start with these items."""
verify_item_name = True
displayname = "Start Inventory"
@@ -380,6 +388,11 @@ class ExcludeLocations(OptionSet):
verify_location_name = True
class DeathLink(Toggle):
"""When you die, everyone dies. Of course the reverse is true too."""
displayname = "Death Link"
per_game_common_options = {
"local_items": LocalItems,
"non_local_items": NonLocalItems,

View File

@@ -1,3 +1,5 @@
# TODO: convert this into a system like AutoWorld
import bsdiff4
import yaml
import os
@@ -10,54 +12,88 @@ from typing import Tuple, Optional
import Utils
current_patch_version = 3
current_patch_version = 2
GAME_ALTTP = "A Link to the Past"
GAME_SM = "Super Metroid"
GAME_SOE = "Secret of Evermore"
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore"}
preferred_endings = {
GAME_ALTTP: "apbp",
GAME_SM: "apm3",
GAME_SOE: "apsoe"
}
def generate_yaml(patch: bytes, metadata: Optional[dict] = None) -> bytes:
from worlds.alttp.Rom import JAP10HASH
def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if game == GAME_ALTTP:
from worlds.alttp.Rom import JAP10HASH as HASH
elif game == GAME_SM:
from worlds.sm.Rom import JAP10HASH as HASH
elif game == GAME_SOE:
from worlds.soe.Patch import USHASH as HASH
else:
raise RuntimeError(f"Selected game {game} for base rom not found.")
patch = yaml.dump({"meta": metadata,
"patch": patch,
"game": "A Link to the Past",
"game": game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 1,
"compatible_version": 3,
"version": current_patch_version,
"base_checksum": JAP10HASH})
"base_checksum": HASH})
return patch.encode(encoding="utf-8-sig")
def generate_patch(rom: bytes, metadata: Optional[dict] = None) -> bytes:
from worlds.alttp.Rom import get_base_rom_bytes
def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes:
if metadata is None:
metadata = {}
patch = bsdiff4.diff(get_base_rom_bytes(), rom)
return generate_yaml(patch, metadata)
patch = bsdiff4.diff(get_base_rom_data(game), rom)
return generate_yaml(patch, metadata, game)
def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str = None,
player: int = 0, player_name: str = "") -> str:
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
player: int = 0, player_name: str = "", game: str = GAME_ALTTP) -> str:
meta = {"server": server, # allow immediate connection to server in multiworld. Empty string otherwise
"player_id": player,
"player_name": player_name}
bytes = generate_patch(load_bytes(rom_file_to_patch),
meta)
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + ".apbp"
meta,
game)
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
".apbp" if game == GAME_ALTTP else ".apm3")
write_lzma(bytes, target)
return target
def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]:
from worlds.alttp.Rom import get_base_rom_bytes
data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig"))
game_name = data["game"]
if not ignore_version and data["compatible_version"] > current_patch_version:
raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.")
patched_data = bsdiff4.patch(get_base_rom_bytes(), data["patch"])
patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"])
rom_hash = patched_data[int(0x7FC0):int(0x7FD5)]
data["meta"]["hash"] = "".join(chr(x) for x in rom_hash)
target = os.path.splitext(patch_file)[0] + ".sfc"
return data["meta"], target, patched_data
def get_base_rom_data(game: str):
if game == GAME_ALTTP:
from worlds.alttp.Rom import get_base_rom_bytes
elif game == "alttp": # old version for A Link to the Past
from worlds.alttp.Rom import get_base_rom_bytes
elif game == GAME_SM:
from worlds.sm.Rom import get_base_rom_bytes
elif game == GAME_SOE:
file_name = Utils.get_options()["soe_options"]["rom"]
get_base_rom_bytes = lambda: bytes(read_rom(open(file_name, "rb")))
else:
raise RuntimeError("Selected game for base rom not found.")
return get_base_rom_bytes()
def create_rom_file(patch_file: str) -> Tuple[dict, str]:
data, target, patched_data = create_rom_bytes(patch_file)
with open(target, "wb") as f:
@@ -68,7 +104,7 @@ def create_rom_file(patch_file: str) -> Tuple[dict, str]:
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
data = Utils.parse_yaml(lzma.decompress(patch_data).decode("utf-8-sig"))
data["meta"]["server"] = server
bytes = generate_yaml(data["patch"], data["meta"])
bytes = generate_yaml(data["patch"], data["meta"], data["game"])
return lzma.compress(bytes)
@@ -82,6 +118,14 @@ def write_lzma(data: bytes, path: str):
f.write(data)
def read_rom(stream, strip_header=True) -> bytearray:
"""Reads rom into bytearray and optionally strips off any smc header"""
buffer = bytearray(stream.read())
if strip_header and len(buffer) % 0x400 == 0x200:
return buffer[0x200:]
return buffer
if __name__ == "__main__":
host = Utils.get_public_ipv4()
options = Utils.get_options()['server_options']
@@ -113,7 +157,13 @@ if __name__ == "__main__":
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".apm3"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
print(f"Created rom {target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".archipelago"):
import json
import zlib
@@ -139,7 +189,7 @@ if __name__ == "__main__":
def _handle_zip_file_entry(zfinfo: zipfile.ZipInfo, server: str):
data = zfr.read(zfinfo)
if zfinfo.filename.endswith(".apbp"):
if zfinfo.filename.endswith(".apbp") or zfinfo.filename.endswith(".apm3"):
data = update_patch_data(data, server)
with ziplock:
zfw.writestr(zfinfo, data)
@@ -160,12 +210,4 @@ if __name__ == "__main__":
import traceback
traceback.print_exc()
input("Press enter to close.")
def read_rom(stream, strip_header=True) -> bytearray:
"""Reads rom into bytearray and optionally strips off any smc header"""
buffer = bytearray(stream.read())
if strip_header and len(buffer) % 0x400 == 0x200:
return buffer[0x200:]
return buffer
input("Press enter to close.")

View File

@@ -11,8 +11,10 @@ Currently, the following games are supported:
* Risk of Rain 2
* The Legend of Zelda: Ocarina of Time
* Timespinner
* Super Metroid
* Secret of Evermore
For setup and instructions check out our [tutorials page](/tutorial).
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
windows binaries.
@@ -34,7 +36,7 @@ Archipelago was directly forked from bonta0's `multiworld_31` branch of ALttPEnt
## Running Archipelago
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).
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/ArchipelagoMW/Archipelago/wiki/Running-from-source).
## Related Repositories
This project makes use of multiple other projects. We wouldn't be here without these other repositories and the contributions of their developers, past and present.

View File

@@ -1,6 +1,6 @@
import argparse
import atexit
exit_func = atexit.register(input, "Press enter to close.")
from __future__ import annotations
import sys
import threading
import time
import multiprocessing
@@ -12,29 +12,37 @@ import logging
import asyncio
from json import loads, dumps
from Utils import get_item_name_from_id
from Utils import get_item_name_from_id, init_logging
import ModuleUpdate
ModuleUpdate.update()
if __name__ == "__main__":
init_logging("SNIClient")
import colorama
from NetUtils import *
from worlds.alttp import Regions, Shops
from worlds.alttp import Items
from worlds.alttp.Rom import ROM_PLAYER_LIMIT
from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT
import Utils
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, gui_enabled, init_logging
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, gui_enabled, get_base_parser
from Patch import GAME_ALTTP, GAME_SM
init_logging("LttPClient")
snes_logger = logging.getLogger("SNES")
from MultiServer import mark_raw
class DeathState(enum.IntEnum):
killing_player = 1
alive = 2
dead = 3
class LttPCommandProcessor(ClientCommandProcessor):
ctx: Context
def _cmd_slow_mode(self, toggle: str = ""):
"""Toggle slow mode, which limits how fast you send / receive items."""
if toggle:
@@ -46,17 +54,18 @@ class LttPCommandProcessor(ClientCommandProcessor):
@mark_raw
def _cmd_snes(self, snes_options: str = "") -> bool:
"""Connect to a snes. Optionally include network address of a snes to connect to, otherwise show available devices; and a SNES device number if more than one SNES is detected"""
"""Connect to a snes. Optionally include network address of a snes to connect to,
otherwise show available devices; and a SNES device number if more than one SNES is detected"""
snes_address = self.ctx.snes_address
snes_device_number = -1
options = snes_options.split()
num_options = len(options)
if num_options > 0:
snes_address = options[0]
if num_options > 1:
try:
snes_device_number = int(options[1])
@@ -76,6 +85,19 @@ class LttPCommandProcessor(ClientCommandProcessor):
else:
return False
# Left here for quick re-addition for debugging.
# def _cmd_snes_write(self, address, data):
# """Write the specified byte (base10) to the SNES' memory address (base16)."""
# if self.ctx.snes_state != SNESState.SNES_ATTACHED:
# self.output("No attached SNES Device.")
# return False
#
# snes_buffered_write(self.ctx, int(address, 16), bytes([int(data)]))
# asyncio.create_task(snes_flush_writes(self.ctx))
# self.output("Data Sent")
# return True
class Context(CommonContext):
command_processor = LttPCommandProcessor
game = "A Link to the Past"
@@ -93,6 +115,8 @@ class Context(CommonContext):
self.snes_request_lock = asyncio.Lock()
self.snes_write_buffer = []
self.snes_connector_lock = threading.Lock()
self.death_state = DeathState.alive # for death link flop behaviour
self.killing_player_task = None
self.awaiting_rom = False
self.rom = None
@@ -108,7 +132,7 @@ class Context(CommonContext):
raise Exception('Invalid ROM detected, '
'please verify that you have loaded the correct rom and reconnect your snes (/snes)')
async def server_auth(self, password_requested):
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(Context, self).server_auth(password_requested)
if self.rom is None:
@@ -120,10 +144,52 @@ 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,
'tags': get_tags(self),
'uuid': Utils.get_unique_identifier(), 'game': "A Link to the Past"
}])
'password': self.password, 'name': auth, 'version': Utils.version_tuple,
'tags': self.tags,
'uuid': Utils.get_unique_identifier(),
'game': self.game
}])
def on_deathlink(self, data: dict):
if not self.killing_player_task or self.killing_player_task.done():
self.killing_player_task = asyncio.create_task(deathlink_kill_player(self))
super(Context, self).on_deathlink(data)
async def handle_deathlink_state(self, currently_dead: bool):
# in this state we only care about triggering a death send
if self.death_state == DeathState.alive:
if currently_dead:
self.death_state = DeathState.dead
await self.send_death()
# in this state we care about confirming a kill, to move state to dead
elif self.death_state == DeathState.killing_player:
# this is being handled in deathlink_kill_player(ctx) already
pass
# in this state we wait until the player is alive again
elif self.death_state == DeathState.dead:
if not currently_dead:
self.death_state = DeathState.alive
async def deathlink_kill_player(ctx: Context):
ctx.death_state = DeathState.killing_player
while ctx.death_state == DeathState.killing_player and \
ctx.snes_state == SNESState.SNES_ATTACHED:
if ctx.game == GAME_ALTTP:
snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0
snes_buffered_write(ctx, WRAM_START + 0x0373, bytes([8])) # deal 1 full heart of damage at next opportunity
elif ctx.game == GAME_SM:
snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([0, 0])) # set current health to 0
await snes_flush_writes(ctx)
await asyncio.sleep(1)
gamemode = None
if ctx.game == GAME_ALTTP:
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
elif ctx.game == GAME_SM:
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
if not gamemode or gamemode[0] in (DEATH_MODES if ctx.game == GAME_ALTTP else SM_DEATH_MODES):
ctx.death_state = DeathState.dead
ctx.last_death_link = time.time()
def color_item(item_id: int, green: bool = False) -> str:
@@ -136,6 +202,7 @@ def color_item(item_id: int, green: bool = False) -> str:
SNES_RECONNECT_DELAY = 5
# LttP
ROM_START = 0x000000
WRAM_START = 0xF50000
WRAM_SIZE = 0x20000
@@ -146,6 +213,7 @@ ROMNAME_SIZE = 0x15
INGAME_MODES = {0x07, 0x09, 0x0b}
ENDGAME_MODES = {0x19, 0x1a}
DEATH_MODES = {0x12}
SAVEDATA_START = WRAM_START + 0xF000
SAVEDATA_SIZE = 0x500
@@ -161,6 +229,21 @@ SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte
SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes
DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte
# SM
SM_ROMNAME_START = 0x1C4F00
SM_INGAME_MODES = {0x07, 0x09, 0x0b}
SM_ENDGAME_MODES = {0x26, 0x27}
SM_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A}
SM_RECV_PROGRESS_ADDR = SRAM_START + 0x2000 # 2 bytes
SM_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
SM_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte
location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
@@ -384,7 +467,7 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40),
'Ganons Tower - Validation Chest': (0x4d, 0x10)}
location_table_uw_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_uw.items()}
location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()}
location_table_npc = {'Mushroom': 0x1000,
'King Zora': 0x2,
@@ -400,7 +483,7 @@ location_table_npc = {'Mushroom': 0x1000,
'Stumpy': 0x8,
'Bombos Tablet': 0x200}
location_table_npc_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_npc.items()}
location_table_npc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_npc.items()}
location_table_ow = {'Flute Spot': 0x2a,
'Sunken Treasure': 0x3b,
@@ -415,14 +498,15 @@ location_table_ow = {'Flute Spot': 0x2a,
'Bumper Cave Ledge': 0x4a,
'Floating Island': 0x5}
location_table_ow_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_ow.items()}
location_table_ow_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_ow.items()}
location_table_misc = {'Bottle Merchant': (0x3c9, 0x2),
'Purple Chest': (0x3c9, 0x10),
"Link's Uncle": (0x3c6, 0x1),
'Hobo': (0x3c9, 0x1)}
location_table_misc_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_misc.items()}
location_table_misc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_misc.items()}
class SNESState(enum.IntEnum):
SNES_DISCONNECTED = 0
@@ -438,16 +522,18 @@ def launch_sni(ctx: Context):
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"):
lower_file = file.lower()
if (lower_file.startswith("sni.") and not lower_file.endswith(".proto")) or lower_file == "sni":
sni_path = os.path.join(sni_path, file)
if os.path.isfile(sni_path):
snes_logger.info(f"Attempting to start {sni_path}")
import subprocess
if Utils.is_frozen(): # if it spawns a visible console, may as well populate it
import sys
if not sys.stdout: # if it spawns a visible console, may as well populate it
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path))
else:
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.Popen(sni_path, cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
else:
snes_logger.info(
f"Attempt to start SNI was aborted as path {sni_path} was not found, "
@@ -497,13 +583,23 @@ async def get_snes_devices(ctx: Context):
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
await verify_snes_app(socket)
await socket.close()
return devices
async def snes_connect(ctx: Context, address, deviceIndex = -1):
async def verify_snes_app(socket):
AppVersion_Request = {
"Opcode": "AppVersion",
}
await socket.send(dumps(AppVersion_Request))
app: str = loads(await socket.recv())["Results"][0]
if "SNI" not in app:
snes_logger.warning(f"Warning: Did not find SNI as the endpoint, instead {app} was found.")
async def snes_connect(ctx: Context, address, deviceIndex=-1):
global SNES_RECONNECT_DELAY
if ctx.snes_socket is not None and ctx.snes_state == SNESState.SNES_CONNECTED:
if ctx.rom:
@@ -532,7 +628,8 @@ async def snes_connect(ctx: Context, address, deviceIndex = -1):
device = devices[ctx.snes_attached_device[0]]
elif numDevices > 1:
if deviceIndex == -1:
snes_logger.info("Found " + str(numDevices) + " SNES devices; connect to one with /snes <address> <device number>:")
snes_logger.info(
"Found " + str(numDevices) + " SNES devices; connect to one with /snes <address> <device number>:")
for idx, availableDevice in enumerate(devices):
snes_logger.info(str(idx + 1) + ": " + availableDevice)
@@ -542,7 +639,7 @@ async def snes_connect(ctx: Context, address, deviceIndex = -1):
else:
device = devices[deviceIndex - 1]
if device is None:
await snes_disconnect(ctx)
return
@@ -703,12 +800,6 @@ async def snes_flush_writes(ctx: Context):
await snes_write(ctx, writes)
# kept as function for easier wrapping by plugins
def get_tags(ctx: Context):
tags = ['AP']
return tags
async def track_locations(ctx: Context, roomid, roomdata):
new_locations = []
@@ -716,7 +807,8 @@ async def track_locations(ctx: Context, roomid, roomdata):
new_locations.append(location_id)
ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(location_id)
snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
snes_logger.info(
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
try:
if roomid in location_shop_ids:
@@ -786,7 +878,6 @@ async def track_locations(ctx: Context, roomid, roomdata):
if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked:
new_check(location_id)
if new_locations:
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
@@ -803,11 +894,30 @@ async def game_watcher(ctx: Context):
if not ctx.rom:
ctx.finished_game = False
rom = await snes_read(ctx, ROMNAME_START, ROMNAME_SIZE)
gameName = await snes_read(ctx, SM_ROMNAME_START, 2)
if gameName is None:
continue
elif gameName == b"SM":
ctx.game = GAME_SM
else:
ctx.game = GAME_ALTTP
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else ROMNAME_START, ROMNAME_SIZE)
if rom is None or rom == bytes([0] * ROMNAME_SIZE):
continue
ctx.rom = rom
death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else
SM_DEATH_LINK_ACTIVE_ADDR, 1)
if death_link:
death_link = bool(death_link[0] & 0b1)
old_tags = ctx.tags.copy()
if death_link:
ctx.tags.add("DeathLink")
else:
ctx.tags -= {"DeathLink"}
if old_tags != ctx.tags and ctx.server and not ctx.server.socket.closed:
await ctx.send_msgs([{"cmd": "ConnectUpdate", "tags": ctx.tags}])
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
ctx.locations_checked = set()
ctx.locations_scouted = set()
@@ -820,65 +930,131 @@ async def game_watcher(ctx: Context):
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
await ctx.disconnect()
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1)
game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4)
if gamemode is None or gameend is None or game_timer is None or \
(gamemode[0] not in INGAME_MODES and gamemode[0] not in ENDGAME_MODES):
continue
if ctx.game == GAME_ALTTP:
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
currently_dead = gamemode[0] in DEATH_MODES
await ctx.handle_deathlink_state(currently_dead)
delay = 7 if ctx.slow_mode else 2
if gameend[0]:
if not ctx.finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
if time.perf_counter() - perf_counter < delay:
gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1)
game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4)
if gamemode is None or gameend is None or game_timer is None or \
(gamemode[0] not in INGAME_MODES and gamemode[0] not in ENDGAME_MODES):
continue
delay = 7 if ctx.slow_mode else 2
if gameend[0]:
if not ctx.finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
if time.perf_counter() - perf_counter < delay:
continue
else:
perf_counter = time.perf_counter()
else:
perf_counter = time.perf_counter()
else:
game_timer = game_timer[0] | (game_timer[1] << 8) | (game_timer[2] << 16) | (game_timer[3] << 24)
if abs(game_timer - prev_game_timer) < (delay * 60):
game_timer = game_timer[0] | (game_timer[1] << 8) | (game_timer[2] << 16) | (game_timer[3] << 24)
if abs(game_timer - prev_game_timer) < (delay * 60):
continue
else:
prev_game_timer = game_timer
if gamemode in ENDGAME_MODES: # triforce room and credits
continue
else:
prev_game_timer = game_timer
if gamemode in ENDGAME_MODES: # triforce room and credits
continue
data = await snes_read(ctx, RECV_PROGRESS_ADDR, 8)
if data is None:
continue
data = await snes_read(ctx, RECV_PROGRESS_ADDR, 8)
if data is None:
continue
recv_index = data[0] | (data[1] << 8)
recv_item = data[2]
roomid = data[4] | (data[5] << 8)
roomdata = data[6]
scout_location = data[7]
recv_index = data[0] | (data[1] << 8)
recv_item = data[2]
roomid = data[4] | (data[5] << 8)
roomdata = data[6]
scout_location = data[7]
if recv_index < len(ctx.items_received) and recv_item == 0:
item = ctx.items_received[recv_index]
recv_index += 1
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_name_getter(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_name_getter(item.location), recv_index, len(ctx.items_received)))
if recv_index < len(ctx.items_received) and recv_item == 0:
item = ctx.items_received[recv_index]
recv_index += 1
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_name_getter(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_name_getter(item.location), recv_index, 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([min(ROM_PLAYER_LIMIT, item.player) if item.player != ctx.slot else 0]))
if scout_location > 0 and scout_location in ctx.locations_info:
snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR,
bytes([scout_location]))
snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR,
bytes([ctx.locations_info[scout_location][0]]))
snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR,
bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location][1])]))
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]))
if scout_location > 0 and scout_location in ctx.locations_info:
snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR, bytes([scout_location]))
snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR, bytes([ctx.locations_info[scout_location][0]]))
snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR, bytes([ctx.locations_info[scout_location][1]]))
await snes_flush_writes(ctx)
await snes_flush_writes(ctx)
if scout_location > 0 and scout_location not in ctx.locations_scouted:
ctx.locations_scouted.add(scout_location)
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
await track_locations(ctx, roomid, roomdata)
elif ctx.game == GAME_SM:
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
currently_dead = gamemode[0] in SM_DEATH_MODES
await ctx.handle_deathlink_state(currently_dead)
if gamemode is not None and gamemode[0] in SM_ENDGAME_MODES:
if not ctx.finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
continue
if scout_location > 0 and scout_location not in ctx.locations_scouted:
ctx.locations_scouted.add(scout_location)
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
await track_locations(ctx, roomid, roomdata)
data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x680, 4)
if data is None:
continue
recv_index = data[0] | (data[1] << 8)
recv_item = data[2] | (data[3] << 8)
while (recv_index < recv_item):
itemAdress = recv_index * 8
message = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8)
# worldId = message[0] | (message[1] << 8) # unused
# itemId = message[2] | (message[3] << 8) # unused
itemIndex = (message[4] | (message[5] << 8)) >> 3
recv_index += 1
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
from worlds.sm.Locations import locations_start_id
location_id = locations_start_id + itemIndex
ctx.locations_checked.add(location_id)
location = ctx.location_name_getter(location_id)
snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}])
data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x600, 4)
if data is None:
continue
# recv_itemOutPtr = data[0] | (data[1] << 8) # unused
itemOutPtr = data[2] | (data[3] << 8)
from worlds.sm.Items import items_start_id
if itemOutPtr < len(ctx.items_received):
item = ctx.items_received[itemOutPtr]
itemId = item.item - items_start_id
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes([playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, (itemId >> 8) & 0xFF]))
itemOutPtr += 1
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602, bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
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), itemOutPtr, len(ctx.items_received)))
await snes_flush_writes(ctx)
async def run_game(romfile):
auto_start = Utils.get_options()["lttp_options"].get("rom_start", True)
@@ -889,35 +1065,40 @@ async def run_game(romfile):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def main():
multiprocessing.freeze_support()
parser = argparse.ArgumentParser()
parser = get_base_parser()
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 SNI server.')
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
parser.add_argument('--password', default=None, help='Password of the multiworld host.')
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
parser.add_argument('--founditems', default=False, action='store_true',
help='Show items found by other players for themselves.')
if not Utils.is_frozen(): # Frozen state has no cmd window in the first place
parser.add_argument('--nogui', default=False, action='store_true', help="Turns off Client GUI.")
args = parser.parse_args()
logging.basicConfig(format='%(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
if args.diff_file:
import Patch
logging.info("Patch file was supplied. Creating sfc rom..")
meta, romfile = Patch.create_rom_file(args.diff_file)
args.connect = meta["server"]
logging.info(f"Wrote rom file to {romfile}")
adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile, gui_enabled)
if adjusted:
try:
shutil.move(adjustedromfile, romfile)
adjustedromfile = romfile
except Exception as e:
logging.exception(e)
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
if args.diff_file.endswith(".apsoe"):
import webbrowser
webbrowser.open("http://www.evermizer.com/apclient/")
logging.info("Starting Evermizer Client in your Browser...")
import time
time.sleep(3)
sys.exit()
elif args.diff_file.endswith((".apbp", "apz3")):
adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile, gui_enabled)
if adjusted:
try:
shutil.move(adjustedromfile, romfile)
adjustedromfile = romfile
except Exception as e:
logging.exception(e)
asyncio.create_task(run_game(adjustedromfile if adjusted else romfile))
else:
asyncio.create_task(run_game(romfile))
ctx = Context(args.snes, args.connect, args.password)
if ctx.server_task is None:
@@ -925,8 +1106,8 @@ async def main():
if gui_enabled:
input_task = None
from kvui import LttPManager
ctx.ui = LttPManager(ctx)
from kvui import SNIManager
ctx.ui = SNIManager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
@@ -968,4 +1149,3 @@ if __name__ == '__main__':
loop.run_until_complete(main())
loop.close()
colorama.deinit()
atexit.unregister(exit_func)

View File

@@ -13,7 +13,7 @@ class Version(typing.NamedTuple):
build: int
__version__ = "0.1.9"
__version__ = "0.2.0"
version_tuple = tuplize_version(__version__)
import builtins
@@ -25,6 +25,8 @@ import functools
import io
import collections
import importlib
import logging
from yaml import load, dump, safe_load
try:
@@ -120,17 +122,25 @@ parse_yaml = safe_load
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
def get_cert_none_ssl_context():
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
@cache_argsless
def get_public_ipv4() -> str:
import socket
import urllib.request
import logging
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen('https://checkip.amazonaws.com/').read().decode('utf8').strip()
ip = urllib.request.urlopen('https://checkip.amazonaws.com/', context=ctx).read().decode('utf8').strip()
except Exception as e:
try:
ip = urllib.request.urlopen('https://v4.ident.me').read().decode('utf8').strip()
ip = urllib.request.urlopen('https://v4.ident.me', context=ctx).read().decode('utf8').strip()
except:
logging.exception(e)
pass # we could be offline, in a local game, so no point in erroring out
@@ -141,10 +151,10 @@ def get_public_ipv4() -> str:
def get_public_ipv6() -> str:
import socket
import urllib.request
import logging
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen('https://v6.ident.me').read().decode('utf8').strip()
ip = urllib.request.urlopen('https://v6.ident.me', context=ctx).read().decode('utf8').strip()
except Exception as e:
logging.exception(e)
pass # we could be offline, in a local game, or ipv6 may not be available
@@ -161,6 +171,14 @@ def get_default_options() -> dict:
"factorio_options": {
"executable": "factorio\\bin\\x64\\factorio",
},
"sm_options": {
"rom_file": "Super Metroid (JU).sfc",
"sni": "SNI",
"rom_start": True,
},
"soe_options": {
"rom_file": "Secret of Evermore (USA).sfc",
},
"lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
"sni": "SNI",
@@ -180,6 +198,7 @@ def get_default_options() -> dict:
"location_check_points": 1,
"hint_cost": 10,
"forfeit_mode": "goal",
"collect_mode": "disabled",
"remaining_mode": "goal",
"auto_shutdown": 0,
"compatibility": 2,
@@ -210,7 +229,6 @@ def get_default_options() -> dict:
def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
import logging
for key, value in src.items():
new_keys = keys.copy()
new_keys.append(key)
@@ -277,7 +295,6 @@ def persistent_load() -> typing.Dict[dict]:
with open(path, "r") as f:
storage = unsafe_parse_yaml(f.read())
except Exception as e:
import logging
logging.debug(f"Could not read store: {e}")
if storage is None:
storage = {}
@@ -310,7 +327,6 @@ def get_adjuster_settings(romfile: str, skip_questions: bool = False) -> typing.
if sprite_pool:
printed_options["sprite_pool"] = sprite_pool
if hasattr(get_adjuster_settings, "adjust_wanted"):
adjust_wanted = getattr(get_adjuster_settings, "adjust_wanted")
elif persistent_load().get("adjuster", {}).get("never_adjust", False): # never adjust, per user request
@@ -337,7 +353,6 @@ def get_adjuster_settings(romfile: str, skip_questions: bool = False) -> typing.
return romfile, False
else:
adjusted = False
import logging
if not hasattr(get_adjuster_settings, "adjust_wanted"):
logging.info(f"Skipping post-patch adjustment")
get_adjuster_settings.adjuster_settings = adjuster_settings
@@ -401,4 +416,33 @@ def restricted_loads(s):
class KeyedDefaultDict(collections.defaultdict):
def __missing__(self, key):
self[key] = value = self.default_factory(key)
return value
return value
def get_text_between(text: str, start: str, end: str) -> str:
return text[text.index(start) + len(start): text.rindex(end)]
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
log_format: str = "[%(name)s]: %(message)s"):
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = local_path("logs")
os.makedirs(log_folder, exist_ok=True)
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
handler.close()
root_logger.setLevel(loglevel)
file_handler = logging.FileHandler(
os.path.join(log_folder, f"{name}.txt"),
write_mode,
encoding="utf-8-sig")
file_handler.setFormatter(logging.Formatter(log_format))
root_logger.addHandler(file_handler)
if sys.stdout:
root_logger.addHandler(
logging.StreamHandler(sys.stdout)
)

View File

@@ -28,7 +28,7 @@ app.config["SELFLAUNCH"] = True
app.config["DEBUG"] = False
app.config["PORT"] = 80
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
app.config['MAX_CONTENT_LENGTH'] = 4 * 1024 * 1024 # 4 megabyte limit
app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
# at what amount of worlds should scheduling be used, instead of rolling in the webthread
@@ -155,7 +155,6 @@ def _read_log(path: str):
@app.route('/log/<suuid:room>')
def display_log(room: UUID):
# noinspection PyTypeChecker
return Response(_read_log(os.path.join("logs", str(room) + ".txt")), mimetype="text/plain;charset=UTF-8")

View File

@@ -16,7 +16,7 @@ def generate_api():
try:
options = {}
race = False
meta_options_source = {}
if 'file' in request.files:
file = request.files['file']
options = get_yaml_data(file)
@@ -24,14 +24,20 @@ def generate_api():
return {"text": options}, 400
if "race" in request.form:
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
meta_options_source = request.form
json_data = request.get_json()
if json_data:
meta_options_source = json_data
if 'weights' in json_data:
# example: options = {"player1weights" : {<weightsdata>}}
options = json_data["weights"]
if "race" in json_data:
race = bool(0 if json_data["race"] in {"false"} else int(json_data["race"]))
hint_cost = int(meta_options_source.get("hint_cost", 10))
forfeit_mode = meta_options_source.get("forfeit_mode", "goal")
if not options:
return {"text": "No options found. Expected file attachment or json weights."
}, 400
@@ -48,7 +54,7 @@ def generate_api():
gen = Generation(
options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}),
# convert to json compatible
meta=json.dumps({"race": race}), state=STATE_QUEUED,
meta=json.dumps({"race": race, "hint_cost": hint_cost, "forfeit_mode": forfeit_mode}), state=STATE_QUEUED,
owner=session["_id"])
commit()
return {"text": f"Generation of seed {gen.id} started successfully.",

View File

@@ -11,7 +11,7 @@ import time
import random
import pickle
import Utils
from .models import *
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
@@ -48,7 +48,7 @@ class DBCommandProcessor(ServerCommandProcessor):
class WebHostContext(Context):
def __init__(self):
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", 0, 2)
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
self.main_loop = asyncio.get_running_loop()
self.video = {}
self.tags = ["AP", "WebHost"]
@@ -111,11 +111,7 @@ def run_server_process(room_id, ponyconfig: dict):
db.generate_mapping(check_tables=False)
async def main():
logging.basicConfig(format='[%(asctime)s] %(message)s',
level=logging.INFO,
handlers=[
logging.FileHandler(os.path.join(LOGS_FOLDER, f"{room_id}.txt"), 'a', 'utf-8-sig')])
Utils.init_logging(str(room_id), write_mode="a")
ctx = WebHostContext()
ctx.load(room_id)
ctx.init_save()

View File

@@ -1,10 +1,11 @@
from flask import send_file, Response, render_template
from pony.orm import select
from Patch import update_patch_data
from Patch import update_patch_data, preferred_endings
from WebHostLib import app, Slot, Room, Seed, cache
import zipfile
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
def download_patch(room_id, patch_id):
patch = Slot.get(id=patch_id)
@@ -19,7 +20,8 @@ def download_patch(room_id, patch_id):
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"
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
f"{preferred_endings[patch.game]}"
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
@@ -28,23 +30,6 @@ def download_spoiler(seed_id):
return Response(Seed.get(id=seed_id).spoiler, mimetype="text/plain")
@app.route("/dl_raw_patch/<suuid:seed_id>/<int:player_id>")
def download_raw_patch(seed_id, player_id: int):
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"
else:
import io
patch_data = update_patch_data(patch.data, server="")
patch_data = io.BytesIO(patch_data)
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:room_id>/<int:player_id>")
def download_slot_file(room_id, player_id: int):
room = Room.get(id=room_id)

View File

@@ -32,8 +32,7 @@ def create():
dictify_range=dictify_range, default_converter=default_converter,
)
if not os.path.isdir(os.path.join(target_folder, 'configs')):
os.mkdir(os.path.join(target_folder, 'configs'))
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
f.write(res)
@@ -81,8 +80,7 @@ def create():
player_settings["gameOptions"] = game_options
if not os.path.isdir(os.path.join(target_folder, 'player-settings')):
os.mkdir(os.path.join(target_folder, 'player-settings'))
os.makedirs(os.path.join(target_folder, 'player-settings'), exist_ok=True)
with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
f.write(json.dumps(player_settings, indent=2, separators=(',', ': ')))

View File

@@ -1,4 +1,4 @@
flask>=2.0.1
flask>=2.0.2
pony>=0.7.14
waitress>=2.0.0
flask-caching>=1.10.1

View File

@@ -22,7 +22,7 @@ player's game, they may find items which belong to the other player. If player A
player B, the item will be sent to player B's world over the internet.
This creates a cooperative experience during multi-world games, requiring players to rely upon each other to complete
their game. Currently, a maximum of 255 players can participate in a single multi-world.
their game.
## What happens if a person has to leave early?
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all

View File

@@ -0,0 +1,22 @@
# Minecraft
## Where is the settings page?
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
you need to configure and export a config file.
## What does randomization do to this game?
Recipes are removed from the crafting book and shuffled into the item pool. It can also optionally change which
structures appear in each dimension. Crafting recipes are re-learned when they are received from other players as
item checks, and occasionally when completing your own achievements.
## What is considered a location check in minecraft?
Location checks in are completed when the player completes various Minecraft achievements. Opening the advancements
menu in-game by pressing "L" will display outstanding achievements.
## When the player receives an item, what happens?
When the player receives an item in Minecraft, it either unlocks crafting recipes or puts items into the player's
inventory directly.
## What is the victory condition?
Victory is achieved when the player kills the Ender Dragon, enters the portal in The End, and completes the credits
sequence either by skipping it or watching hit play out.

View File

@@ -0,0 +1,36 @@
# Risk of Rain 2
## Where is the settings page?
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
you need to configure and export a config file.
## What does randomization do to this game?
Risk of Rain is already a random game, by virtue of being a roguelite. The Archipelago mod implements pure multiworld
functionality in which certain chests (made clear via a location check progress bar) will send an item out to the
multiworld. The items that _would have been_ in those chests will be returned to the Risk of Rain player via grants
by other players in other worlds.
## What Risk of Rain items can appear in other players' worlds?
The Risk of Rain items are:
* `Common Item` (White items)
* `Uncommon Item` (Green items)
* `Boss Item` (Yellow items)
* `Legendary Item` (Red items)
* `Lunar Item` (Blue items)
* `Equipment` (Orange items)
* `Dio's Best Friend` (Used if you set the YAML setting `total_revives_available` above `0`)
Each item grants you a random in-game item from the category it belongs to.
When an item is granted by another world to the Risk of Rain player (one of the items listed above) then a random
in-game item of that tier will appear in the Risk of Rain player's inventory. If the item grant is an `Equipment`
and the player already has an equipment item equipped then the _item that was equipped_ will be dropped on the ground
and _the new equipment_ will take it's place. (If you want the old one back, pick it up.)
## What does another world's item look like in Risk of Rain?
When the Risk of Rain player fills up their location check bar then the next spawned item will become an item grant for another
player's world. The item in Risk of Rain will disappear in a poof of smoke and the grant will automatically go out to the multiworld.
## What is the item pickup step?
The item pickup step is a YAML setting which allows you to set how many items you need to spawn before the _next_
item that is spawned disappears (in a poof of smoke) and goes out to the multiworld.

View File

@@ -0,0 +1,29 @@
# Secret of Evermore
## Where is the settings page?
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all options
necessary to configure and export a config file.
## What does randomization do to this game?
Items which would normally be acquired throughout the game have been moved around! Progression logic remains,
so the game is always able to be completed. However, because of the item shuffle, the player may need to access certain
areas before they would in the vanilla game. For example, the Windwalker (flying machine) is accessible as soon as any
weapon is obtained.
Additional help can be found in the [guide](https://github.com/black-sliver/evermizer/blob/feat-mw/guide.md).
## What items and locations get shuffled?
All gourds/chests/pots, boss drops and alchemists are shuffled. Alchemy ingredients, sniff spot items, call bead spells
and the dog can be randomized using yaml options.
## Which items can be in another player's world?
Any of the items which can be shuffled may also be placed in another player's world.
Specific items can be limited to your own world using plando.
## What does another world's item look like in Secret of Evermore?
Secret of Evermore will display "Sent an Item". Check the client output if you want to know which.
## What happens when the player receives an item?
When the player receives an item, a popup will appear to show which item was received. Items won't be recieved while a
script is active such as when visiting Nobilia Market or during most Boss Fights. Once all scripts have ended, items
will be recieved.

View File

@@ -0,0 +1,25 @@
# Super Metroid
## Where is the settings page?
The player settings page for this game is located <a href="../player-settings">here</a>. It contains all the options
you need to configure and export a config file.
## What does randomization do to this game?
Items which the player would normally acquire throughout the game have been moved around. Logic remains, so the game
is always able to be completed, but because of the item shuffle the player may need to access certain areas before
they would in the vanilla game.
## What items and locations get shuffled?
All power-ups and ammunition can be shuffled, and all locations in the game which could contain any of those items
may have their contents changed.
## Which items can be in another player's world?
Any of the items which can be shuffled may also be placed into another player's world. It is possible to choose to
limit certain items to your own world.
## What does another world's item look like in Super Metroid?
A unique item sprite has been added to the game to represent items belonging to another world.
## When the player receives an item, what happens?
When the player receives an item, a text box will appear to show which item was received, and from whom.

View File

@@ -4,7 +4,7 @@ window.addEventListener('load', () => {
gameName = document.getElementById('player-settings').getAttribute('data-game');
// Update game name on page
document.getElementById('game-name').innerHTML = gameName;
document.getElementById('game-name').innerText = gameName;
Promise.all([fetchSettingData()]).then((results) => {
let settingHash = localStorage.getItem(`${gameName}-hash`);

View File

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

View File

@@ -0,0 +1,32 @@
# Subnautica Randomizer Setup Guide
## Required Software
- [Subnautica](https://store.steampowered.com/app/264710/Subnautica/)
- [QModManager4](https://www.nexusmods.com/subnautica/mods/201)
- [Archipelago Mod for Subnautica](https://github.com/Berserker66/ArchipelagoSubnauticaModSrc/releases)
## Installation Procedures
1. Install QModManager4 as per its instructions.
2. The folder you installed QModManager4 into will now have a /QMods directory. It might appear after a start of Subnautica. You can also create this folder yourself.
3. Unpack the Archipelago Mod into this folder, so that Subnautica/QMods/Archipelago/ is a valid path.
4. Start Subnautica. You should see a Connect Menu in the topleft of your main Menu.
## Troubleshooting
If you don't see the connect window check that you see a qmodmanager_log-Subnautica.txt in Subnautica, if not QModManager4 is not correctly installed, otherwise open it and look for `[Info : BepInEx] Loading [Archipelago 1.0.0.0]`, version number doesn't matter. If it doesn't show this, then QModManager4 didn't find the Archipelago mod, so check your paths.
## Joining a MultiWorld Game
1. In Host, enter the address of the server, such as archipelago.gg:38281, your server host should be able to tell you this.
2. In Password enter the server password if one exists, otherwise leave blank.
3. In PlayerName enter your "name" field from the yaml, or website config.
4. Hit Connect. If it says succesfully authenticated you can now create a new savegame or resume the correct savegame.

View File

@@ -0,0 +1,180 @@
# Archipelago Plando Guide
## What is Plando?
The purposes of randomizers is to randomize the items in a game to give a new experience.
Plando takes this concept and changes it up by allowing you to plan out certain aspects of the game by placing certain
items in certain locations, certain bosses in certain rooms, edit text for certain NPCs/signs, or even force certain region
connections. Each of these options are going to be detailed separately as `item plando`, `boss plando`, `text plando`,
and `connection plando`. Every game in archipelago supports item plando but the other plando options are only supported
by certain games. Currently, Minecraft and LTTP both support connection plando, but only LTTP supports text and boss plando.
### Enabling Plando
On the website plando will already be enabled. If you will be generating the game locally plando features must be enabled (opt-in).
* To opt-in go to the archipelago installation (default: `C:\ProgramData\Archipelago`), open the host.yaml with a text
editor and find the `plando_options` key. The available plando modules can be enabled by adding them after this such as
`plando_options: bosses, items, texts, connections`.
## Item Plando
Item plando allows a player to place an item in a specific location or specific locations, place multiple items into
a list of specific locations both in their own game or in another player's game. **Note that there's a very good chance that
cross-game plando could very well be broken i.e. placing on of your items in someone else's world playing a different game.**
* The options for item plando are `from_pool`, `world`, `percentage`, `force`, and either item and location, or items and locations.
* `from_pool` determines if the item should be taken *from* the item pool or *added* to it. This can be true or false
and defaults to true if omitted.
* `world` is the target world to place the item in.
* It gets ignored if only one world is generated.
* Can be a number, name, true, false, or null. False is the default.
* If a number is used it targets that slot or player number in the multiworld.
* If a name is used it will target the world with that player name.
* If set to true it will be any player's world besides your own.
* If set to false it will target your own world.
* If set to null it will target a random world in the multiworld.
* `force` determines whether the generator will fail if the item can't be placed in the location can be true, false,
or silent. Silent is the default.
* If set to true the item must be placed and the generator will throw an error if it is unable to do so.
* If set to false the generator will log a warning if the placement can't be done but will still generate.
* If set to silent and the placement fails it will be ignored entirely.
* `percentage` is the percentage chance for the relevant block to trigger. This can be any value from 0 to 100 and if
omitted will default to 100.
* Single Placement is when you use a plando block to place a single item at a single location.
* `item` is the item you would like to place and `location` is the location to place it.
* Multi Placement uses a plando block to place multiple items in multiple locations until either list is exhausted.
* `items` defines the items to use and a number letting you place multiple of it.
* `locations` is a list of possible locations those items can be placed in.
* Using the multi placement method, placements are picked randomly.
### Available Items
* [A Link to the Past](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Items.py#L52)
* [Factorio Non-Progressive](https://wiki.factorio.com/Technologies) Note that these use the *internal names*. For example, `advanced-electronics`
* [Factorio Progressive](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/factorio/Technologies.py#L374)
* [Minecraft](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Items.py#L14)
* [Ocarina of Time](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/Items.py#L61)
* [Risk of Rain 2](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ror2/Items.py#L8)
* [Slay the Spire](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/spire/Items.py#L13)
* [Subnautica](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/subnautica/items.json)
* [Timespinner](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/timespinner/Items.py#L11)
### Available Locations
* [A Link to the Past](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Regions.py#L429)
* [Factorio](https://wiki.factorio.com/Technologies) Same as items
* [Minecraft](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Locations.py#L18)
* [Ocarina of Time](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/LocationList.py#L38)
* [Risk of Rain 2](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ror2/Locations.py#L17) This is a special
case. The locations are "ItemPickup[number]" up to the maximum set in the yaml.
* [Slay the Spire](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/spire/Locations.py)
* [Subnautica](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/subnautica/locations.json)
* [Timespinner](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/timespinner/Locations.py#L13)
A list of all available items and locations can also be found in the [server's datapackage](/api/datapackage).
### Examples
```yaml
plando_items:
# example block 1 - Timespinner
- item:
Empire Orb: 1
Radiant Orb: 1
location: Starter Chest 1
from_pool: true
world: true
percentage: 50
# example block 2 - Ocarina of Time
- items:
Kokiri Sword: 1
Biggoron Sword: 1
Bow: 1
Magic Meter: 1
Progressive Strength Upgrade: 3
Progressive Hookshot: 2
locations:
- Deku Tree Slingshot Chest
- Dodongos Cavern Bomb Bag Chest
- Jabu Jabus Belly Boomerang Chest
- Bottom of the Well Lens of Truth Chest
- Forest Temple Bow Chest
- Fire Temple Megaton Hammer Chest
- Water Temple Longshot Chest
- Shadow Temple Hover Boots Chest
- Spirit Temple Silver Gauntlets Chest
world: false
# example block 3 - Slay the Spire
- items:
Boss Relic: 3
locations:
Boss Relic 1
Boss Relic 2
Boss Relic 3
# example block 4 - Factorio
- items:
progressive-electric-energy-distribution: 2
electric-energy-accumulators: 1
progressive-turret: 2
locations:
military
gun-turret
logistic-science-pack
steel-processing
percentage: 80
force: true
```
1. This block has a 50% chance to occur, and if it does will place either the Empire Orb or Radiant Orb on another player's
Starter Chest 1 and removes the chosen item from the item pool.
2. This block will always trigger and will place the player's swords, bow, magic meter, strength upgrades, and hookshots
in their own dungeon major item chests.
3. This block will always trigger and will lock boss relics on the bosses.
4. This block has an 80% chance of occuring and when it does will place all but 1 of the items randomly among the four
locations chosen here.
## Boss Plando
As this is currently only supported by A Link to the Past instead of explaining here please refer to the
[relevant guide](/tutorial/zelda3/plando/en)
## Text Plando
As this is currently only supported by A Link to the Past instead of explaining here please refer to the
[relevant guide](/tutorial/zelda3/plando/en)
## Connections Plando
This is currently only supported by Minecraft and A Link to the Past. As the way that these games interact with
their connections is different I will only explain the basics here while more specifics for Link to the Past connection
plando can be found in its plando guide.
* The options for connections are `percentage`, `entrance`, `exit`, and `direction`. Each of these options support subweights.
* `percentage` is the percentage chance for this connection from 0 to 100 and defaults to 100.
* Every connection has an `entrance` and an `exit`. These can be unlinked like in A Link to the Past insanity entrance shuffle.
* `direction` can be `both`, `entrance`, or `exit` and determines in which direction this connection will operate.
[Link to the Past connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/EntranceShuffle.py#L3852)
[Minecraft connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/Regions.py#L62)
### Examples
```yaml
plando_connections:
# example block 1 - Link to the Past
- entrance: Cave Shop (Lake Hylia)
exit: Cave 45
direction: entrance
- entrance: Cave 45
exit: Cave Shop (Lake Hylia)
direction: entrance
- entrance: Agahnims Tower
exit: Old Man Cave Exit (West)
direction: exit
# example block 2 - Minecraft
- entrance: Overworld Structure 1
exit: Nether Fortress
direction: both
- entrance: Overworld Structure 2
exit: Village
direction: both
```
1. These connections are decoupled so going into the lake hylia cave shop will take you to the inside of cave 45 and
when you leave the interior you will exit to the cave 45 ledge. Going into the cave 45 entrance will then take you to the
lake hylia cave shop. Walking into the entrance for the old man cave and Agahnim's Tower entrance will both take you to
their locations as normal but leaving old man cave will exit at Agahnim's Tower.
2. This will force a nether fortress and a village to be the overworld structures for your game. Note that for the Minecraft
connection plando to work structure shuffle must be enabled.

View File

@@ -2,26 +2,66 @@
## Installing the Archipelago software
The most recent public release of Archipelago can be found [here](https://github.com/ArchipelagoMW/Archipelago/releases).
Run the exe file, and after accepting the license agreement you will be prompted on which components you would like to install. The generator allows you to generate multiworld games on your computer. The ROM setups are optional but are required if anyone in the game that you generate wants to play any of those games as they are needed to generate the relevant patch files. The server will allow you to host the multiworld on your machine but this also requires you to port forward. The default port for Archipelago is `38281` If you are unsure how to do this there are plenty of other guides on the internet that will be more suited to your hardware. The Clients are what you use to connect your game to the multiworld. If the game/games you plan to play are available here go ahead and install these as well. If the game you choose to play is supported by Archipelago but not listed check the relevant tutorial.
Run the exe file, and after accepting the license agreement you will be prompted on which components you would like to install.
The generator allows you to generate multiworld games on your computer. The ROM setups are optional but are required if
anyone in the game that you generate wants to play any of those games as they are needed to generate the relevant patch
files. The server will allow you to host the multiworld on your machine but this also requires you to port forward. The
default port for Archipelago is `38281`. If you are unsure how to do this there are plenty of other guides on the internet
that will be more suited to your hardware. The `Clients` are what you use to connect your game to the multiworld. If the
game/games you plan to play are available here go ahead and install these as well. If the game you choose to play is
supported by Archipelago but not listed in the installation check the relevant tutorial.
## Generating a game
### Gather all player YAMLS
All players that wish to play in the generated multiworld must have a YAML file which contains all of the settings that they wish to play with.
All players that wish to play in the generated multiworld must have a YAML file which contains the settings that they wish to play with.
A YAML is a file which contains human readable markup. In other words, this is a settings file kind of like an INI file or a TOML file.
Each player can go to the game's player settings page in order to determine the settings how they want them and then download a YAML file containing these settings.
After getting all of the YAML files these can all either be placed together in the `Archipelago\Players` folder or compressed into a ZIP folder to then be uploaded to the [website generator](/generate).
If rolling locally ensure that the folder is clear of any files you do not wish to include in the game such as the included default player settings files.
After getting the YAML files of each participant for your multiworld game, these can all either be placed together in the
`Archipelago\Players` folder or compressed into a ZIP folder to then be uploaded to the [website generator](/generate).
If rolling locally ensure that the folder is clear of any files you do not wish to include in the game such as the
included default player settings files.
#### Changing local host settings for generation
Sometimes there are various settings that you may want to change before rolling a seed such as enabling race mode,
auto-forfeit, plando support, or setting a password. All of these settings plus other options are able to be changed by
modifying the `host.yaml` file in the base `Archipelago` folder. The settings chosen here are baked into
the serverdata file that gets output with the other files after generation so if rolling locally ensure this file is edited
to your liking *before* rolling the seed.
### Rolling the seed
After gathering all of the YAML files together in the `Archipelago\Players` folder, run the program `ArchipelagoGenerate.exe` in the base `Archipelago` folder. This will then open a console window and either silently close itself or spit out an error. If you receive an error, it is likely due to an error in the YAML file. If the error is unhelpful in you figuring out the issue asking in the ***#tech-support*** channel of our Discord. The generator will put a zip folder into your `Archipelago\output` folder with the format `AP_XXXXXXXXX`.zip. This contains all of the patch files and relevant mods for the players as well as the serverdata for the host.
### Changing host settings
Sometimes there are various settings that you may want to change before rolling a seed such as enabling race mode, auto-forfeit, or set a password. All of these settings plus other options are able to be changed by modifying the `host.yaml` file in the base `Archipelago` folder.
#### On the Website
After gathering the YAML files together in one location, select all of the files and compress them into a .zip folder.
Next go to the [Start Playing](/start-playing) page and click on `generate a randomized game` to reach the website generator.
Here, you can adjust some server settings such as forfeit rules and the cost for a player to use a hint before generation.
After adjusting the host settings to your liking click on the Upload File button and using the explorer window that opens,
navigate to the location where you zipped the player files and upload this zip. The page will generate your game and refresh
multiple times to check on completion status. After the generation completes you will be on a Seed Info page that provides
the seed, the date/time of creation, a link to the spoiler log, if available, and links to any rooms created from this seed.
To begin playing, click on `Create New Room`, which will take you to the room page. From here you can navigate back to thse
Seed Info page or to the Tracker page. Sharing the link to this page with your friends will provide them with the
necessary info and files for them to connect to the multiworld.
#### Rolling using the generation program
After gathering the YAML files together in the `Archipelago\Players` folder, run the program `ArchipelagoGenerate.exe`
in the base `Archipelago` folder. This will then open a console window and either silently close itself or spit out an
error. If you receive an error, it is likely due to an error in the YAML file. If the error is unhelpful in figuring
out the issue asking in the ***#tech-support*** channel of our Discord for help with finding it is highly recommended.
The generator will put a zip folder into your `Archipelago\output` folder with the format `AP_XXXXXXXXX`.zip.
This contains the patch files and relevant mods for the players as well as the serverdata for the host.
## Hosting a multiworld
### Uploading the seed to the website
The easiest and most recommended method is to upload the zip file that you generated to the website [here](/uploads). This will give a page with the seed info and have a link to the spoiler if it exists. Click on Create New room and then share the link fo rhe room with the other players so that they can download their patches or mods. The room will also have a link to a Multiworld Tracker and tell you what the players need to connect to from their clients.
The easiest and most recommended method is to generate the game on the website which will allow you to create a private
room with all the necessary files you can share, as well as hosting the game and supporting item trackers for various games.
If for some reason the seed was rolled on a machine, then either the resulting zip file or the resulting `AP_XXXXX.archipelago`
inside the zip file can be uploaded to the [upload page](/uploads). This will give a page with the seed info and have a
link to the spoiler if it exists. Click on Create New room and then share the link for the room with the other players
so that they can download their patches or mods. The room will also have a link to a Multiworld Tracker and tell you
what the players need to connect to from their clients.
### Hosting a seed locally
For this we'll assume you have already port forwarding `38281` and have generated a seed that is still in the `outputs` folder. Next, you'll want to run `ArchipelagoServer.exe`. A window will open in order to open the multiworld data for the game. You can either use the generated zip folder or extract the .archipelago file and use it. If everything worked correctly the console window should tell you it's now hosting a game with the IP, port, and password that clients will need in order to connect.
Extract the patch and mod files then send those to your friends and you're done!
For this we'll assume you have already port forwarding `38281` and have generated a seed that is still in the `outputs`
folder. Next, you'll want to run `ArchipelagoServer.exe`. A window will open in order to open the multiworld data for the
game. You can either use the generated zip folder or extract the .archipelago file and use it. If everything worked correctly the console window should tell you it's now hosting a game with the IP, port, and password that clients will need in order to connect.
Extract the patch and mod files then send those to your friends, and you're done!

View File

@@ -0,0 +1,69 @@
# Archipelago Triggers Guide
## What are triggers?
Triggers allow you to customize your game settings by allowing you to define certain options or even a variety of
settings to occur or "trigger" under certain conditions. These are essentially "if, then statements" for options in your game.
A good example of what you can do with triggers is the custom
[mercenary mode](https://github.com/alwaysintreble/Archipelago-yaml-dump/blob/main/Snippets/Mercenary%20Mode%20Snippet.yaml)
that was created using entirely triggers and plando. For more information on plando you can reference
[this guide](/tutorial/archipelago/plando/en) or [this guide](/tutorial/zelda3/plando/en).
## Trigger use
Triggers have to be defined in the root of the yaml file meaning it must be outside of a game section.
The best place to do this is the bottom of the yaml.
- Triggers comprise of the trigger section and then each trigger must have an `option_category`, `option_name`, and
`option_result` from which it will react to and then an `options` section where the definition of what will happen.
- `option_category` is the defining section from which the option is defined in.
- Example: `A Link to the Past`
- This is the root category the option is located in. If the option you're triggering off of is in root then you
would use `null`, otherwise this is the game for which you want this option trigger to activate.
- `option_name` is the option setting from which the triggered choice is going to react to.
- Example: `shop_item_slots`
- This can be any option from any category defined in the yaml file in either root or a game section except for `game`.
- `option_result` is the result of this option setting from which you would like to react.
- Example: `15`
- Each trigger must be used for exactly one option result. If you would like the same thing to occur with multiple
results you would need multiple triggers for this.
- `options` is where you define what will happen when this is detected. This can be something as simple as ensuring
another option also gets selected or placing an item in a certain location.
- Example:
```yaml
A Link to the Past:
start_inventory:
Rupees (300): 2
```
This format must be:
```yaml
root option:
option to change:
desired result
```
### Examples
The above examples all together will end up looking like this:
```yaml
triggers:
- option_category: A Link to the Past
option_name: shop_item_slots
option_result: 15
options:
A Link to the Past:
start_inventory:
Rupees(300): 2
```
For this example if the generator happens to roll 15 shuffled in shop item slots for your game you'll be granted 600 rupees at the beginning.
These can also be used to change other options.
For example:
```yaml
triggers:
- option_category: Timespinner
option_name: SpecificKeycards
option_result: true
options:
Timespinner:
Inverted: true
```
In this example if your world happens to roll SpecificKeycards then your game will also start in inverted.

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

View File

@@ -1,52 +1,125 @@
# Factorio Randomizer Setup Guide
## Required Software
##### Players
- [Factorio](https://factorio.com) - Needed by Players and Hosts
### Server Host
- [Factorio](https://factorio.com)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
##### Server Hosts
- [Factorio](https://factorio.com) - Needed by Players and Hosts
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) - Needed by Hosts
### Players
- [Factorio](https://factorio.com)
## Create a Config (.yaml) File
## General Concept
### What is a config file and why do I need one?
Your config 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 config 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.
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
### Where do I get a config file?
The [Player Settings](/games/Factorio/player-settings) page on the website allows you to configure
your personal settings and export a config file from them.
### Verifying your config file
If you would like to validate your config file to make sure it works, you may do so on
the [YAML Validator](/mysterycheck) page.
## Connecting to Someone Else's Factorio Game
Connecting to someone else's game is the simplest way to play Factorio with Archipelago. It allows multiple
people to play in a single world, all contributing to the completion of the seed.
1. Acquire the Archipelago mod for this seed. It should be named `AP_*.zip`, where `*` is the seed number.
2. Copy the mod file into your Factorio `mods` folder, which by default is located at:
`C:\Users\YourName\AppData\Roaming\Factorio\mods`
3. Get the server address from the person hosting the game you are joining
4. Launch Factorio
5. Click on "Multiplayer" in the main menu
6. Click on "Connect to address"
7. Enter the address into this box
8. Click "Connect"
## Prepare to Host Your Own Factorio Game
### Defining Some Terms
In Archipelago, multiple Factorio worlds may be played simultaneously. Each of these worlds must be hosted by a
Factorio server, which is connected to the Archipelago Server via middleware.
This guide uses the following terms to refer to the software:
- **Factorio Client** - The Factorio instance which will be used to play the game.
- **Factorio Server** - The Factorio instance which will be used to host the Factorio world. Any number of
Factorio Clients may connect to this server.
- **Archipelago Client** - The middleware software used to connect the Factorio Server to the Archipelago Server.
- **Archipelago Server** - The central Archipelago server, which connects all games to each other.
### What a Playable State Looks Like
- An Archipelago Server
- The generated Factorio Mod, created as a result of running `ArchipelagoGenerate.exe`
- One running instance of `ArchipelagoFactorioClient.exe` (the Archipelago Client) per Factorio world
- A running modded Factorio Server, which should have been started by the Archipelago Client automatically
- A running modded Factorio Client
### Dedicated Server Setup
You need a dedicated isolated Factorio installation that the FactorioClient can take control over. If you intend to both host a world and play on the same device, you will need two separate Factorio installations; one for the FactorioClient to hook into and control, and one for you to play on.
The easiest and cheapest way to do so is to either buy or register a Factorio key on factorio.com, which allows you to download as many Factorio games as you want. If you own a steam copy already you can link your account on the website.
1. Download the latest Factorio from https://factorio.com/download for your system, for Windows the recommendation is "win64-manual".
To play Factorio with Archipelago, a dedicated server setup is required. This dedicated Factorio Server must be
installed separately from your main Factorio Client installation. The recommended way to install two instances
of Factorio on your computer is to download the Factorio installer file directly from
[factorio.com](https://factorio.com/download).
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. In this same folder if there are shortcuts named "mods" and "saves" delete these and replace with folders with the same names.
4. Navigate to where you installed ArchipelagoFactorioClient and open the host.yaml file as text. Find the entry `executable` under `factorio_options` and set it to point to your hosting Factorio.exe. If you put Factorio into your Archipelago folder, this would already match.<br>
ex.
#### If you purchased Factorio on Steam, GOG, etc.
You can register your copy of Factorio on [factorio.com](https://factorio.com/). You will be required to
create an account, if you have not done so already. As part of that process, you will be able to enter your
Factorio product code. This will allow you to download the game directly from their website.
#### Download the Standalone Version
It is recommended to download the standalone version of Factorio for use as a dedicated server. Doing so prevents
any potential conflicts with your currently-installed version of Factorio. Download the file by clicking on the
button appropriate to your operating system, and extract the folder to a convenient location (we recommend
C:\Factorio or similar).<br />
<img src="/static/assets/tutorial/factorio/factorio-download.png" />
Next, you should launch your Factorio Server by running `factorio.exe`, which is located at: `bin/x64/factorio.exe`.
You will be asked to log-in to your Factorio account using the same credentials you used on Factorio's website.
After you have logged in, you may close the game.
#### Configure your Archipelago Installation
You must modify your `host.yaml` file inside your Archipelago installation directory so that it points to your
standalone Factorio executable. Here is an example of the appropriate setup, note the double `\\` are required:
```yaml
factorio_options:
executable: C:\\Program Files\\factorio\\bin\\x64\\factorio"
executable: C:\\factorio\\bin\\x64\\factorio"
```
### Player Setup
- Manually install the AP mod for the correct world you want to join, then use Factorio's built-in multiplayer. If you're connecting to a FactorioClient on the same system you will connect to localhost
## Joining a MultiWorld Game
With all that complete, you are now able to...
1. Install the generated Factorio AP Mod (would be in /Mods after step 2 of Setup)
## Host Your Own Factorio Game
2. Run FactorioClient, it should launch a Factorio server, which you can control with /factorio <original factorio commands>,
1. Obtain the Factorio mod for this Archipelago seed. It should be named `AP_*.zip`, where `*` is the seed number.
2. Install the mod into your Factorio Server by copying the zip file into the `mods` folder.
3. Install the mod into your Factorio Client by copying the zip file into the `mods` folder, which is likely located
at `C:\Users\YourName\AppData\Roaming\Factorio\mods`.
4. Obtain the Archipelago Server address from the website's host room, or from the server host.
5. Run your Archipelago Client, which is named `ArchilepagoFactorioClient.exe`. This was installed along with
Archipelago if you chose to include it during the installation process.
6. Enter `/connect [server-address]` into the input box at the bottom of the Archipelago Client and press "Enter"
<br /><img src="/static/assets/tutorial/factorio/connect-to-ap-server.png" />
7. Launch your Factorio Client
8. Click on "Multiplayer" in the main menu
9. Click on "Connect to address"
10. Enter `localhost` into the server address box
11. Click "Connect"
* 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.
## Allowing Other People to Join Your Game
1. Ensure your Archipelago Client is running.
2. Ensure port `34197` is forwarded to the computer running the Archipelago Client.
3. Obtain your IP address by visiting [this website](https://whatismyip.com/).
4. Provide your IP address to anyone you want to join your game, and have them follow the steps for
"Connecting to Someone Else's Factorio Game" above.
* / 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.
## Troubleshooting
In case any problems should occur, the Archipelago Client will create a file `FactorioClient.txt` in the `/logs`.
The contents of this file may help you troubleshoot an issue on your own and is vital for requesting help from other
people in Archipelago.
## Additional Resources
- [Alternate Tutorial by Umenen](https://docs.google.com/document/d/1yZPAaXB-QcetD8FJsmsFrenAHO5V6Y2ctMAyIoT9jS4)
- [Factorio Speedrun Guide](https://www.youtube.com/watch?v=ExLrmK1c7tA)
- [Factorio Wiki](https://wiki.factorio.com/)

View File

@@ -0,0 +1,116 @@
# Secret of Evermore Setup Guide
## Required Software
- [SNI](https://github.com/alttpo/sni/releases) (included in Archipelago if already installed)
- Hardware or software capable of loading and playing SNES ROM files
- An emulator capable of connecting to SNI with ROM access
- [snes9x-rr win32.zip](https://github.com/gocha/snes9x-rr/releases) +
[socket.dll](http://www.nyo.fr/~skarsnik/socket.dll) +
[connector.lua](https://raw.githubusercontent.com/alttpo/sni/main/lua/Connector.lua)
- or [BizHawk](http://tasvideos.org/BizHawk.html)
- or [bsnes-plus-nwa](https://github.com/black-sliver/bsnes-plus)
- Or SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
- Your Secret of Evermore US ROM file, probably named `Secret of Evermore (USA).sfc`
## Create a Config (.yaml) File
### What is a config file and why do I need one?
Your config 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 config 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 config file?
The [Player Settings](/games/Secret%20of%20Evermore/player-settings) page on the website allows you to configure your
personal settings and export a config file from them.
### Verifying your config file
If you would like to validate your config file to make sure it works, you may do so on the
[YAML Validator](/mysterycheck) page.
## Generating a Single-Player Game
Stand-alone "Evermizer" has a way of balancing single-player games, but may not always be on par feature-wise.
Head over to [evermizer.com](https://evermizer.com) if you want to try the official stand-alone, otherwise read below.
1. Navigate to the [Player Settings](/games/Secret%20of%20Evermore/player-settings) page, configure your options, and
click the "Generate Game" button.
2. You will be presented with a "Seed Info" page.
3. Click the "Create New Room" link.
4. You will be presented with a server page, from which you can download your patch file.
5. Run your patch file through [apbpatch](https://evermizer.com/apbpatch) and load it in your emulator or console.
## Joining a MultiWorld Game
### Obtain your patch file and create your ROM
When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that
is done, the host will provide you with either a link to download your patch file, or with a zip file containing
everyone's patch files. Your patch file should have a `.apsoe` extension.
Put your patch file on your desktop or somewhere convenient, open [apbpatch](https://evermizer.com/apbpatch) and
generate your ROM from it. Load the ROM file in your emulator or console.
### Connect to SNI
#### With an emulator
Start SNI either from the Archipelago install folder or the stand-alone version.
If this is its first time launching, you may be prompted to allow it to communicate through the Windows Firewall.
##### snes9x-rr
1. Load your ROM file if it hasn't already been loaded.
2. Click on the File menu and hover on **Lua Scripting**
3. Click on **New Lua Script Window...**
4. In the new window, click **Browse...**
5. Select the `Connector.lua` file you downloaded above
6. If the script window complains about missing `socket.dll` make sure the DLL is in snes9x or the lua file's directory.
##### BizHawk
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following
these menu options:
`Config --> Cores --> SNES --> BSNES`
Once you have changed the loaded core, you must restart BizHawk.
2. Load your ROM file if it hasn't already been loaded.
3. Click on the Tools menu and click on **Lua Console**
4. Click the button to open a new Lua script.
5. Select the `Connector.lua` file you downloaded above
##### bsnes-plus-nwa
This should automatically connect to SNI.
If this is its first time launching, you may be prompted to allow it to communicate through the Windows Firewall.
#### With hardware
This guide assumes you have downloaded the correct firmware for your device. If you have not
done so already, please do this now. SD2SNES and FXPak Pro users may download the appropriate firmware
[here](https://github.com/RedGuyyyy/sd2snes/releases). Other hardware may find helpful information
[on this page](http://usb2snes.com/#supported-platforms).
1. Copy the ROM file to your SD card.
2. Load the ROM file from the menu.
### Open the client
Open [ap-soeclient](http://evermizer.com/apclient) in a modern browser. Do not switch tabs, open it in a new window
if you want to use the browser while playing. Do not minimize the window with the client.
The client should automatically connect to SNI, the "SNES" status should change to green.
### Connect to the Archipelago Server
Enter `/connect server:port` in the client's command prompt and press enter. You'll find `server:port` on the same page
that had the patch file.
### Play the game
When the game is loaded but not yet past the intro cutscene, the "Game" status is yellow. When the client shows "AP" as
green and "Game" as yellow, you're ready to play. The intro can be skipped pressing the START button and "Game" should
change to green. Congratulations on successfully joining a multiworld game!
## Hosting a MultiWorld game
The recommended way to host a game is to use our [hosting service](/generate). The process is relatively simple:
1. Collect config files from your players.
2. Create a zip file containing your players' config files.
3. Upload that zip file to the website linked above.
4. Wait a moment while the seed is generated.
5. When the seed is generated, you will be redirected to a "Seed Info" page.
6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players,
so they may download their patch files from there.
7. Note that a link to a MultiWorld Tracker is at the top of the room page. The tracker shows the progress of all
players in the game. Any observers may also be given the link to this page.
8. Once all players have joined, you may begin playing.

View File

@@ -0,0 +1,126 @@
# Super Metroid Setup Guide
## Required Software
- [Super Metroid Client](https://github.com/ArchipelagoMW/SuperMetroidClient/releases)
- **sniConnector.lua** (located on the client download page)
- [SNI](https://github.com/alttpo/sni/releases) (Included in the Super Metroid Client)
- Hardware or software capable of loading and playing SNES ROM files
- An emulator capable of connecting to SNI
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
[BizHawk](http://tasvideos.org/BizHawk.html))
- An SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
- Your Super Metroid ROM file, probably named `Super Metroid (Japan, USA).sfc`
## Installation Procedures
### Windows Setup
1. Download and install the Super Metroid Client 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**.
2. During setup, you will be asked to locate your base ROM file. This is your Super Metroid ROM file.
3. If you are using an emulator, you should assign your Lua capable emulator as your default program
for launching ROM files.
1. Extract your emulator's folder to your Desktop, or somewhere you will remember.
2. Right-click on a ROM file and select **Open with...**
3. Check the box next to **Always use this app to open .sfc files**
4. Scroll to the bottom of the list and click the grey text **Look for another App on this PC**
5. Browse for your emulator's `.exe` file and click **Open**. This file should be located inside
the folder you extracted in step one.
### Macintosh Setup
- We need volunteers to help fill this section! Please contact **Farrak Kilhn** on Discord if you want to help.
## Create a Config (.yaml) File
### What is a config file and why do I need one?
Your config 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 config 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 config file?
The [Player Settings](/games/Super%20Metroid/player-settings) page on the website allows you to configure your
personal settings and export a config file from them.
### Verifying your config file
If you would like to validate your config file to make sure it works, you may do so on the
[YAML Validator](/mysterycheck) page.
## Generating a Single-Player Game
1. Navigate to the [Player Settings](/games/Super%20Metroid/player-settings) page, configure your options, and click
the "Generate Game" button.
2. You will be presented with a "Seed Info" page.
3. Click the "Create New Room" link.
4. You will be presented with a server page, from which you can download your patch file.
5. Double-click on your patch file, and the Super Metroid Client will launch automatically, create your ROM from
the patch file, and open your emulator for you.
6. Since this is a single-player game, you will no longer need the client, so feel free to close it.
## Joining a MultiWorld Game
### Obtain your patch file and create your ROM
When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that
is done, the host will provide you with either a link to download your patch file, or with a zip file containing
everyone's patch files. Your patch file should have a `.apm3` extension.
Put your patch file on your desktop or somewhere convenient, and double click it. This should automatically
launch the client, and will also create your ROM in the same place as your patch file.
### Connect to the client
#### With an emulator
When the client launched automatically, SNI should have also automatically launched in the background.
If this is its first time launching, you may be prompted to allow it to communicate through the Windows
Firewall.
##### snes9x Multitroid
1. Load your ROM file if it hasn't already been loaded.
2. Click on the File menu and hover on **Lua Scripting**
3. Click on **New Lua Script Window...**
4. In the new window, click **Browse...**
5. Select the `sniConnector.lua` file you downloaded above
##### BizHawk
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following
these menu options:
`Config --> Cores --> SNES --> BSNES`
Once you have changed the loaded core, you must restart BizHawk.
2. Load your ROM file if it hasn't already been loaded.
3. Click on the Tools menu and click on **Lua Console**
4. Click the button to open a new Lua script.
5. Select the `sniConnector.lua` file you downloaded above
#### With hardware
This guide assumes you have downloaded the correct firmware for your device. If you have not
done so already, please do this now. SD2SNES and FXPak Pro users may download the appropriate firmware
[here](https://github.com/RedGuyyyy/sd2snes/releases). Other hardware may find helpful information
[on this page](http://usb2snes.com/#supported-platforms).
1. Close your emulator, which may have auto-launched.
2. Power on your device and load the ROM.
### Connect to the Archipelago Server
The patch file which launched your client should have automatically connected you to the AP Server.
There are a few reasons this may not happen however, including if the game is hosted on the website but
was generated elsewhere. If the client window shows "Server Status: Not Connected", simply ask the host
for the address of the server, and copy/paste it into the "Server" input field then press enter.
The client will attempt to reconnect to the new server address, and should momentarily show "Server
Status: Connected".
### Play the game
When the client shows both SNES Device and Server as connected, you're ready to begin playing. Congratulations
on successfully joining a multiworld game!
## Hosting a MultiWorld game
The recommended way to host a game is to use our [hosting service](/generate). The process is relatively simple:
1. Collect config files from your players.
2. Create a zip file containing your players' config files.
3. Upload that zip file to the website linked above.
4. Wait a moment while the seed is generated.
5. When the seed is generated, you will be redirected to a "Seed Info" page.
6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players,
so they may download their patch files from there.
7. Note that a link to a MultiWorld Tracker is at the top of the room page. The tracker shows the progress of all
players in the game. Any observers may also be given the link to this page.
8. Once all players have joined, you may begin playing.

View File

@@ -15,6 +15,34 @@
]
}
]
},
{
"name": "Archipelago Triggers Guide",
"description": "A Guide to setting up and using triggers in your game settings.",
"files": [
{
"language": "English",
"filename": "archipelago/triggers_en.md",
"link": "archipelago/triggers/en",
"authors": [
"alwaysintreble"
]
}
]
},
{
"name": "Archipelago Plando Guide",
"description": "A guide to understanding and using plando for your game.",
"files": [
{
"language": "English",
"filename": "archipelago/plando_en.md",
"link": "archipelago/plando/en",
"authors": [
"alwaysintreble"
]
}
]
}
]
},
@@ -144,7 +172,8 @@
"filename": "factorio/setup_en.md",
"link": "factorio/setup/en",
"authors": [
"Berserker"
"Berserker",
"Farrak Kilhn"
]
}
]
@@ -223,5 +252,62 @@
]
}
]
},
{
"gameTitle": "Subnautica",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Subnautica randomizer connected to an Archipelago Multiworld",
"files": [
{
"language": "English",
"filename": "Subnautica/setup_en.md",
"link": "Subnautica/setup/en",
"authors": [
"Berserker"
]
}
]
}
]
},
{
"gameTitle": "Super Metroid",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Super Metroid Client on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "super-metroid/multiworld_en.md",
"link": "super-metroid/multiworld/en",
"authors": [
"Farrak Kilhn"
]
}
]
}
]
},
{
"gameTitle": "Secret of Evermore",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to playing Secret of Evermore randomizer. This guide covers single-player, multiworld and related software.",
"files": [
{
"language": "English",
"filename": "secret-of-evermore/multiworld_en.md",
"link": "secret-of-evermore/multiworld/en",
"authors": [
"Black Sliver"
]
}
]
}
]
}
]

View File

@@ -1,14 +1,10 @@
# 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/mJKEHaiyR_Y" frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen>
</iframe>
</div>
## Required Software
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- [SNI](https://github.com/alttpo/sni/releases) (Included in Archipelago)
- [Z3Client](https://github.com/ArchipelagoMW/Z3Client/releases) or the SNIClient included with
[Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- If installing Archipelago, make sure to check the box for SNIClient -> A Link to the Past Patch Setup during install, or SNI will not be included
- [SNI](https://github.com/alttpo/sni/releases) (Included in both Z3Client and SNIClient)
- Hardware or software capable of loading and playing SNES ROM files
- An emulator capable of connecting to SNI
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
@@ -17,60 +13,54 @@
- Your Japanese v1.0 ROM file, probably named `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
## Installation Procedures
### Windows Setup
1. Download and install Archipelago 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.Archipelago.exe`
- 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
ROM file a second time.
- You may also be prompted to install Microsoft Visual C++. If you already have this software on your computer
(possibly because a Steam game installed it already), the installer will not prompt you to install it again.
1. Download and install your preferred client from the link above, making sure to install the most recent version.
**The installer file is located in the assets section at the bottom of the version information**.
- During setup, you will be asked to locate your base ROM file. This is your Japanese Link to the Past ROM file.
2. If you are using an emulator, you should assign your Lua capable emulator as your default program
for launching ROM files.
1. Extract your emulator's folder to your Desktop, or somewhere you will remember.
2. Right click on a ROM file and select **Open with...**
2. Right-click on a ROM file and select **Open with...**
3. Check the box next to **Always use this app to open .sfc files**
4. Scroll to the bottom of the list and click the grey text **Look for another App on this PC**
5. Browse for your emulator's `.exe` file and click **Open**. This file should be located inside
the folder you extracted in step one.
### Macintosh Setup
- We need volunteers to help fill this section! Please contact **Farrak Kilhn** on Discord if you want to help.
## Create a Config (.yaml) File
## 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
### What is a config file and why do I need one?
Your config 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 config 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?
The [Generate Game](/games/A Link to the Past/player-settings) page on the website allows you to configure your personal settings and
export a YAML file from them.
### Where do I get a config file?
The [Player Settings](/games/A%20Link%20to%20the%20Past/player-settings) page on the website allows you to configure
your personal settings and export a config file from them.
### Verifying your YAML file
If you would like to validate your YAML file to make sure it works, you may do so on the
### Verifying your config file
If you would like to validate your config file to make sure it works, you may do so on the
[YAML Validator](/mysterycheck) page.
## Generating a Single-Player Game
1. Navigate to the [Player Settings](/games/A%20Link%20to%20the%20Past/player-settings) page, configure your options, and click the "Generate Game" button.
2. You will be presented with a "Seed Info" page, where you can download your patch file.
3. Double-click on your patch file, and the emulator should launch with your game automatically. As the
Client is unnecessary for single player games, you may close it and the WebUI.
1. Navigate to the [Player Settings](/games/A%20Link%20to%20the%20Past/player-settings) page, configure your options,
and click the "Generate Game" button.
2. You will be presented with a "Seed Info" page.
3. Click the "Create New Room" link.
4. You will be presented with a server page, from which you can download your patch file.
5. Double-click on your patch file, and the Z3Client will launch automatically, create your ROM from
the patch file, and open your emulator for you.
6. Since this is a single-player game, you will no longer need the client, so feel free to close it.
## Joining a MultiWorld Game
### Obtain your patch file and create your ROM
When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that
When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that
is done, the host will provide you with either a link to download your patch file, or with a zip file containing
everyone's patch files. Your patch file should have a `.apbp` extension.
Put your patch file on your desktop or somewhere convenient, and double click it. This should automatically
launch the client, and will also create your ROM file in the same place as your patch file.
launch the client, and will also create your ROM in the same place as your patch file.
### Connect to the client
@@ -84,23 +74,21 @@ Firewall.
2. Click on the File menu and hover on **Lua Scripting**
3. Click on **New Lua Script Window...**
4. In the new window, click **Browse...**
5. Browse to the location you extracted snes9x Multitroid to, enter the `lua` folder, and choose `multibridge.lua`
6. Observe a name has been assigned to you, and that the client shows "SNES Device: Connected", with that same
name in the upper left corner.
5. Select the connector lua file included with your client
- Z3Client users should download `sniConnector.lua` from the client download page
- SNIClient users should look in their Archipelago folder for `/sni/Connector.lua`
##### BizHawk
1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following
these menu options:
these menu options:
`Config --> Cores --> SNES --> BSNES`
Once you have changed the loaded core, you must restart BizHawk.
2. Load your ROM file if it hasn't already been loaded.
3. Click on the Tools menu and click on **Lua Console**
4. Click the button to open a new Lua script.
5. Browse to your MultiWorld Utilities installation directory, and into the following directories:
`SNI`
6. Select `Connector.lua` and click Open.
7. Observe a name has been assigned to you, and that the client shows "SNES Device: Connected", with that same
name in the upper left corner.
5. Select the `sniConnector.lua` file you downloaded above
- Z3Client users should download `sniConnector.lua` from the client download page
- SNIClient users should look in their Archipelago folder for `/sni/Connector.lua`
#### With hardware
This guide assumes you have downloaded the correct firmware for your device. If you have not
@@ -110,54 +98,30 @@ done so already, please do this now. SD2SNES and FXPak Pro users may download th
1. Close your emulator, which may have auto-launched.
2. Power on your device and load the ROM.
3. Observe the client window now shows "SNES Device: Connected", and lists the name of your device.
### Connect to the MultiServer
The patch file which launched your client should have automatically connected you to the MultiServer.
### Connect to the Archipelago Server
The patch file which launched your client should have automatically connected you to the AP Server.
There are a few reasons this may not happen however, including if the game is hosted on the website but
was generated elsewhere. If the client window shows "Server Status: Not Connected", simply ask the host
for the address of the server, and copy/paste it into the "Server" input field then press enter.
The client will attempt to reconnect to the new server address, and should momentarily show "Server
Status: Connected". If the client does not connect after a few moments, you may need to refresh the page.
Status: Connected".
### Play the game
When the client shows both SNES Device and Server as connected, you're ready to begin playing. Congratulations
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](/generate). The process is relatively simple:
The recommended way to host a game is to use our [hosting service](/generate). The process is relatively simple:
1. Collect YAML files from your players.
2. Create a zip file containing your players' YAML files.
1. Collect config files from your players.
2. Create a zip file containing your players' config files.
3. Upload that zip file to the website linked above.
4. Wait a moment while the seed is generated.
5. When the seed is generated, you will be redirected to a "Seed Info" page.
6. Click "Create New Room". This will take you to the server page. Provide the link to this page to your players,
so they may download their patch files from there.
**Note:** The patch files provided on this page will allow players to automatically connect to the server,
while the patch files on the "Seed Info" page will not.
7. Note that a link to a MultiWorld Tracker is at the top of the room page. You should also provide this link
to your players, so they can watch the progress of the game. Any observers may also be given the link to
this page.
so they may download their patch files from there.
7. Note that a link to a MultiWorld Tracker is at the top of the room page. The tracker shows the progress of all
players in the game. Any observers may also be given the link to this page.
8. Once all players have joined, you may begin playing.
## Auto-Tracking
If you would like to use auto-tracking for your game, several pieces of software provide this functionality.
The recommended software for auto-tracking is currently
[OpenTracker](https://github.com/trippsc2/OpenTracker/releases).
### Installation
1. Download the appropriate installation file for your computer (Windows users want the `.msi` file).
2. During the installation process, you may be asked to install the Microsoft Visual Studio Build Tools. A link
to this software is provided during the installation procedure, and it must be installed manually.
### Enable auto-tracking
1. With OpenTracker launched, click the Tracking menu at the top of the window, then choose **AutoTracker...**
2. Click the **Get Devices** button
3. Select your SNES device from the drop-down list
4. If you would like to track small keys and dungeon items, check the box labeled **Race Illegal Tracking**
5. Click the **Start Autotracking** button
6. Close the AutoTracker window, as it is no longer necessary

View File

@@ -31,8 +31,8 @@
- Example: `simple`
- Runs a particular boss shuffle mode to finish construction instead of vanilla placement, typically used as
a last instruction.
- [Available Bosses](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Bosses.py#L135)
- [Available Arenas](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Bosses.py#L186)
- [Available Bosses](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Bosses.py#L135)
- [Available Arenas](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Bosses.py#L150)
#### Examples
```yaml
@@ -79,8 +79,8 @@ boss_shuffle:
- placements are picked randomly, not sorted in any way
- Warning: Placing non-Dungeon Prizes on Prize locations and
Prizes on non-Prize locations will break the game in various ways.
- [Available Items](https://github.com/Berserker66/MultiWorld-Utilities/blob/3b5ba161dea223b96e9b1fc890e03469d9c6eb59/Items.py#L26)
- [Available Locations](https://github.com/Berserker66/MultiWorld-Utilities/blob/3b5ba161dea223b96e9b1fc890e03469d9c6eb59/Regions.py#L418)
- [Available Items](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Items.py#L52)
- [Available Locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Regions.py#L434)
#### Examples
```yaml
@@ -133,10 +133,10 @@ Link's House and removes the picked item from the item pool.
- `\n` is a newline.
- `@` is the entered player's name.
- Warning: Text Mapper does not support full unicode.
- [Alphabet](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L756)
- [Alphabet](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L758)
- at is the location within the game to attach the text to.
- can be weighted.
- [List of targets](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L1498)
- [List of targets](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/Text.py#L1499)
#### Example
```yaml
@@ -160,7 +160,7 @@ This has a 50% chance to trigger at all. If it does, it throws a coin between `u
- entrance is the overworld door
- exit is the underworld exit
- direction can be `both`, `entrance` or `exit`
- doors can be found in [this file](https://github.com/Berserker66/MultiWorld-Utilities/blob/main/EntranceShuffle.py)
- doors can be found in [this file](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/EntranceShuffle.py#L3852)
#### Example

View File

@@ -28,7 +28,7 @@ can all have different options.
### Where do I get a YAML file?
A basic OOT yaml will look like this. (There are lots of cosmetic options that have been removed for the sake of this tutorial, if you want to see a complete list, download (Archipelago)[https://github.com/ArchipelagoMW/Archipelago/releases] and look for the sample file in the "Players" folder))
A basic OOT yaml will look like this. There are lots of cosmetic options that have been removed for the sake of this tutorial, if you want to see a complete list, download [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) and look for the sample file in the "Players" folder.
```yaml
description: Default Ocarina of Time Template # Used to describe your yaml. Useful if you have multiple files
# Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit

View File

@@ -1,12 +1,12 @@
{
"gameOptions": {
"description": {
"keyString": "description",
"friendlyName": "Description",
"inputType": "text",
"description": "A short description of this preset. Useful if you have multiple files",
"defaultValue": "Preset Name"
},
"keyString": "description",
"friendlyName": "Description",
"inputType": "text",
"description": "A short description of this preset. Useful if you have multiple files",
"defaultValue": "Preset Name"
},
"name": {
"keyString": "name",
"friendlyName": "Player Name",
@@ -476,8 +476,8 @@
}
}
},
"triforce_pieces_mode": {
"keyString": "triforce_pieces_mode",
"triforce_pieces_mode": {
"keyString": "triforce_pieces_mode",
"friendlyName": "Triforce Piece Availability Mode",
"description": "Determines which of the following three options will be used to determine the total available triforce pieces.",
"inputType": "range",
@@ -501,7 +501,7 @@
"defaultValue": 0
}
}
},
},
"triforce_pieces_available": {
"keyString": "triforce_pieces_available",
"friendlyName": "Exact Number (Triforce Hunt)",
@@ -534,7 +534,7 @@
}
}
},
"triforce_pieces_extra": {
"triforce_pieces_extra": {
"keyString": "triforce_pieces_extra",
"friendlyName": "Required Plus (Triforce Hunt)",
"description": "Only used if enabled in Triforce Piece Availability Mode.",
@@ -564,7 +564,7 @@
"description": "15 extra Triforce pieces will be hidden throughout Hyrule",
"defaultValue": 0
},
"20": {
"20": {
"keyString": "triforce_pieces_extra.20",
"friendlyName": 20,
"description": "20 extra Triforce pieces will be hidden throughout Hyrule",
@@ -572,7 +572,7 @@
}
}
},
"triforce_pieces_percentage": {
"triforce_pieces_percentage": {
"keyString": "triforce_pieces_percentage",
"friendlyName": "Percentage (Triforce Hunt)",
"description": "Only used if enabled in Triforce Piece Availability Mode.",
@@ -1164,41 +1164,79 @@
}
}
},
"beemizer": {
"keyString": "beemizer",
"friendlyName": "Beemizer",
"description": "Remove non-health items from the global item pool and replace them with single bees and bee traps.",
"beemizer_total_chance": {
"keyString": "beemizer_total_chance",
"friendlyName": "Beemizer - Total Chance",
"description": "Chance to replace junk-fill items in the global item pool with single bees and bee traps.",
"inputType": "range",
"subOptions": {
"0": {
"keyString": "beemizer.0",
"keyString": "beemizer_total_chance.0",
"friendlyName": "Level 0",
"description": "No bee traps are placed.",
"defaultValue": 50
},
"1": {
"keyString": "beemizer.1",
"25": {
"keyString": "beemizer_total_chance.25",
"friendlyName": "Level 1",
"description": "25% of rupees, bombs and arrows are replaced with bees, of which 60% are traps and 40% single bees",
"defaultValue": 1
"description": "25% chance for each junk-fill item (rupees, bombs and arrows) to be replaced with bees.",
"defaultValue": 0
},
"2": {
"keyString": "beemizer.2",
"50": {
"keyString": "beemizer_total_chance.50",
"friendlyName": "Level 2",
"description": "50% of rupees, bombs and arrows are replaced with bees, of which 70% are traps and 30% single bees",
"defaultValue": 2
"description": "50% chance for each junk-fill item (rupees, bombs and arrows) to be replaced with bees.",
"defaultValue": 0
},
"3": {
"keyString": "beemizer.3",
"75": {
"keyString": "beemizer_total_chance.75",
"friendlyName": "Level 3",
"description": "75% of rupees, bombs and arrows are replaced with bees, of which 80% are traps and 20% single bees",
"defaultValue": 3
"description": "75% chance for each junk-fill item (rupees, bombs and arrows) to be replaced with bees.",
"defaultValue": 0
},
"4": {
"keyString": "beemizer.4",
"100": {
"keyString": "beemizer_total_chance.100",
"friendlyName": "Level 4",
"description": "100% of rupees, bombs and arrows are replaced with bees, of which 90% are traps and 10% single bees",
"defaultValue": 4
"description": "All junk-fill items (rupees, bombs and arrows) are replaced with bees.",
"defaultValue": 0
}
}
},
"beemizer_trap_chance": {
"keyString": "beemizer_trap_chance",
"friendlyName": "Beemizer - Trap Chance",
"description": "Chance that replaced junk-fill items are bee traps.",
"inputType": "range",
"subOptions": {
"60": {
"keyString": "beemizer_trap_chance.60",
"friendlyName": "Level 0",
"description": "60% chance for each beemizer replacement to be a trap (40% chance of a single bee).",
"defaultValue": 50
},
"70": {
"keyString": "beemizer_trap_chance.70",
"friendlyName": "Level 1",
"description": "70% chance for each beemizer replacement to be a trap (30% chance of a single bee).",
"defaultValue": 0
},
"80": {
"keyString": "beemizer_trap_chance.80",
"friendlyName": "Level 2",
"description": "80% chance for each beemizer replacement to be a trap (20% chance of a single bee).",
"defaultValue": 0
},
"90": {
"keyString": "beemizer_trap_chance.90",
"friendlyName": "Level 3",
"description": "90% chance for each beemizer replacement to be a trap (10% chance of a single bee).",
"defaultValue": 0
},
"100": {
"keyString": "beemizer_trap_chance.100",
"friendlyName": "Level 4",
"description": "All beemizer replacements are traps (no single bees).",
"defaultValue": 0
}
}
},
@@ -1818,37 +1856,37 @@
"description": "Never use this. Makes all overworld palette colors black.",
"defaultValue": 0
},
"grayscale": {
"grayscale": {
"keyString": "rom.ow_palettes.grayscale",
"friendlyName": "Grayscale",
"description": "Removes all saturation of colors.",
"defaultValue": 0
},
"negative": {
"negative": {
"keyString": "rom.ow_palettes.negative",
"friendlyName": "Negative",
"description": "Invert all colors",
"defaultValue": 0
},
"classic": {
"classic": {
"keyString": "rom.ow_palettes.classic",
"friendlyName": "Classic",
"description": "Produces results similar to the website.",
"defaultValue": 0
},
"dizzy": {
"dizzy": {
"keyString": "rom.ow_palettes.dizzy",
"friendlyName": "Dizzy",
"description": "No logic in colors but saturation and lightness are conserved.",
"defaultValue": 0
},
"sick": {
"sick": {
"keyString": "rom.ow_palettes.sick",
"friendlyName": "Sick",
"description": "No logic in colors but lightness is conserved.",
"defaultValue": 0
},
"puke": {
"puke": {
"keyString": "rom.ow_palettes.puke",
"friendlyName": "Puke",
"description": "No logic at all.",
@@ -1880,37 +1918,37 @@
"description": "Never use this. Makes all underworld palette colors black.",
"defaultValue": 0
},
"grayscale": {
"grayscale": {
"keyString": "rom.uw_palettes.grayscale",
"friendlyName": "Grayscale",
"description": "Removes all saturation of colors.",
"defaultValue": 0
},
"negative": {
"negative": {
"keyString": "rom.uw_palettes.negative",
"friendlyName": "Negative",
"description": "Invert all colors",
"defaultValue": 0
},
"classic": {
"classic": {
"keyString": "rom.uw_palettes.classic",
"friendlyName": "Classic",
"description": "Produces results similar to the website.",
"defaultValue": 0
},
"dizzy": {
"dizzy": {
"keyString": "rom.uw_palettes.dizzy",
"friendlyName": "Dizzy",
"description": "No logic in colors but saturation and lightness are conserved.",
"defaultValue": 0
},
"sick": {
"sick": {
"keyString": "rom.uw_palettes.sick",
"friendlyName": "Sick",
"description": "No logic in colors but lightness is conserved.",
"defaultValue": 0
},
"puke": {
"puke": {
"keyString": "rom.uw_palettes.puke",
"friendlyName": "Puke",
"description": "No logic at all.",
@@ -1918,7 +1956,7 @@
}
}
},
"hud_palettes": {
"hud_palettes": {
"keyString": "rom.hud_palettes",
"friendlyName": "HUD Palettes",
"description": "Randomize the colors of the HUD (user interface), within reason.",
@@ -1942,37 +1980,37 @@
"description": "Never use this. Makes all HUD palette colors black.",
"defaultValue": 0
},
"grayscale": {
"grayscale": {
"keyString": "rom.hud_palettes.grayscale",
"friendlyName": "Grayscale",
"description": "Removes all saturation of colors.",
"defaultValue": 0
},
"negative": {
"negative": {
"keyString": "rom.hud_palettes.negative",
"friendlyName": "Negative",
"description": "Invert all colors",
"defaultValue": 0
},
"classic": {
"classic": {
"keyString": "rom.hud_palettes.classic",
"friendlyName": "Classic",
"description": "Produces results similar to the website.",
"defaultValue": 0
},
"dizzy": {
"dizzy": {
"keyString": "rom.hud_palettes.dizzy",
"friendlyName": "Dizzy",
"description": "No logic in colors but saturation and lightness are conserved.",
"defaultValue": 0
},
"sick": {
"sick": {
"keyString": "rom.hud_palettes.sick",
"friendlyName": "Sick",
"description": "No logic in colors but lightness is conserved.",
"defaultValue": 0
},
"puke": {
"puke": {
"keyString": "rom.hud_palettes.puke",
"friendlyName": "Puke",
"description": "No logic at all.",
@@ -1980,7 +2018,7 @@
}
}
},
"shield_palettes": {
"shield_palettes": {
"keyString": "rom.shield_palettes",
"friendlyName": "Shield Palettes",
"description": "Randomize the colors of the shield, within reason.",
@@ -2004,37 +2042,37 @@
"description": "Never use this. Makes all shield palette colors black.",
"defaultValue": 0
},
"grayscale": {
"grayscale": {
"keyString": "rom.shield_palettes.grayscale",
"friendlyName": "Grayscale",
"description": "Removes all saturation of colors.",
"defaultValue": 0
},
"negative": {
"negative": {
"keyString": "rom.shield_palettes.negative",
"friendlyName": "Negative",
"description": "Invert all colors",
"defaultValue": 0
},
"classic": {
"classic": {
"keyString": "rom.shield_palettes.classic",
"friendlyName": "Classic",
"description": "Produces results similar to the website.",
"defaultValue": 0
},
"dizzy": {
"dizzy": {
"keyString": "rom.shield_palettes.dizzy",
"friendlyName": "Dizzy",
"description": "No logic in colors but saturation and lightness are conserved.",
"defaultValue": 0
},
"sick": {
"sick": {
"keyString": "rom.shield_palettes.sick",
"friendlyName": "Sick",
"description": "No logic in colors but lightness is conserved.",
"defaultValue": 0
},
"puke": {
"puke": {
"keyString": "rom.shield_palettes.puke",
"friendlyName": "Puke",
"description": "No logic at all.",
@@ -2042,7 +2080,7 @@
}
}
},
"sword_palettes": {
"sword_palettes": {
"keyString": "rom.sword_palettes",
"friendlyName": "Sword Palettes",
"description": "Randomize the colors of the sword, within reason.",
@@ -2066,37 +2104,37 @@
"description": "Never use this. Makes all sword palette colors black.",
"defaultValue": 0
},
"grayscale": {
"grayscale": {
"keyString": "rom.sword_palettes.grayscale",
"friendlyName": "Grayscale",
"description": "Removes all saturation of colors.",
"defaultValue": 0
},
"negative": {
"negative": {
"keyString": "rom.sword_palettes.negative",
"friendlyName": "Negative",
"description": "Invert all colors",
"defaultValue": 0
},
"classic": {
"classic": {
"keyString": "rom.sword_palettes.classic",
"friendlyName": "Classic",
"description": "Produces results similar to the website.",
"defaultValue": 0
},
"dizzy": {
"dizzy": {
"keyString": "rom.sword_palettes.dizzy",
"friendlyName": "Dizzy",
"description": "No logic in colors but saturation and lightness are conserved.",
"defaultValue": 0
},
"sick": {
"sick": {
"keyString": "rom.sword_palettes.sick",
"friendlyName": "Sick",
"description": "No logic in colors but lightness is conserved.",
"defaultValue": 0
},
"puke": {
"puke": {
"keyString": "rom.sword_palettes.puke",
"friendlyName": "Puke",
"description": "No logic at all.",
@@ -2105,4 +2143,4 @@
}
}
}
}
}

View File

@@ -226,12 +226,19 @@ pot_shuffle:
'on': 0 # Keys, items, and buttons hidden under pots in dungeons are shuffled with other pots in their supertile
'off': 50 # Default pot item locations
### End of Enemizer Section ###
beemizer: # Remove items from the global item pool and replace them with single bees and bee traps
0: 50 # No bee traps are placed
1: 0 # 25% of rupees, bombs and arrows are replaced with bees, of which 60% are traps and 40% single bees
2: 0 # 50% of rupees, bombs and arrows are replaced with bees, of which 70% are traps and 30% single bees
3: 0 # 75% of rupees, bombs and arrows are replaced with bees, of which 80% are traps and 20% single bees
4: 0 # 100% of rupees, bombs and arrows are replaced with bees, of which 90% are traps and 10% single bees
# can add weights for any whole number between 0 and 100
beemizer_total_chance: # Remove items from the global item pool and replace them with single bees (fill bottles) and bee traps
0: 50 # No junk fill items are replaced (Beemizer is off)
25: 0 # 25% chance for each junk fill item (rupees, bombs and arrows) to be replaced with bees
50: 0 # 50% chance for each junk fill item (rupees, bombs and arrows) to be replaced with bees
75: 0 # 75% chance for each junk fill item (rupees, bombs and arrows) to be replaced with bees
100: 0 # All junk fill items (rupees, bombs and arrows) are replaced with bees
beemizer_trap_chance:
60: 50 # 60% chance for each beemizer replacement to be a trap, 40% chance to be a single bee
70: 0 # 70% chance for each beemizer replacement to be a trap, 30% chance to be a single bee
80: 0 # 80% chance for each beemizer replacement to be a trap, 20% chance to be a single bee
90: 0 # 90% chance for each beemizer replacement to be a trap, 10% chance to be a single bee
100: 0 # All beemizer replacements are traps
### Shop Settings ###
shop_shuffle_slots: # Maximum amount of shop slots to be filled with regular item pool items (such as Moon Pearl)
0: 50

View File

@@ -80,12 +80,18 @@
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.5);
}
.markdown ul{
.markdown h4, .markdown h5,.markdown h6{
margin-bottom: 0.5rem;
}
.markdown ul{
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.markdown ol{
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.markdown li{

View File

@@ -0,0 +1,101 @@
#player-tracker-wrapper{
margin: 0;
}
#inventory-table{
border-top: 2px solid #000000;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
width: 384px;
background-color: #8d60a7;
}
#inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
#inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
filter: grayscale(100%) contrast(75%) brightness(30%);
}
#inventory-table img.acquired{
filter: none;
}
#inventory-table div.counted-item {
position: relative;
}
#inventory-table div.item-count {
position: absolute;
color: white;
font-family: "Minecraftia", monospace;
font-weight: bold;
bottom: 0px;
right: 0px;
}
#location-table{
width: 384px;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: #8d60a7;
padding: 0 3px 3px;
font-size: 14px;
cursor: default;
}
#location-table th{
vertical-align: middle;
text-align: left;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
line-height: 20px;
}
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header {
font-weight: bold;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}
#location-table tbody.locations {
font-size: 12px;
}
#location-table td.location-name {
padding-left: 16px;
}
.hide {
display: none;
}

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/timespinnerTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/timespinnerTracker.js') }}"></script>
</head>
<body>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ icons['Timespinner Wheel'] }}" class="{{ 'acquired' if 'Timespinner Wheel' in acquired_items }}" title="Timespinner Wheel" /></td>
<td><img src="{{ icons['Timespinner Spindle'] }}" class="{{ 'acquired' if 'Timespinner Spindle' in acquired_items }}" title="Timespinner Spindle" /></td>
<td><img src="{{ icons['Timespinner Gear 1'] }}" class="{{ 'acquired' if 'Timespinner Gear 1' in acquired_items }}" title="Timespinner Gear 1" /></td>
<td><img src="{{ icons['Timespinner Gear 2'] }}" class="{{ 'acquired' if 'Timespinner Gear 2' in acquired_items }}" title="Timespinner Gear 2" /></td>
<td><img src="{{ icons['Timespinner Gear 3'] }}" class="{{ 'acquired' if 'Timespinner Gear 3' in acquired_items }}" title="Timespinner Gear 3" /></td>
</tr>
<tr>
<td><img src="{{ icons['Talaria Attachment'] }}" class="{{ 'acquired' if 'Talaria Attachment' in acquired_items }}" title="Talaria Attachment" /></td>
<td><img src="{{ icons['Succubus Hairpin'] }}" class="{{ 'acquired' if 'Succubus Hairpin' in acquired_items }}" title="Succubus Hairpin" /></td>
<td><img src="{{ icons['Lightwall'] }}" class="{{ 'acquired' if 'Lightwall' in acquired_items }}" title="Lightwall" /></td>
<td><img src="{{ icons['Celestial Sash'] }}" class="{{ 'acquired' if 'Celestial Sash' in acquired_items }}" title="Celestial Sash" /></td>
<td><img src="{{ icons['Twin Pyramid Key'] }}" class="{{ 'acquired' if 'Twin Pyramid Key' in acquired_items }}" title="Twin Pyramid Key" /></td>
</tr>
<tr>
<td><img src="{{ icons['Security Keycard A'] }}" class="{{ 'acquired' if 'Security Keycard A' in acquired_items }}" title="Security Keycard A" /></td>
<td><img src="{{ icons['Security Keycard B'] }}" class="{{ 'acquired' if 'Security Keycard B' in acquired_items }}" title="Security Keycard B" /></td>
<td><img src="{{ icons['Security Keycard C'] }}" class="{{ 'acquired' if 'Security Keycard C' in acquired_items }}" title="Security Keycard C" /></td>
<td><img src="{{ icons['Security Keycard D'] }}" class="{{ 'acquired' if 'Security Keycard D' in acquired_items }}" title="Security Keycard D" /></td>
<td><img src="{{ icons['Library Keycard V'] }}" class="{{ 'acquired' if 'Library Keycard V' in acquired_items }}" title="Library Keycard V" /></td>
</tr>
<tr>
<td><img src="{{ icons['Tablet'] }}" class="{{ 'acquired' if 'Tablet' in acquired_items }}" title="Tablet" /></td>
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
<td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td>
<td><img src="{{ icons['Water Mask'] }}" class="{{ 'acquired' if 'Water Mask' in acquired_items }}" title="Water Mask" /></td>
<td><img src="{{ icons['Gas Mask'] }}" class="{{ 'acquired' if 'Gas Mask' in acquired_items }}" title="Gas Mask" /></td>
</tr>
<tr>
{% if 'Fire Orb' in acquired_items %}
<td><img src="{{ icons['Fire Orb'] }}" class="acquired" title="Fire Orb" /></td>
{% elif 'Infernal Flames' in acquired_items %}
<td><img src="{{ icons['Infernal Flames'] }}" class="acquired" title="Infernal Flames" /></td>
{% elif 'Pyro Ring' in acquired_items %}
<td><img src="{{ icons['Pyro Ring'] }}" class="acquired" title="Pyro Ring" /></td>
{% elif 'Pyro Ring' in acquired_items %}
<td><img src="{{ icons['Djinn Inferno'] }}" class="acquired" title="Djinn Inferno" /></td>
{% else %}
<td><img src="{{ icons['Fire Orb'] }}" title="Fire Orb" /></td>
{% endif %}
{% if 'Plasma Orb' in acquired_items %}
<td><img src="{{ icons['Plasma Orb'] }}" class="acquired" title="Plasma Orb" /></td>
{% elif 'Plasma Geyser' in acquired_items %}
<td><img src="{{ icons['Plasma Geyser'] }}" class="acquired" title="Plasma Geyser" /></td>
{% elif 'Royal Ring' in acquired_items %}
<td><img src="{{ icons['Royal Ring'] }}" class="acquired" title="Royal Ring" /></td>
{% else %}
<td><img src="{{ icons['Plasma Orb'] }}" title="Plasma Orb" /></td>
{% endif %}
</tr>
</table>
<table id="location-table">
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
</tr>
<tbody class="locations hide" id="{{area}}">
{% for location in location_info[area] %}
<tr>
<td class="location-name">{{ location }}</td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -28,34 +28,16 @@
<td><a href="{{ url_for("download_spoiler", seed_id=seed.id) }}">Download</a></td>
</tr>
{% endif %}
{% if seed.multidata %}
<tr>
<td>Rooms:&nbsp;</td>
<td>
{% call macros.list_rooms(rooms) %}
<li>
<a href="{{ url_for("new_room", seed=seed.id) }}">Create New Room</a>
</li>
{% endcall %}
</td>
</tr>
{% else %}
<tr>
<td>Files:&nbsp;</td>
<td>
<ul>
{% for slot in seed.slots %}
<td>Rooms:&nbsp;</td>
<td>
{% call macros.list_rooms(rooms) %}
<li>
<a href="{{ url_for("download_raw_patch", seed_id=seed.id, player_id=slot.player_id) }}">Player {{ slot.player_name }}</a>
<a href="{{ url_for("new_room", seed=seed.id) }}">Create New Room</a>
</li>
{% endfor %}
</ul>
{% endcall %}
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,7 @@ from pony.orm import flush, select
from WebHostLib import app, Seed, Room, Slot
from Utils import parse_yaml
accepted_zip_contents = {"patches": ".apbp",
"spoiler": ".txt",
"multidata": ".archipelago"}
from Patch import preferred_endings
banned_zip_contents = (".sfc",)
@@ -29,15 +26,17 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted."
elif file.filename.endswith(".apbp"):
elif file.filename.endswith(tuple(preferred_endings.values())):
data = zfile.open(file, "r").read()
yaml_data = parse_yaml(lzma.decompress(data).decode("utf-8-sig"))
if yaml_data["version"] < 2:
return "Old format cannot be uploaded (outdated .apbp)", 500
return "Old format cannot be uploaded (outdated .apbp)"
metadata = yaml_data["meta"]
slots.add(Slot(data=data, player_name=metadata["player_name"],
slots.add(Slot(data=data,
player_name=metadata["player_name"],
player_id=metadata["player_id"],
game="A Link to the Past"))
game=yaml_data["game"]))
elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read()
@@ -48,7 +47,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
elif file.filename.endswith(".zip"):
# Factorio mods need a specific name or they do not function
_, seed_name, slot_id, slot_name = file.filename.rsplit("_", 1)[0].split("-")
_, seed_name, slot_id, slot_name = file.filename.rsplit("_", 1)[0].split("-", 3)
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="Factorio"))
@@ -112,7 +111,7 @@ def uploads():
flush() # place into DB and generate ids
return redirect(url_for("viewSeed", seed=seed.id))
else:
flash("Not recognized file format. Awaiting a .multidata file.")
flash("Not recognized file format. Awaiting a .archipelago file or .zip containing one.")
return render_template("hostGame.html")

Binary file not shown.

View File

@@ -22,4 +22,21 @@
size_hint_y: None
height: self.minimum_height
orientation: 'vertical'
spacing: dp(3)
spacing: dp(3)
<ServerLabel>:
text: "Server:"
size_hint_x: None
<ContainerLayout>:
size_hint_x: 1
size_hint_y: 1
pos: (0, 0)
<ServerToolTip>:
size: self.texture_size
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
halign: "left"
canvas.before:
Color:
rgba: 0.2, 0.2, 0.2, 1
Rectangle:
size: self.size
pos: self.pos

645
docs/api.md Normal file
View File

@@ -0,0 +1,645 @@
# Archipelago API
This document tries to explain some internals required to implement a game for
Archipelago's generation and server. Once a seed is generated, a client or mod is
required to send and receive items between the game and server.
Client implementation is out of scope of this document. Please refer to an
existing game that provides a similar API to yours.
Refer to the following documents as well:
* [network protocol.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/network%20protocol.md)
* [adding games.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/adding%20games.md)
Archipelago will be abbreviated as "AP" from now on.
## Language
AP worlds are written in python3.
Clients that connect to the server to sync items can be in any language that
allows using WebSockets.
## Coding style
AP follows all the PEPs. When in doubt use an IDE with coding style
linter, for example PyCharm Community Edition.
## Docstrings
Docstrings are strings attached to an object in Python that describe what the
object is supposed to be. Certain docstrings will be picked up and used by AP.
They are assigned by writing a string without any assignment right below a
definition. The string must be a triple-quoted string.
Example:
```python
from worlds.AutoWorld import World
class MyGameWorld(World):
"""This is the description of My Game that will be displayed on the AP
website."""
```
## Definitions
### World Class
A `World` class is the class with all the specifics of a certain game to be
included. It will be instantiated for each player that rolls a seed for that
game.
### MultiWorld Object
The `MultiWorld` object references the whole multiworld (all items and locations
for all players) and is accessible through `self.world` inside a `World` object.
### Player
The player is just an integer in AP and is accessible through `self.player`
inside a World object.
### Player Options
Players provide customized settings for their World in the form of yamls.
Those are accessible through `self.world.<option_name>[self.player]`. A dict
of valid options has to be provided in `self.options`. Options are automatically
added to the `World` object for easy access.
### World Options
Any AP installation can provide settings for a world, for example a ROM file,
accessible through `Utils.get_options()['<world>_options']['<option>']`.
Users can set those in their `host.yaml` file.
### Locations
Locations are places where items can be located in your game. This may be chests
or boss drops for RPG-like games but could also be progress in a research tree.
Each location has a `name` and an `id` (a.k.a. "code" or "address"), is placed
in a Region and has access rules.
The name needs to be unique in each game, the ID needs to be unique across all
games and is best in the same range as the item IDs.
Special locations with ID `None` can hold events.
### Items
Items are all things that can "drop" for your game. This may be RPG items like
weapons, could as well be technologies you normally research in a research tree.
Each item has a `name`, an `id` (can be known as "code"), and an `advancement`
flag. An advancement item is an item which a player may require to advance in
their world. Advancement items will be assigned to locations with higher
priority and moved around to meet defined rules and accomplish progression
balancing.
Special items with ID `None` can mark events (read below).
### Events
Events will mark some progress. You define an event location, an
event item, strap some rules to the location (i.e. hold certain
items) and manually place the event item at the event location.
Events can be used to either simplify the logic or to get better spoiler logs.
Events will show up in the spoiler playthrough but they do not represent actual
items or locations within the game.
There is one special case for events: Victory. To get the win condition to show
up in the spoiler log, you create an event item and place it at an event
location with the `access_rules` for game completion. Once that's done, the
world's win condition can be as simple as checking for that item.
By convention the victory event is called `"Victory"`. It can be placed at one
or more event locations based on player options.
### Regions
Regions are logical groups of locations that share some common access rules. If
location logic is written from scratch, using regions greatly simplifies the
definition and allow to somewhat easily implement things like entrance
randomizer in logic.
Regions have a list called `exits` which are `Entrance` objects representing
transitions to other regions.
There has to be one special region "Menu" from which the logic unfolds. AP
assumes that a player will always be able to return to the "Menu" region by
resetting the game ("Save and quit").
### Entrances
An `Entrance` connects to a region, is assigned to region's exits and has rules
to define if it and thus the connected region is accessible.
They can be static (regular logic) or be defined/connected during generation
(entrance randomizer).
### Access Rules
An access rule is a function that returns `True` or `False` for a `Location` or
`Entrance` based on the the current `state` (items that can be collected).
### Item Rules
An item rule is a function that returns `True` or `False` for a `Location` based
on a single item. It can be used to reject placement of an item there.
## Implementation
### Your World
All code for your world implementation should be placed in a python package in
the `/worlds` directory. The starting point for the package is `__init.py__`.
Conventionally, your world class is placed in that file.
World classes must inherit from the `World` class in `/worlds/AutoWorld.py`,
which can be imported as `..AutoWorld.World` from your package.
AP will pick up your world automatically due to the `AutoWorld` implementation.
### Requirements
If your world needs specific python packages, they can be listed in
`world/[world_name]/requirements.txt`.
See [pip documentation](https://pip.pypa.io/en/stable/cli/pip_install/#requirements-file-format)
### Relative Imports
AP will only import the `__init__.py`. Depending on code size it makes sense to
use multiple files and use relative imports to access them.
e.g. `from .Options import mygame_options` from your `__init__.py` will load
`world/[world_name]/Options.py` and make its `mygame_options` accesible.
When imported names pile up it may be easier to use `from . import Options`
and access the variable as `Options.mygame_options`.
### Your Item Type
Each world uses its own subclass of `BaseClasses.Item`. The constuctor can be
overridden to attach additional data to it, e.g. "price in shop".
Since the constructor is only ever called from your code, you can add whatever
arguments you like to the constructor.
In its simplest form we only set the game name and use the default constuctor
```python
from BaseClasses import Item
class MyGameItem(Item):
game: str = "My Game"
```
By convention this class definition will either be placed in your `__init__.py`
or your `Items.py`. For a more elaborate example see `worlds/oot/Items.py`.
### Your location type
The same we have done for items above, we will do for locations
```python
from BaseClasses import Location
class MyGameLocation(Location):
game: str = "My Game"
# override constructor to automatically mark event locations as such
def __init__(self, player: int, name = '', code = None, parent = None):
super(MyGameLocation, self).__init__(player, name, code, parent)
self.event = code is None
```
in your `__init__.py` or your `Locations.py`.
### Options
By convention options are defined in `Options.py` and will be used when parsing
the players' yaml files.
Each option has its own class, inherits from a base option type, has a docstring
to describe it and a `displayname` property for display on the website and in
spoiler logs.
The actual name as used in the yaml is defined in a `dict[str, Option]`, that is
assigned to the world under `self.options`.
Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`.
For more see `Options.py` in AP's base directory.
#### Toggle, DefaultOnToggle
Those don't need any additional properties defined. After parsing the option,
its `value` will either be True or False.
#### Range
Define properties `range_start`, `range_end` and `default`. Ranges will be
displayed as sliders on the website and can be set to random in the yaml.
#### Choice
Choices are like toggles, but have more options than just True and False.
Define a property `option_<name> = <number>` per selectable value and
`default = <number>` to set the default selection. Aliases can be set by
defining a property `alias_<name> = <same number>`.
One special case where aliases are required is when option name is `yes`, `no`,
`on` or `off` because they parse to `True` or `False`:
```python
option_off = 0
option_on = 1
option_some = 2
alias_false = 0
alias_true = 1
default = 0
```
#### Sample
```python
# Options.py
from Options import Toggle, Range, Choice, Option
import typing
class Difficulty(Choice):
"""Sets overall game difficulty."""
displayname = "Difficulty"
option_easy = 0
option_normal = 1
option_hard = 2
alias_beginner = 0 # same as easy
alias_expert = 2 # same as hard
default = 1 # default to normal
class FinalBossHP(Range):
"""Sets the HP of the final boss"""
displayname = "Final Boss HP"
range_start = 100
range_end = 10000
default = 2000
class FixXYZGlitch(Toggle):
"""Fixes ABC when you do XYZ"""
displayname = "Fix XYZ Glitch"
# By convention we call the options dict variable `<world>_options`.
mygame_options: typing.Dict[str, type(Option)] = {
"difficulty": Difficulty,
"final_boss_hp": FinalBossHP,
"fix_xyz_glitch": FixXYZGlitch
}
```
```python
# __init__.py
from ..AutoWorld import World
from .Options import mygame_options # import the options dict
class MyGameWorld(World):
#...
options = mygame_options # assign the options dict to the world
#...
```
### Local or Remote
A world with `remote_items` set to `True` gets all items items from the server
and no item from the local game. So for an RPG opening a chest would not add
any item to your inventory, instead the server will send you what was in that
chest. The advantage is that a generic mod can be used that does not need to
know anything about the seed.
A world with `remote_items` set to `False` will locally reward its local items.
For console games this can remove delay and make script/animation/dialog flow
more natural. These games typically have been edited to 'bake in' the items.
### A World Class Skeleton
```python
# world/mygame/__init__.py
from .Options import mygame_options # the options we defined earlier
from .Items import mygame_items # data used below to add items to the World
from .Locations import mygame_locations # same as above
from ..AutoWorld import World
from BaseClasses import Region, Location, Entrance, Item
from Utils import get_options, output_path
class MyGameItem(Item): # or from Items import MyGameItem
game = "My Game" # name of the game/world this item is from
class MyGameLocation(Location): # or from Locations import MyGameLocation
game = "My Game" # name of the game/world this location is in
class MyGameWorld(World):
"""Insert description of the world/game here."""
game: str = "My Game" # name of the game/world
options = mygame_options # options the player can set
topology_present: bool = True # show path to required location checks in spoiler
remote_items: bool = False # True if all items come from the server
remote_start_inventory: bool = False # True if start inventory comes from the server
# data_version is used to signal that items, locations or their names
# changed. Set this to 0 during development so other games' clients do not
# cache any texts, then increase by 1 for each release that makes changes.
data_version = 0
# ID of first item and location, could be hard-coded but code may be easier
# to read with this as a propery.
base_id = 1234
# Instead of dynamic numbering, IDs could be part of data.
# The following two dicts are required for the generation to know which
# items exist. They could be generated from json or something else. They can
# include events, but don't have to since events will be placed manually.
item_name_to_id = {name: id for
id, name in enumerate(mygame_items, base_id)}
location_name_to_id = {name: id for
id, name in enumerate(mygame_locations, base_id)}
# Items can be grouped using their names to allow easy checking if any item
# from that group has been collected. Group names can also be used for !hint
item_name_groups = {
"weapons": {"sword", "lance"}
}
```
### Generation
The world has to provide the following things for generation
* the properties mentioned above
* additions to the item pool
* additions to the regions list: at least one called "Menu"
* locations placed inside those regions
* a `def create_item(self, item: str) -> MyGameItem` for plando/manual placing
* applying `self.world.precollected_items` for plando/start inventory
if not using a `remote_start_inventory`
* a `def generate_output(self, output_directory: str)` that creates the output
if there is output to be generated. If only items are randomized and
`remote_items = True` it is possible to have a generic mod and output
generation can be skipped. In all other cases this is required. When this is
called, `self.world.get_locations()` has all locations for all players, with
properties `item` pointing to the item and `player` identifying the player.
`self.world.get_filled_locations(self.player)` will filter for this world.
`item.player` can be used to see if it's a local item.
In addition the following methods can be implemented
* `def generate_early(self)`
called per player before any items or locations are created. You can set
properties on your world here. Already has access to player options and RNG.
* `def create_regions(self)`
called to place player's regions into the MultiWorld's regions list. If it's
hard to separate, this can be done during `generate_early` or `basic` as well.
* `def create_items(self)`
called to place player's items into the MultiWorld's itempool.
* `def set_rules(self)`
called to set access and item rules on locations and entrances.
* `def generate_basic(self)`
called after the previous steps. Some placement and player specific
randomizations can be done here. After this step all regions and items have
to be in the MultiWorld's regions and itempool.
* `pre_fill`, `fill_hook` and `post_fill` are called to modify item placement
before, during and after the regular fill process, before `generate_output`.
* `fill_slot_data` and `modify_multidata` can be used to modify the data that
will be used by the server to host the MultiWorld.
* `def get_required_client_version(self)`
can return a tuple of 3 ints to make sure the client is compatible to this
world (e.g. item IDs) when connecting.
#### generate_early
```python
def generate_early(self):
# read player settings to world instance
self.final_boss_hp = self.world.final_boss_hp[self.player].value
```
#### create_item
```python
# we need a way to know if an item provides progress in the game ("key item")
# this can be part of the items definition, or depend on recipe randomization
from .Items import is_progression # this is just a dummy
def create_item(self, item: str):
# This is called when AP wants to create an item by name (for plando) or
# when you call it from your own code.
return MyGameItem(item, is_progression(item), self.item_name_to_id[item],
self.player)
def create_event(self, event: str):
# while we are at it, we can also add a helper to create events
return MyGameItem(event, True, None, self.player)
```
#### create_items
```python
def create_items(self):
# Add items to the Multiworld.
# If there are two of the same item, the item has to be twice in the pool.
# Which items are added to the pool may depend on player settings,
# e.g. custom win condition like triforce hunt.
# Having an item in the start inventory won't remove it from the pool.
# If an item can't have duplicates it has to be excluded manually.
# List of items to exclude, as a copy since it will be destroyed below
exclude = [item for item in self.world.precollected_items[self.player]]
for item in map(self.create_item, mygame_items):
if item in exclude:
exclude.remove(item) # this is destructive. create unique list above
self.world.itempool.append(self.create_item('nothing'))
else:
self.world.itempool.append(item)
# itempool and number of locations should match up.
# If this is not the case we want to fill the itempool with junk.
junk = 0 # calculate this based on player settings
self.world.itempool += [self.create_item('nothing') for _ in range(junk)]
```
#### create_regions
```python
def create_regions(self):
# Add regions to the multiworld. "Menu" is the required starting point.
# Arguments to Region() are name, type, human_readable_name, player, world
r = Region("Menu", None, "Menu", self.player, self.world)
# Set Region.exits to a list of entrances that are reachable from region
r.exits = [Entrance(self.player, "New game", r)] # or use r.exits.append
# Append region to MultiWorld's regions
self.world.regions.append(r) # or use += [r...]
r = Region("Main Area", None, "Main Area", self.player, self.world)
# Add main area's locations to main area (all but final boss)
r.locations = [MyGameLocation(self.player, location.name,
self.location_name_to_id[location.name], r)]
r.exits = [Entrance(self.player, "Boss Door", r)]
self.world.regions.append(r)
r = Region("Boss Room", None, "Boss Room", self.player, self.world)
# add event to Boss Room
r.locations = [MyGameLocation(self.player, "Final Boss", None, r)]
self.world.regions.append(r)
# If entrances are not randomized, they should be connected here, otherwise
# they can also be connected at a later stage.
self.world.get_entrance("New Game", self.player)\
.connect(self.world.get_region("Main Area", self.player))
self.world.get_entrance("Boss Door", self.player)\
.connect(self.world.get_region("Boss Room", self.player))
# If setting location access rules from data is easier here, set_rules can
# possibly omitted.
```
#### generate_basic
```python
def generate_basic(self):
# place "Victory" at "Final Boss" and set collection as win condition
self.world.get_location("Final Boss", self.player)\
.place_locked_item(self.create_event("Victory"))
self.world.completion_condition[self.player] = \
lambda state: state.has("Victory", self.player)
# place item Herb into location Chest1 for some reason
item = self.create_item("Herb")
self.world.get_location("Chest1", self.player).place_locked_item(item)
# in most cases it's better to do this at the same time the itempool is
# filled to avoid accidental duplicates:
# manually placed and still in the itempool
```
### Setting Rules
```python
from ..generic.Rules import add_rule, set_rule, forbid_item
from Items import get_item_type
def set_rules(self):
# For some worlds this step can be omitted if either a Logic mixin
# (see below) is used, it's easier to apply the rules from data during
# location generation or everything is in generate_basic
# set a simple rule for an region
set_rule(self.world.get_entrance("Boss Door", self.player),
lambda state: state.has("Boss Key", self.player))
# combine rules to require two items
add_rule(self.world.get_location("Chest2", self.player),
lambda state: state.has("Sword", self.player))
add_rule(self.world.get_location("Chest2", self.player),
lambda state: state.has("Shield", self.player))
# or simply combine yourself
set_rule(self.world.get_location("Chest2", self.player),
lambda state: state.has("Sword", self.player) and
state.has("Shield", self.player))
# require two of an item
set_rule(self.world.get_location("Chest3", self.player),
lambda state: state.has("Key", self.player, 2))
# require one item from an item group
add_rule(self.world.get_location("Chest3", self.player),
lambda state: state.has_group("weapons", self.player))
# state also has .item_count() for items, .has_any() and.has_all() for sets
# and .count_group() for groups
# set_rule is likely to be a bit faster than add_rule
# disallow placing a specific local item at a specific location
forbid_item(self.world.get_location("Chest4", self.player), "Sword")
# disallow placing items with a specific property
add_item_rule(self.world.get_location("Chest5", self.player),
lambda item: get_item_type(item) == "weapon")
# get_item_type needs to take player/world into account
# if MyGameItem has a type property, a more direct implementation would be
add_item_rule(self.world.get_location("Chest5", self.player),
lambda item: item.player != self.player or\
item.my_type == "weapon")
# location.item_rule = ... is likely to be a bit faster
```
### Logic Mixin
While lambdas and events could do pretty much anything, by convention we
implement more complex logic in logic mixins, even if there is no need to add
properties to the `BaseClasses.CollectionState` state object.
When importing a file that defines a class that inherits from
`..AutoWorld.LogicMixin` the state object's class is automatically extended by
the mixin's members. These members should be prefixed with underscore following
the name of the implementing world. This is due to sharing a namespace with all
other logic mixins.
Typical uses are defining methods that are used instead of `state.has`
in lambdas, e.g.`state._mygame_has(custom, world, player)` or recurring checks
like `state._mygame_can_do_something(world, player)` to simplify lambdas.
More advanced uses could be to add additional variables to the state object,
override `World.collect(self, state, item)` and `remove(self, state, item)`
to update the state object, and check those added variables in added methods.
Please do this with caution and only when neccessary.
#### Sample
```python
# Logic.py
from ..AutoWorld import LogicMixin
class MyGameLogic(LogicMixin):
def _mygame_has_key(self, world: MultiWorld, player: int):
# Arguments above are free to choose
# it may make sense to use World as argument instead of MultiWorld
return self.has('key', player) # or whatever
```
```python
# __init__.py
from ..generic.Rules import set_rule
import .Logic # apply the mixin by importing its file
class MyGameWorld(World):
# ...
def set_rules(self):
set_rule(self.world.get_location("A Door", self.player),
lamda state: state._myworld_has_key(self.world, self.player))
```
### Generate Output
```python
from .Mod import generate_mod
def generate_output(self, output_directory: str):
# How to generate the mod or ROM highly depends on the game
# if the mod is written in Lua, Jinja can be used to fill a template
# if the mod reads a json file, `json.dump()` can be used to generate that
# code below is a dummy
data = {
"seed": self.world.seed_name, # to verify the server's multiworld
"slot": self.world.player_name[self.player], # to connect to server
"items": {location.name: location.item.name
if location.item.player == self.player else "Remote"
for location in self.world.get_filled_locations(self.player)},
# store start_inventory from player's .yaml
"starter_items": [item.name for item
in self.world.precollected_items[self.player]],
"final_boss_hp": self.final_boss_hp,
# store option name "easy", "normal" or "hard" for difficuly
"difficulty": self.world.difficulty[self.player].current_key,
# store option value True or False for fixing a glitch
"fix_xyz_glitch": self.world.fix_xyz_glitch[self.player].value
}
# point to a ROM specified by the installation
src = Utils.get_options()["mygame_options"]["rom_file"]
# or point to worlds/mygame/data/mod_template
src = os.path.join(os.path.dirname(__file__), "data", "mod_template")
# generate output path
mod_name = f"AP-{self.world.seed_name}-P{self.player}-{self.world.player_name[self.player]}"
out_file = os.path.join(output_directory, mod_name + ".zip")
# generate the file
generate_mod(src, out_file, data)
```

View File

@@ -44,6 +44,7 @@ These packets are are sent from the multiworld server to the client. They are no
* [PrintJSON](#PrintJSON)
* [DataPackage](#DataPackage)
* [Bounced](#Bounced)
* [InvalidPacket](#InvalidPacket)
### RoomInfo
Sent to clients when they connect to an Archipelago server.
@@ -53,15 +54,17 @@ Sent to clients when they connect to an Archipelago server.
| version | NetworkVersion | Object denoting the version of Archipelago which the server is running. See [NetworkVersion](#NetworkVersion) for more details. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. Example: `WebHost` |
| password | bool | Denoted whether a password is required to join this room.|
| permissions | dict\[str, Permission\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "forfeit" and "remaining". |
| permissions | dict\[str, Permission\[int\]\] | Mapping of permission name to [Permission](#Permission), keys are: "forfeit", "collect" and "remaining". |
| hint_cost | int | The amount of points it costs to receive a hint from the server. |
| location_check_points | int | The amount of hint points you receive per item/location check completed. ||
| players | list\[NetworkPlayer\] | Sent only if the client is properly authenticated (see [Archipelago Connection Handshake](#Archipelago-Connection-Handshake)). Information on the players currently connected to the server. See [NetworkPlayer](#NetworkPlayer) for more details. |
| datapackage_version | int | Data version of the [data package](#Data Package Contents) the server will send. Used to update the client's (optional) local cache. |
| datapackage_versions | dict[str, int] | Data versions of the individual games' data packages the server will send. |
| games | list\[str\] | sorted list of game names for the players, so first player's game will be games\[0\]. Matches game names in datapackage. |
| datapackage_version | int | Data version of the [data package](#Data-Package-Contents) the server will send. Used to update the client's (optional) local cache. |
| datapackage_versions | dict\[str, int\] | Data versions of the individual games' data packages the server will send. |
| seed_name | str | uniquely identifying name of this generation |
| time | float | Unix time stamp of "now". Send for time synchronization if wanted for things like the DeathLink Bounce. |
#### forfeit_mode
#### forfeit
Dictates what is allowed when it comes to a player forfeiting their run. A forfeit is an action which distributes the rest of the items in a player's run to those other players awaiting them.
* `auto`: Distributes a player's items to other players when they complete their goal.
@@ -70,7 +73,17 @@ Dictates what is allowed when it comes to a player forfeiting their run. A forfe
* `disabled`: All forfeit modes disabled.
* `goal`: Allows for manual use of forfeit command once a player completes their goal. (Disabled until goal completion)
#### remaining_mode
#### collect
Dictates what is allowed when it comes to a player collecting their run. A collect is an action which sends the rest of the items in a player's run.
* `auto`: Automatically when they complete their goal.
* `enabled`: Denotes that players may !collect at any time in the game.
* `auto-enabled`: Both of the above options together.
* `disabled`: All collect modes disabled.
* `goal`: Allows for manual use of collect command once a player completes their goal. (Disabled until goal completion)
#### remaining
Dictates what is allowed when it comes to a player querying the items remaining in their run.
* `goal`: Allows a player to query for items remaining in their run but only after they completed their own goal.
@@ -119,15 +132,18 @@ Sent to clients to acknowledge a received [LocationScouts](#LocationScouts) pack
### RoomUpdate
Sent when there is a need to update information about the present game session. Generally useful for async games.
Once authenticated (received Connected), this may also contain data from Connected.
#### Arguments
The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring two:
The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring:
| Name | Type | Notes |
| ---- | ---- | ----- |
| hint_points | int | New argument. The client's current hint points. |
| players | list\[NetworkPlayer\] | Changed argument. Always sends all players, whether connected or not. |
| checked_locations | May be a partial update, containing new locations that were checked. |
| missing_locations | Should never be sent as an update, if needed is the inverse of checked_locations. |
All arguments for this packet are optional.
All arguments for this packet are optional, only changes are sent.
### Print
Sent to clients purely to display a message to the player.
@@ -142,9 +158,10 @@ Sent to clients purely to display a message to the player. This packet differs f
| Name | Type | Notes |
| ---- | ---- | ----- |
| data | list\[JSONMessagePart\] | See [JSONMessagePart](#JSONMessagePart) for more details on this type. |
| type | str | May be present to indicate the nature of this message. Known types are Hint and ItemSend.
| receiving | int | Is present if type is Hint or ItemSend and marks the destination player's ID.
| item | NetworkItem | Is present if type is Hint or ItemSend and marks the source player id, location id and item id.
| type | str | May be present to indicate the nature of this message. Known types are Hint and ItemSend. |
| receiving | int | Is present if type is Hint or ItemSend and marks the destination player's ID. |
| item | NetworkItem | Is present if type is Hint or ItemSend and marks the source player id, location id and item id. |
| found | bool | Is present if type is Hint, denotes whether the location hinted for was checked. |
### DataPackage
Sent to clients to provide what is known as a 'data package' which contains information to enable a client to most easily communicate with the Archipelago server. Contents include things like location id to name mappings, among others; see [Data Package Contents](#Data-Package-Contents) for more info.
@@ -162,7 +179,14 @@ Sent to clients after a client requested this message be sent to them, more info
| ---- | ---- | ----- |
| data | dict | The data in the Bounce package copied |
### InvalidPacket
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
| Name | Type | Notes |
| ---- | ---- | ----- |
| type | string | "cmd" if the Packet isn't available/allowed, "arguments" if the problem is with the package data. |
| text | string | Error text explaining the caught error. |
| original_cmd | string | Echoes the cmd it failed on. May be null if the cmd was not found.
## (Client -> Server)
These packets are sent purely from client to server. They are not accepted by clients.
@@ -186,11 +210,19 @@ Sent by the client to initiate a connection to an Archipelago game session.
| name | str | The player name for this client. |
| uuid | str | Unique identifier for player client. |
| version | NetworkVersion | An object representing the Archipelago version this client supports. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
#### Authentication
Many, if not all, other packets require a successfully authenticated client. This is described in more detail in [Archipelago Connection Handshake](#Archipelago-Connection-Handshake).
### ConnectUpdate
Update arguments from the Connect package, currently only updating tags is supported.
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| tags | list\[str\] | Denotes special features or capabilities that the sender is capable of. [Tags](#Tags) |
### Sync
Sent to server to request a [ReceivedItems](#ReceivedItems) packet to synchronize items.
#### Arguments
@@ -234,7 +266,7 @@ Requests the data package from the server. Does not require client authenticatio
#### Arguments
| Name | Type | Notes |
| ------ | ----- | ------ |
| exclusions | list[str] | Optional. If specified, will not send back the specified data. Such as, ["Factorio"] -> Datapackage without Factorio data.|
| exclusions | list\[str\] | Optional. If specified, will not send back the specified data. Such as, \["Factorio"\] -> Datapackage without Factorio data.|
### Bounce
Send this message to the server, tell it which clients should receive the message and
@@ -243,13 +275,22 @@ the server will forward the message to all those targets to which any one requir
#### Arguments
| Name | Type | Notes |
| ------ | ----- | ------ |
| games | list[str] | Optional. Game names that should receive this message |
| slots | list[int] | Optional. Player IDs that should receive this message |
| tags | list[str] | Optional. Client tags that should receive this message |
| games | list\[str\] | Optional. Game names that should receive this message |
| slots | list\[int\] | Optional. Player IDs that should receive this message |
| tags | list\[str\] | Optional. Client tags that should receive this message |
| data | dict | Any data you want to send |
## Appendix
### Coop
Coop in Archipelago is automatically facilitated by the server, however some of the default behaviour may not be what you desire.
If the game in question is a remote-items game (attribute on AutoWorld), then all items will always be sent and received.
If the game in question is not a remote-items game, then any items that are placed within the same world will not be send by the server.
To manually react to others in the same player slot doing checks, listen to [RoomUpdate](#RoomUpdate) -> checked_locations.
### NetworkPlayer
A list of objects. Each object denotes one player. Each object has four fields about the player, in this order: `team`, `slot`, `alias`, and `name`. `team` and `slot` are ints, `alias` and `name` are strs.
@@ -303,6 +344,7 @@ class JSONMessagePart(TypedDict):
type: Optional[str]
color: Optional[str]
text: Optional[str]
player: Optional[int] # marks owning player id for location/item
```
`type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently.
@@ -310,6 +352,7 @@ Possible values for `type` include:
* player_id
* item_id
* location_id
* entrance_name
`color` is used to denote a console color to display the message part with. This is limited to console colors due to backwards compatibility needs with games such as ALttP. Although background colors as well as foreground colors are listed, only one may be applied to a [JSONMessagePart](#JSONMessagePart) at a time.
@@ -365,7 +408,7 @@ class Permission(enum.IntEnum):
disabled = 0b000 # 0, completely disables access
enabled = 0b001 # 1, allows manual use
goal = 0b010 # 2, allows manual use after goal completion
auto = 0b110 # 6, forces use after goal completion, only works for forfeit
auto = 0b110 # 6, forces use after goal completion, only works for forfeit and collect
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
```
@@ -377,6 +420,8 @@ We encourage clients to cache the data package they receive on disk, or otherwis
Note:
* Any ID is unique to its type across AP: Item 56 only exists once and Location 56 only exists once.
* Any Name is unique to its type across its own Game only: Single Arrow can exist in two games.
* The IDs from the game "Archipelago" may be used in any other game.
Especially Location ID -1: Cheat Console and -2: Server (typically Remote Start Inventory)
#### Contents
| Name | Type | Notes |
@@ -386,8 +431,28 @@ Note:
#### GameData
GameData is a **dict** but contains these keys and values. It's broken out into another "type" for ease of documentation.
| Name | Type | Notes |
| ---- | ---- | ----- |
| item_name_to_id | dict[str, int] | Mapping of all item names to their respective ID. |
| location_name_to_id | dict[str, int] | Mapping of all location names to their respective ID. |
| version | int | Version number of this game's data |
### Tags
Tags are represented as a list of strings, the common Client tags follow:
| Name | Notes |
| ----- | ---- |
| AP | Signifies that this client is a reference client, its usefulness is mostly in debugging to compare client behaviours more easily. |
| IgnoreGame | Tells the server to ignore the "game" attribute in the [Connect](#Connect) packet. |
| DeathLink | Client participates in the DeathLink mechanic, therefore will send and receive DeathLink bounce packets |
| Tracker | Tells the server that this client is actually a Tracker and will refuse new locations from this client. |
### DeathLink
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
| Name | Type | Notes |
| ---- | ---- | ---- |
| time | float | Unix Time Stamp of time of death. |
| cause | str | Optional. Text to explain the cause of death, ex. "Berserker was run over by a train." |
| source | str | Name of the player who first died. Can be a slot name, but can also be a name from within a multiplayer game. |

View File

@@ -23,12 +23,21 @@ server_options:
# so for example hint_cost: 20 would mean that for every 20% of available checks, you get the ability to hint, for a total of 5
hint_cost: 10 # Set to 0 if you want free hints
# Forfeit modes
# A Forfeit sends out the remaining items *from* a world that forfeits
# "disabled" -> clients can't forfeit,
# "enabled" -> clients can always forfeit
# "auto" -> automatic forfeit on goal completion, "goal" -> clients can forfeit after achieving their goal
# "auto" -> automatic forfeit on goal completion
# "auto-enabled" -> automatic forfeit on goal completion and manual forfeit is also enabled
# "goal" -> forfeit is allowed after goal completion
forfeit_mode: "goal"
# Collect modes
# A Collect sends the remaining items *to* a world that collects
# "disabled" -> clients can't collect,
# "enabled" -> clients can always collect
# "auto" -> automatic collect on goal completion
# "auto-enabled" -> automatic collect on goal completion and manual collect is also enabled
# "goal" -> collect is allowed after goal completion
collect_mode: "disabled"
# Remaining modes
# !remaining handling, that tells a client which items remain in their pool
# "enabled" -> Client can always ask for remaining items
@@ -82,6 +91,15 @@ lttp_options:
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
rom_start: true
sm_options:
# File name of the v1.0 J rom
rom_file: "Super Metroid (JU).sfc"
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
sni: "SNI"
# Set this to false to never autostart a rom (such as after patching)
# True for operating system default program
# Alternatively, a path to a program to open the .sfc file with
rom_start: true
factorio_options:
executable: "factorio\\bin\\x64\\factorio"
minecraft_options:
@@ -89,4 +107,7 @@ minecraft_options:
max_heap_size: "2G"
oot_options:
# File name of the OoT v1.0 ROM
rom_file: "The Legend of Zelda - Ocarina of Time.z64"
rom_file: "The Legend of Zelda - Ocarina of Time.z64"
soe_options:
# File name of the SoE US ROM
rom_file: "Secret of Evermore (USA).sfc"

438
inno_setup_310.iss Normal file
View File

@@ -0,0 +1,438 @@
#define sourcepath "build\exe.win-amd64-3.10"
#define MyAppName "Archipelago"
#define MyAppExeName "ArchipelagoServer.exe"
#define MyAppIcon "data/icon.ico"
#dim VersionTuple[4]
#define MyAppVersion ParseVersion('build\exe.win-amd64-3.10\ArchipelagoServer.exe', VersionTuple[0], VersionTuple[1], VersionTuple[2], VersionTuple[3])
#define MyAppVersionText Str(VersionTuple[0])+"."+Str(VersionTuple[1])+"."+Str(VersionTuple[2])
[Setup]
; NOTE: The value of AppId uniquely identifies this application.
; Do not use the same AppId value in installers for other applications.
AppId={{918BA46A-FAB8-460C-9DFF-AE691E1C865B}}
AppName={#MyAppName}
AppCopyright=Distributed under MIT License
AppVerName={#MyAppName} {#MyAppVersionText}
VersionInfoVersion={#MyAppVersion}
DefaultDirName={commonappdata}\{#MyAppName}
DisableProgramGroupPage=yes
DefaultGroupName=Archipelago
OutputDir=setups
OutputBaseFilename=Setup {#MyAppName} {#MyAppVersionText}
Compression=lzma2
SolidCompression=yes
LZMANumBlockThreads=8
ArchitecturesInstallIn64BitMode=x64
ChangesAssociations=yes
ArchitecturesAllowed=x64
AllowNoIcons=yes
SetupIconFile={#MyAppIcon}
UninstallDisplayIcon={app}\{#MyAppExeName}
; you will likely have to remove the following signtool line when testing/debugging locally. Don't include that change in PRs.
SignTool= signtool
LicenseFile= LICENSE
WizardStyle= modern
SetupLogging=yes
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}";
[Types]
Name: "full"; Description: "Full installation"
Name: "hosting"; Description: "Installation for hosting purposes"
Name: "playing"; Description: "Installation for playing purposes"
Name: "custom"; Description: "Custom installation"; Flags: iscustom
[Components]
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
Name: "generator"; Description: "Generator"; Types: full hosting
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
Name: "server"; Description: "Server"; Types: full hosting
Name: "client"; Description: "Clients"; Types: full playing
Name: "client/sni"; Description: "SNI Client"; Types: full playing
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs]
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify;
[Files]
Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp
Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: generator/oot
Source: "{#sourcepath}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#sourcepath}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni
Source: "{#sourcepath}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp
Source: "{#sourcepath}\ArchipelagoGenerate.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: generator
Source: "{#sourcepath}\ArchipelagoServer.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: server
Source: "{#sourcepath}\ArchipelagoFactorioClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/factorio
Source: "{#sourcepath}\ArchipelagoTextClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/text
Source: "{#sourcepath}\ArchipelagoSNIClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni
Source: "{#sourcepath}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni/lttp or generator/lttp
Source: "{#sourcepath}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
;minecraft temp files
Source: "{tmp}\forge-installer.jar"; DestDir: "{app}"; Flags: skipifsourcedoesntexist external deleteafterinstall; Components: client/minecraft
[Icons]
Name: "{group}\{#MyAppName} Folder"; Filename: "{app}";
Name: "{group}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Components: server
Name: "{group}\{#MyAppName} Text Client"; Filename: "{app}\ArchipelagoTextClient.exe"; Components: client/text
Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Components: client/sni
Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon
Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; Components: server
Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Tasks: desktopicon; Components: client/sni
Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
[Run]
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..."
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: client/sni/lttp or generator/lttp
Filename: "{app}\jre8\bin\java.exe"; Parameters: "-jar ""{app}\forge-installer.jar"" --installServer ""{app}\Minecraft Forge server"""; Flags: runhidden; Check: IsForgeNeeded(); StatusMsg: "Installing Forge Server..."; Components: client/minecraft
[UninstallDelete]
Type: dirifempty; Name: "{app}"
[InstallDelete]
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
[Registry]
Root: HKCR; Subkey: ".apbp"; ValueData: "{#MyAppName}patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}patch"; ValueData: "Archipelago Binary Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apm3"; ValueData: "{#MyAppName}smpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archipelago Super Metroid Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata\shell\open\command"; ValueData: """{app}\ArchipelagoMinecraftClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: ""; Components: server
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server
[Code]
const
SHCONTCH_NOPROGRESSBOX = 4;
SHCONTCH_RESPONDYESTOALL = 16;
FORGE_VERSION = '1.16.5-36.2.0';
// See: https://stackoverflow.com/a/51614652/2287576
function IsVCRedist64BitNeeded(): boolean;
var
strVersion: string;
begin
if (RegQueryStringValue(HKEY_LOCAL_MACHINE,
'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', 'Version', strVersion)) then
begin
// Is the installed version at least the packaged one ?
Log('VC Redist x64 Version : found ' + strVersion);
Result := (CompareStr(strVersion, 'v14.29.30037') < 0);
end
else
begin
// Not even an old version installed
Log('VC Redist x64 is not already installed');
Result := True;
end;
end;
function IsForgeNeeded(): boolean;
begin
Result := True;
if (FileExists(ExpandConstant('{app}')+'\Minecraft Forge Server\forge-'+FORGE_VERSION+'.jar')) then
Result := False;
end;
function IsJavaNeeded(): boolean;
begin
Result := True;
if (FileExists(ExpandConstant('{app}')+'\jre8\bin\java.exe')) then
Result := False;
end;
function OnDownloadMinecraftProgress(const Url, FileName: String; const Progress, ProgressMax: Int64): Boolean;
begin
if Progress = ProgressMax then
Log(Format('Successfully downloaded Minecraft additional files to {tmp}: %s', [FileName]));
Result := True;
end;
procedure UnZip(ZipPath, TargetPath: string);
var
Shell: Variant;
ZipFile: Variant;
TargetFolder: Variant;
begin
Shell := CreateOleObject('Shell.Application');
ZipFile := Shell.NameSpace(ZipPath);
if VarIsClear(ZipFile) then
RaiseException(
Format('ZIP file "%s" does not exist or cannot be opened', [ZipPath]));
TargetFolder := Shell.NameSpace(TargetPath);
if VarIsClear(TargetFolder) then
RaiseException(Format('Target path "%s" does not exist', [TargetPath]));
TargetFolder.CopyHere(
ZipFile.Items, SHCONTCH_NOPROGRESSBOX or SHCONTCH_RESPONDYESTOALL);
end;
var R : longint;
var lttprom: string;
var LttPROMFilePage: TInputFileWizardPage;
var smrom: string;
var SMRomFilePage: TInputFileWizardPage;
var soerom: string;
var SoERomFilePage: TInputFileWizardPage;
var ootrom: string;
var OoTROMFilePage: TInputFileWizardPage;
var MinecraftDownloadPage: TDownloadWizardPage;
function GetSNESMD5OfFile(const rom: string): string;
var data: AnsiString;
begin
if LoadStringFromFile(rom, data) then
begin
if Length(data) mod 1024 = 512 then
begin
data := copy(data, 513, Length(data)-512);
end;
Result := GetMD5OfString(data);
end;
end;
function CheckRom(name: string; hash: string): string;
var rom: string;
begin
log('Handling ' + name)
rom := FileSearch(name, WizardDirValue());
if Length(rom) > 0 then
begin
log('existing ROM found');
log(IntToStr(CompareStr(GetSNESMD5OfFile(rom), hash)));
if CompareStr(GetSNESMD5OfFile(rom), hash) = 0 then
begin
log('existing ROM verified');
Result := rom;
exit;
end;
log('existing ROM failed verification');
end;
end;
function AddRomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
Result.Add(
'Location of ROM file:',
'SNES ROM files|*.sfc;*.smc|All files|*.*',
'.sfc');
end;
procedure AddMinecraftDownloads();
begin
MinecraftDownloadPage := CreateDownloadPage(SetupMessage(msgWizardPreparing), SetupMessage(msgPreparingDesc), @OnDownloadMinecraftProgress);
end;
procedure AddOoTRomPage();
begin
ootrom := FileSearch('The Legend of Zelda - Ocarina of Time.z64', WizardDirValue());
if Length(ootrom) > 0 then
begin
log('existing ROM found');
log(IntToStr(CompareStr(GetMD5OfFile(ootrom), '5bd1fe107bf8106b2ab6650abecd54d6'))); // normal
log(IntToStr(CompareStr(GetMD5OfFile(ootrom), '6697768a7a7df2dd27a692a2638ea90b'))); // byteswapped
log(IntToStr(CompareStr(GetMD5OfFile(ootrom), '05f0f3ebacbc8df9243b6148ffe4792f'))); // decompressed
if (CompareStr(GetMD5OfFile(ootrom), '5bd1fe107bf8106b2ab6650abecd54d6') = 0) or (CompareStr(GetMD5OfFile(ootrom), '6697768a7a7df2dd27a692a2638ea90b') = 0) or (CompareStr(GetMD5OfFile(ootrom), '05f0f3ebacbc8df9243b6148ffe4792f') = 0) then
begin
log('existing ROM verified');
exit;
end;
log('existing ROM failed verification');
end;
ootrom := ''
OoTROMFilePage :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your OoT 1.0 ROM located?',
'Select the file, then click Next.');
OoTROMFilePage.Add(
'Location of ROM file:',
'N64 ROM files (*.z64, *.n64)|*.z64;*.n64|All files|*.*',
'.z64');
end;
function NextButtonClick(CurPageID: Integer): Boolean;
begin
if (CurPageID = wpReady) and (WizardIsComponentSelected('client/minecraft')) then begin
MinecraftDownloadPage.Clear;
if(IsForgeNeeded()) then
MinecraftDownloadPage.Add('https://maven.minecraftforge.net/net/minecraftforge/forge/'+FORGE_VERSION+'/forge-'+FORGE_VERSION+'-installer.jar','forge-installer.jar','');
if(IsJavaNeedeD()) then
MinecraftDownloadPage.Add('https://corretto.aws/downloads/latest/amazon-corretto-8-x64-windows-jre.zip','java.zip','');
MinecraftDownloadPage.Show;
try
try
MinecraftDownloadPage.Download;
Result := True;
except
if MinecraftDownloadPage.AbortedByUser then
Log('Aborted by user.')
else
SuppressibleMsgBox(AddPeriod(GetExceptionMessage), mbCriticalError, MB_OK, IDOK);
Result := False;
end;
finally
if( isJavaNeeded() ) then
if(ForceDirectories(ExpandConstant('{app}'))) then
UnZip(ExpandConstant('{tmp}')+'\java.zip',ExpandConstant('{app}'));
MinecraftDownloadPage.Hide;
end;
Result := True;
end
else if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then
Result := not (LttPROMFilePage.Values[0] = '')
else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then
Result := not (SMROMFilePage.Values[0] = '')
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
Result := not (SoEROMFilePage.Values[0] = '')
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
Result := not (OoTROMFilePage.Values[0] = '')
else
Result := True;
end;
function GetROMPath(Param: string): string;
begin
if Length(lttprom) > 0 then
Result := lttprom
else if Assigned(LttPRomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173')
if R <> 0 then
MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := LttPROMFilePage.Values[0]
end
else
Result := '';
end;
function GetSMROMPath(Param: string): string;
begin
if Length(smrom) > 0 then
Result := smrom
else if Assigned(SMRomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675')
if R <> 0 then
MsgBox('Super Metroid ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := SMROMFilePage.Values[0]
end
else
Result := '';
end;
function GetSoEROMPath(Param: string): string;
begin
if Length(soerom) > 0 then
Result := soerom
else if Assigned(SoERomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a')
if R <> 0 then
MsgBox('Secret of Evermore ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := SoEROMFilePage.Values[0]
end
else
Result := '';
end;
function GetOoTROMPath(Param: string): string;
begin
if Length(ootrom) > 0 then
Result := ootrom
else if (Assigned(OoTROMFilePage)) then
begin
R := CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '5bd1fe107bf8106b2ab6650abecd54d6') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '6697768a7a7df2dd27a692a2638ea90b') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '05f0f3ebacbc8df9243b6148ffe4792f');
if R <> 0 then
MsgBox('OoT ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := OoTROMFilePage.Values[0]
end
else
Result := '';
end;
procedure InitializeWizard();
begin
AddOoTRomPage();
lttprom := CheckRom('Zelda no Densetsu - Kamigami no Triforce (Japan).sfc', '03a63945398191337e896e5771f77173');
if Length(lttprom) = 0 then
LttPROMFilePage:= AddRomPage('Zelda no Densetsu - Kamigami no Triforce (Japan).sfc');
smrom := CheckRom('Super Metroid (JU).sfc', '21f3e98df4780ee1c667b84e57d88675');
if Length(smrom) = 0 then
SMRomFilePage:= AddRomPage('Super Metroid (JU).sfc');
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
if Length(soerom) = 0 then
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
AddMinecraftDownloads();
end;
function ShouldSkipPage(PageID: Integer): Boolean;
begin
Result := False;
if (assigned(LttPROMFilePage)) and (PageID = LttPROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/lttp') or WizardIsComponentSelected('generator/lttp'));
if (assigned(SMROMFilePage)) and (PageID = SMROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/soe'));
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/oot'));
end;

View File

@@ -29,7 +29,7 @@ ArchitecturesAllowed=x64
AllowNoIcons=yes
SetupIconFile={#MyAppIcon}
UninstallDisplayIcon={app}\{#MyAppExeName}
; you will likely have to remove the following signtool line when testing/debugging localy. Don't include that change in PRs.
; you will likely have to remove the following signtool line when testing/debugging locally. Don't include that change in PRs.
SignTool= signtool
LicenseFile= LICENSE
WizardStyle= modern
@@ -48,33 +48,39 @@ Name: "playing"; Description: "Installation for playing purposes"
Name: "custom"; Description: "Custom installation"; Flags: iscustom
[Components]
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
Name: "generator"; Description: "Generator"; Types: full hosting
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296
Name: "server"; Description: "Server"; Types: full hosting
Name: "client"; Description: "Clients"; Types: full playing
Name: "client/lttp"; Description: "A Link to the Past"; Types: full playing
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed
Name: "generator"; Description: "Generator"; Types: full hosting
Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning
Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680
Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning
Name: "server"; Description: "Server"; Types: full hosting
Name: "client"; Description: "Clients"; Types: full playing
Name: "client/sni"; Description: "SNI Client"; Types: full playing
Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning
Name: "client/factorio"; Description: "Factorio"; Types: full playing
Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing
[Dirs]
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify;
[Files]
Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/lttp or generator/lttp
Source: "{code:GetROMPath}"; DestDir: "{app}"; DestName: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"; Flags: external; Components: client/sni/lttp or generator/lttp
Source: "{code:GetSMROMPath}"; DestDir: "{app}"; DestName: "Super Metroid (JU).sfc"; Flags: external; Components: client/sni/sm or generator/sm
Source: "{code:GetSoEROMPath}"; DestDir: "{app}"; DestName: "Secret of Evermore (USA).sfc"; Flags: external; Components: generator/soe
Source: "{code:GetOoTROMPath}"; DestDir: "{app}"; DestName: "The Legend of Zelda - Ocarina of Time.z64"; Flags: external; Components: generator/oot
Source: "{#sourcepath}\*"; Excludes: "*.sfc, *.log, data\sprites\alttpr, SNI, EnemizerCLI, Archipelago*.exe"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#sourcepath}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/lttp
Source: "{#sourcepath}\SNI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\SNI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: client/sni
Source: "{#sourcepath}\EnemizerCLI\*"; Excludes: "*.sfc, *.log"; DestDir: "{app}\EnemizerCLI"; Flags: ignoreversion recursesubdirs createallsubdirs; Components: generator/lttp
Source: "{#sourcepath}\ArchipelagoGenerate.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: generator
Source: "{#sourcepath}\ArchipelagoServer.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: server
Source: "{#sourcepath}\ArchipelagoFactorioClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/factorio
Source: "{#sourcepath}\ArchipelagoTextClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/text
Source: "{#sourcepath}\ArchipelagoLttPClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/lttp
Source: "{#sourcepath}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/lttp
Source: "{#sourcepath}\ArchipelagoSNIClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni
Source: "{#sourcepath}\ArchipelagoLttPAdjuster.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/sni/lttp or generator/lttp
Source: "{#sourcepath}\ArchipelagoMinecraftClient.exe"; DestDir: "{app}"; Flags: ignoreversion; Components: client/minecraft
Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall
@@ -84,30 +90,38 @@ Source: "{tmp}\forge-installer.jar"; DestDir: "{app}"; Flags: skipifsourcedoesnt
[Icons]
Name: "{group}\{#MyAppName} Folder"; Filename: "{app}";
Name: "{group}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Components: server
Name: "{group}\{#MyAppName} Text Client"; Filename: "{app}\ArchipelagoTextClient.exe"; Components: client/lttp
Name: "{group}\{#MyAppName} LttP Client"; Filename: "{app}\ArchipelagoLttPClient.exe"; Components: client/lttp
Name: "{group}\{#MyAppName} Text Client"; Filename: "{app}\ArchipelagoTextClient.exe"; Components: client/text
Name: "{group}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Components: client/sni
Name: "{group}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Components: client/factorio
Name: "{group}\{#MyAppName} Minecraft Client"; Filename: "{app}\ArchipelagoMinecraftClient.exe"; Components: client/minecraft
Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon
Name: "{commondesktop}\{#MyAppName} Server"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; Components: server
Name: "{commondesktop}\{#MyAppName} LttP Client"; Filename: "{app}\ArchipelagoLttPClient.exe"; Tasks: desktopicon; Components: client/lttp
Name: "{commondesktop}\{#MyAppName} SNI Client"; Filename: "{app}\ArchipelagoSNIClient.exe"; Tasks: desktopicon; Components: client/sni
Name: "{commondesktop}\{#MyAppName} Factorio Client"; Filename: "{app}\ArchipelagoFactorioClient.exe"; Tasks: desktopicon; Components: client/factorio
[Run]
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..."
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: client/lttp or generator
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: client/sni/lttp or generator/lttp
Filename: "{app}\jre8\bin\java.exe"; Parameters: "-jar ""{app}\forge-installer.jar"" --installServer ""{app}\Minecraft Forge server"""; Flags: runhidden; Check: IsForgeNeeded(); StatusMsg: "Installing Forge Server..."; Components: client/minecraft
[UninstallDelete]
Type: dirifempty; Name: "{app}"
[InstallDelete]
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
[Registry]
Root: HKCR; Subkey: ".apbp"; ValueData: "{#MyAppName}patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/lttp
Root: HKCR; Subkey: "{#MyAppName}patch"; ValueData: "Archipelago Binary Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/lttp
Root: HKCR; Subkey: "{#MyAppName}patch\DefaultIcon"; ValueData: "{app}\ArchipelagoLttPClient.exe,0"; ValueType: string; ValueName: ""; Components: client/lttp
Root: HKCR; Subkey: "{#MyAppName}patch\shell\open\command"; ValueData: """{app}\ArchipelagoLttPClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/lttp
Root: HKCR; Subkey: ".apbp"; ValueData: "{#MyAppName}patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}patch"; ValueData: "Archipelago Binary Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apm3"; ValueData: "{#MyAppName}smpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smpatch"; ValueData: "Archipelago Super Metroid Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: "{#MyAppName}smpatch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: client/sni
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Components: client/minecraft
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Components: client/minecraft
@@ -120,7 +134,6 @@ Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{ap
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server
[Code]
const
SHCONTCH_NOPROGRESSBOX = 4;
@@ -189,38 +202,66 @@ begin
ZipFile.Items, SHCONTCH_NOPROGRESSBOX or SHCONTCH_RESPONDYESTOALL);
end;
var ROMFilePage: TInputFileWizardPage;
var R : longint;
var rom: string;
var lttprom: string;
var LttPROMFilePage: TInputFileWizardPage;
var smrom: string;
var SMRomFilePage: TInputFileWizardPage;
var soerom: string;
var SoERomFilePage: TInputFileWizardPage;
var ootrom: string;
var OoTROMFilePage: TInputFileWizardPage;
var MinecraftDownloadPage: TDownloadWizardPage;
procedure AddRomPage();
function GetSNESMD5OfFile(const rom: string): string;
var data: AnsiString;
begin
rom := FileSearch('Zelda no Densetsu - Kamigami no Triforce (Japan).sfc', WizardDirValue());
if LoadStringFromFile(rom, data) then
begin
if Length(data) mod 1024 = 512 then
begin
data := copy(data, 513, Length(data)-512);
end;
Result := GetMD5OfString(data);
end;
end;
function CheckRom(name: string; hash: string): string;
var rom: string;
begin
log('Handling ' + name)
rom := FileSearch(name, WizardDirValue());
if Length(rom) > 0 then
begin
log('existing ROM found');
log(IntToStr(CompareStr(GetMD5OfFile(rom), '03a63945398191337e896e5771f77173')));
if CompareStr(GetMD5OfFile(rom), '03a63945398191337e896e5771f77173') = 0 then
log(IntToStr(CompareStr(GetSNESMD5OfFile(rom), hash)));
if CompareStr(GetSNESMD5OfFile(rom), hash) = 0 then
begin
log('existing ROM verified');
Result := rom;
exit;
end;
log('existing ROM failed verification');
end;
rom := ''
ROMFilePage :=
end;
function AddRomPage(name: string): TInputFileWizardPage;
begin
Result :=
CreateInputFilePage(
wpSelectComponents,
'Select ROM File',
'Where is your Zelda no Densetsu - Kamigami no Triforce (Japan).sfc located?',
'Where is your ' + name + ' located?',
'Select the file, then click Next.');
ROMFilePage.Add(
Result.Add(
'Location of ROM file:',
'SNES ROM files|*.sfc|All files|*.*',
'SNES ROM files|*.sfc;*.smc|All files|*.*',
'.sfc');
end;
@@ -286,38 +327,62 @@ begin
MinecraftDownloadPage.Hide;
end;
Result := True;
end else
end
else if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then
Result := not (LttPROMFilePage.Values[0] = '')
else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then
Result := not (SMROMFilePage.Values[0] = '')
else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then
Result := not (SoEROMFilePage.Values[0] = '')
else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then
Result := not (OoTROMFilePage.Values[0] = '')
else
Result := True;
end;
procedure InitializeWizard();
begin
AddOoTRomPage();
AddRomPage();
AddMinecraftDownloads();
end;
function ShouldSkipPage(PageID: Integer): Boolean;
begin
Result := False;
if (assigned(ROMFilePage)) and (PageID = ROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/lttp') or WizardIsComponentSelected('generator/lttp'));
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/oot'));
end;
function GetROMPath(Param: string): string;
begin
if Length(rom) > 0 then
Result := rom
else if Assigned(RomFilePage) then
if Length(lttprom) > 0 then
Result := lttprom
else if Assigned(LttPRomFilePage) then
begin
R := CompareStr(GetMD5OfFile(ROMFilePage.Values[0]), '03a63945398191337e896e5771f77173')
R := CompareStr(GetSNESMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173')
if R <> 0 then
MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := ROMFilePage.Values[0]
Result := LttPROMFilePage.Values[0]
end
else
Result := '';
end;
function GetSMROMPath(Param: string): string;
begin
if Length(smrom) > 0 then
Result := smrom
else if Assigned(SMRomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675')
if R <> 0 then
MsgBox('Super Metroid ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := SMROMFilePage.Values[0]
end
else
Result := '';
end;
function GetSoEROMPath(Param: string): string;
begin
if Length(soerom) > 0 then
Result := soerom
else if Assigned(SoERomFilePage) then
begin
R := CompareStr(GetSNESMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a')
if R <> 0 then
MsgBox('Secret of Evermore ROM validation failed. Very likely wrong file.', mbInformation, MB_OK);
Result := SoEROMFilePage.Values[0]
end
else
Result := '';
@@ -338,3 +403,36 @@ begin
else
Result := '';
end;
procedure InitializeWizard();
begin
AddOoTRomPage();
lttprom := CheckRom('Zelda no Densetsu - Kamigami no Triforce (Japan).sfc', '03a63945398191337e896e5771f77173');
if Length(lttprom) = 0 then
LttPROMFilePage:= AddRomPage('Zelda no Densetsu - Kamigami no Triforce (Japan).sfc');
smrom := CheckRom('Super Metroid (JU).sfc', '21f3e98df4780ee1c667b84e57d88675');
if Length(smrom) = 0 then
SMRomFilePage:= AddRomPage('Super Metroid (JU).sfc');
soerom := CheckRom('Secret of Evermore (USA).sfc', '6e9c94511d04fac6e0a1e582c170be3a');
if Length(soerom) = 0 then
SoEROMFilePage:= AddRomPage('Secret of Evermore (USA).sfc');
AddMinecraftDownloads();
end;
function ShouldSkipPage(PageID: Integer): Boolean;
begin
Result := False;
if (assigned(LttPROMFilePage)) and (PageID = LttPROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/lttp') or WizardIsComponentSelected('generator/lttp'));
if (assigned(SMROMFilePage)) and (PageID = SMROMFilePage.ID) then
Result := not (WizardIsComponentSelected('client/sni/sm') or WizardIsComponentSelected('generator/sm'));
if (assigned(SoEROMFilePage)) and (PageID = SoEROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/soe'));
if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then
Result := not (WizardIsComponentSelected('generator/oot'));
end;

234
kvui.py
View File

@@ -1,28 +1,153 @@
import os
import logging
import typing
import asyncio
import sys
os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1"
os.environ["KIVY_NO_ARGS"] = "1"
os.environ["KIVY_LOG_ENABLE"] = "0"
from kivy.app import App
from kivy.base import ExceptionHandler, ExceptionManager, Config
from kivy.core.window import Window
from kivy.base import ExceptionHandler, ExceptionManager, Config, Clock
from kivy.factory import Factory
from kivy.properties import BooleanProperty, ObjectProperty
from kivy.uix.button import Button
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.uix.boxlayout import BoxLayout
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.label import Label
from kivy.uix.progressbar import ProgressBar
from kivy.utils import escape_markup
from kivy.lang import Builder
import Utils
from NetUtils import JSONtoTextParser, JSONMessagePart
if typing.TYPE_CHECKING:
import CommonClient
context_type = CommonClient.CommonContext
else:
context_type = object
# I was surprised to find this didn't already exist in kivy :(
class HoverBehavior(object):
"""from https://stackoverflow.com/a/605348110"""
hovered = BooleanProperty(False)
border_point = ObjectProperty(None)
def __init__(self, **kwargs):
self.register_event_type('on_enter')
self.register_event_type('on_leave')
Window.bind(mouse_pos=self.on_mouse_pos)
Window.bind(on_cursor_leave=self.on_cursor_leave)
super(HoverBehavior, self).__init__(**kwargs)
def on_mouse_pos(self, *args):
if not self.get_root_window():
return # do proceed if I'm not displayed <=> If have no parent
pos = args[1]
# Next line to_widget allow to compensate for relative layout
inside = self.collide_point(*self.to_widget(*pos))
if self.hovered == inside:
return # We have already done what was needed
self.border_point = pos
self.hovered = inside
if inside:
self.dispatch("on_enter")
else:
self.dispatch("on_leave")
def on_cursor_leave(self, *args):
# if the mouse left the window, it is obviously no longer inside the hover label.
self.hovered = BooleanProperty(False)
self.border_point = ObjectProperty(None)
self.dispatch("on_leave")
Factory.register('HoverBehavior', HoverBehavior)
class ServerToolTip(Label):
pass
class ServerLabel(HoverBehavior, Label):
def __init__(self, *args, **kwargs):
super(ServerLabel, self).__init__(*args, **kwargs)
self.layout = FloatLayout()
self.popuplabel = ServerToolTip(text="Test")
self.layout.add_widget(self.popuplabel)
def on_enter(self):
self.popuplabel.text = self.get_text()
App.get_running_app().root.add_widget(self.layout)
def on_leave(self):
App.get_running_app().root.remove_widget(self.layout)
def get_text(self):
if self.ctx.server:
ctx = self.ctx
text = f"Connected to: {ctx.server_address}."
if ctx.slot is not None:
text += f"\nYou are Slot Number {ctx.slot} in Team Number {ctx.team}, " \
f"named {ctx.player_names[ctx.slot]}."
if ctx.items_received:
text += f"\nYou have received {len(ctx.items_received)} items. " \
f"You can list them in order with /received."
if ctx.total_locations:
text += f"\nYou have checked {len(ctx.checked_locations)} " \
f"out of {ctx.total_locations} locations. " \
f"You can get more info on missing checks with /missing."
if ctx.permissions:
text += "\nPermissions:"
for permission_name, permission_data in ctx.permissions.items():
text += f"\n {permission_name}: {permission_data}"
if ctx.hint_cost is not None and ctx.total_locations:
text += f"\nA new !hint <itemname> costs {ctx.hint_cost}% of checks made. " \
f"For you this means every {max(0, int(ctx.hint_cost * 0.01 * ctx.total_locations))} " \
"location checks."
elif ctx.hint_cost == 0:
text += "\n!hint is free to use."
else:
text += f"\nYou are not authenticated yet."
return text
else:
return "No current server connection. \nPlease connect to an Archipelago server."
@property
def ctx(self) -> context_type:
return App.get_running_app().ctx
class MainLayout(GridLayout):
pass
class ContainerLayout(FloatLayout):
pass
class GameManager(App):
logging_pairs = [
("Client", "Archipelago"),
]
base_title = "Archipelago Client"
def __init__(self, ctx):
def __init__(self, ctx: context_type):
self.title = self.base_title
self.ctx = ctx
self.commandprocessor = ctx.command_processor(ctx)
self.icon = r"data/icon.png"
@@ -31,12 +156,27 @@ class GameManager(App):
super(GameManager, self).__init__()
def build(self):
self.grid = GridLayout()
self.container = ContainerLayout()
self.grid = MainLayout()
self.grid.cols = 1
connect_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=30)
# top part
server_label = ServerLabel()
connect_layout.add_widget(server_label)
self.server_connect_bar = TextInput(text="archipelago.gg", size_hint_y=None, height=30, multiline=False)
self.server_connect_bar.bind(on_text_validate=self.connect_button_action)
connect_layout.add_widget(self.server_connect_bar)
self.server_connect_button = Button(text="Connect", size=(100, 30), size_hint_y=None, size_hint_x=None)
self.server_connect_button.bind(on_press=self.connect_button_action)
connect_layout.add_widget(self.server_connect_button)
self.grid.add_widget(connect_layout)
self.progressbar = ProgressBar(size_hint_y=None, height=3)
self.grid.add_widget(self.progressbar)
self.tabs = TabbedPanel()
# middle part
self.tabs = TabbedPanel(size_hint_y=1)
self.tabs.default_tab_text = "All"
self.log_panels["All"] = self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name)
for logger_name, name in
self.logging_pairs))
@@ -48,13 +188,75 @@ class GameManager(App):
self.tabs.add_widget(panel)
self.grid.add_widget(self.tabs)
if len(self.logging_pairs) == 1:
# Hide Tab selection if only one tab
self.tabs.clear_tabs()
self.tabs.do_default_tab = False
self.tabs.current_tab.height = 0
self.tabs.tab_height = 0
# bottom part
bottom_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=30)
info_button = Button(height=30, text="Command:", size_hint_x=None)
info_button.bind(on_release=self.command_button_action)
bottom_layout.add_widget(info_button)
textinput = TextInput(size_hint_y=None, height=30, multiline=False)
textinput.bind(on_text_validate=self.on_message)
self.grid.add_widget(textinput)
bottom_layout.add_widget(textinput)
self.grid.add_widget(bottom_layout)
self.commandprocessor("/help")
return self.grid
Clock.schedule_interval(self.update_texts, 1 / 30)
self.container.add_widget(self.grid)
self.catch_unhandled_exceptions()
return self.container
def catch_unhandled_exceptions(self):
"""Relay unhandled exceptions to UI logger."""
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
orig_hook = sys.excepthook
def handle_exception(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.getLogger("Client").exception("Uncaught exception",
exc_info=(exc_type, exc_value, exc_traceback))
return orig_hook(exc_type, exc_value, exc_traceback)
handle_exception._wrapped = True
sys.excepthook = handle_exception
def update_texts(self, dt):
if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \
f" | Connected to: {self.ctx.server_address} " \
f"{'.'.join(str(e) for e in self.ctx.server_version)}"
self.server_connect_button.text = "Disconnect"
self.progressbar.max = len(self.ctx.checked_locations) + len(self.ctx.missing_locations)
self.progressbar.value = len(self.ctx.checked_locations)
else:
self.server_connect_button.text = "Connect"
self.title = self.base_title + " " + Utils.__version__
self.progressbar.value = 0
def command_button_action(self, button):
logging.getLogger("Client").info("/help for client commands and !help for server commands.")
def connect_button_action(self, button):
if self.ctx.server:
self.ctx.server_address = None
asyncio.create_task(self.ctx.disconnect())
else:
asyncio.create_task(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
def on_stop(self):
# "kill" input tasks
for x in range(self.ctx.input_requests):
self.ctx.input_queue.put_nowait("")
self.ctx.input_requests = 0
self.ctx.exit_event.set()
def on_message(self, textinput: TextInput):
@@ -82,22 +284,22 @@ class FactorioManager(GameManager):
("FactorioServer", "Factorio Server Log"),
("FactorioWatcher", "Bridge Data Log"),
]
title = "Archipelago Factorio Client"
base_title = "Archipelago Factorio Client"
class LttPManager(GameManager):
class SNIManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("SNES", "SNES"),
]
title = "Archipelago LttP Client"
base_title = "Archipelago SNI Client"
class TextManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
title = "Archipelago Text Client"
base_title = "Archipelago Text Client"
class LogtoUI(logging.Handler):
@@ -106,7 +308,7 @@ class LogtoUI(logging.Handler):
self.on_log = on_log
def handle(self, record: logging.LogRecord) -> None:
self.on_log(record)
self.on_log(self.format(record))
class UILog(RecycleView):
@@ -118,8 +320,8 @@ class UILog(RecycleView):
for logger in loggers_to_handle:
logger.addHandler(LogtoUI(self.on_log))
def on_log(self, record: logging.LogRecord) -> None:
self.data.append({"text": escape_markup(record.getMessage())})
def on_log(self, record: str) -> None:
self.data.append({"text": escape_markup(record)})
def on_message_markup(self, text):
self.data.append({"text": text})
@@ -129,8 +331,8 @@ class E(ExceptionHandler):
logger = logging.getLogger("Client")
def handle_exception(self, inst):
self.logger.exception(inst)
return ExceptionManager.RAISE
self.logger.exception("Uncaught Exception:", exc_info=inst)
return ExceptionManager.PASS
class KivyJSONtoTextParser(JSONtoTextParser):

View File

@@ -30,6 +30,8 @@ game: # Pick a game to play
Subnautica: 0
Slay the Spire: 0
Ocarina of Time: 0
Super Metroid: 0
requires:
version: 0.1.7 # Version of Archipelago required for this yaml to work as expected.
# Shared Options supported by all games:
@@ -57,6 +59,270 @@ progression_balancing:
# exclude_locations: # Force certain locations to never contain progression items, and always be filled with junk.
# - "Master Sword Pedestal"
Super Metroid: # see https://randommetroidsolver.pythonanywhere.com/randomizer advanced tab for detailed info on each option
# start_inventory: # Begin the file with the listed items/upgrades
# Screw Attack: 1
# Bomb: 1
# Speed Booster: 1
# Grappling Beam: 1
# Space Jump: 1
# Hi-Jump Boots: 1
# Spring Ball: 1
# Charge Beam: 1
# Ice Beam: 1
# Spazer: 1
# Reserve Tank: 4
# Missile: 46
# Super Missile: 20
# Power Bomb: 20
# Energy Tank: 14
# Morph Ball: 1
# X-Ray Scope: 1
# Wave Beam: 1
# Plasma Beam: 1
# Varia Suit: 1
# Gravity Suit: 1
start_inventory_removes_from_pool:
on: 0
off: 1
death_link:
on: 0
off: 1
preset: # choose one of the preset or specify "custom" to use customPreset option
newbie: 0
casual: 0
regular: 1
veteran: 0
expert: 0
master: 0
samus: 0
Season_Races: 0
SMRAT2021: 0
solution: 0
custom: 0 # see https://randommetroidsolver.pythonanywhere.com/presets for detailed info on each preset settings
varia_custom: 0 # use an entry from the preset list on https://randommetroidsolver.pythonanywhere.com/presets
varia_custom_preset: # use an entry from the preset list on https://randommetroidsolver.pythonanywhere.com/presets
regular
start_location:
Ceres: 0
Landing_Site: 1
Gauntlet_Top: 0
Green_Brinstar_Elevator: 0
Big_Pink: 0
Etecoons_Supers: 0
Wrecked_Ship_Main: 0
Firefleas_Top: 0
Business_Center: 0
Bubble_Mountain: 0
Mama_Turtle: 0
Watering_Hole: 0
Aqueduct: 0
Red_Brinstar_Elevator: 0
Golden_Four: 0
max_difficulty:
easy: 0
medium: 0
hard: 0
harder: 0
hardcore: 1
mania: 0
infinity: 0
morph_placement:
early: 1
normal: 0
suits_restriction:
on: 1
off: 0
strict_minors:
on: 0
off: 1
missile_qty: 30 # a range between 10 and 90 that is divided by 10 as a float
super_qty: 20 # a range between 10 and 90 that is divided by 10 as a float
power_bomb_qty: 10 # a range between 10 and 90 that is divided by 10 as a float
minor_qty: 100 # a range between 7 (minimum to beat the game) and 100
energy_qty:
ultra_sparse: 0
sparse: 0
medium: 0
vanilla: 1
area_randomization:
on: 0
light: 0
off: 1
area_layout:
on: 0
off: 1
doors_colors_rando:
on: 0
off: 1
allow_grey_doors:
on: 0
off: 1
boss_randomization:
on: 0
off: 1
fun_combat:
on: 0
off: 1
fun_movement:
on: 0
off: 1
fun_suits:
on: 0
off: 1
layout_patches:
on: 1
off: 0
varia_tweaks:
on: 0
off: 1
nerfed_charge:
on: 0
off: 1
gravity_behaviour:
Vanilla: 0
Balanced: 1
Progressive: 0
elevators_doors_speed:
on: 1
off: 0
spin_jump_restart:
on: 0
off: 1
infinite_space_jump:
on: 0
off: 1
refill_before_save:
on: 0
off: 1
hud:
on: 0
off: 1
animals:
on: 0
off: 1
no_music:
on: 0
off: 1
random_music:
on: 0
off: 1
#item_sounds: always forced on due to a conflict in patching
#majors_split: not supported always "Full"
#scav_num_locs: not supported always off
#scav_randomized: not supported always off
#scav_escape: not supported always off
#progression_speed: not supported always random
#progression_difficulty: not supported always random
#hide_items: not supported always off
#minimizer: not supported always off
#minimizer_qty: not supported always off
#minimizer_tourian: not supported always off
#escape_rando: not supported always off
#remove_escape_enemies: not supported always off
#rando_speed: not supported always off
custom_preset: # see https://randommetroidsolver.pythonanywhere.com/presets for detailed info on each preset settings
Knows: # each skill (know) has a pair [can use, perceived difficulty using one of the following values]
# easy = 1
# medium = 5
# hard = 10
# harder = 25
# hardcore = 50
# mania = 100
Mockball: [True, 1]
SimpleShortCharge: [True, 1]
InfiniteBombJump: [True, 5]
GreenGateGlitch: [True, 5]
ShortCharge: [False, 0]
GravityJump: [True, 10]
SpringBallJump: [True, 10]
SpringBallJumpFromWall: [False, 0]
GetAroundWallJump: [True, 10]
DraygonGrappleKill: [True, 5]
DraygonSparkKill: [False, 0]
MicrowaveDraygon: [True, 1]
MicrowavePhantoon: [True, 5]
IceZebSkip: [False, 0]
SpeedZebSkip: [False, 0]
HiJumpMamaTurtle: [False, 0]
GravLessLevel1: [True, 50]
GravLessLevel2: [False, 0]
GravLessLevel3: [False, 0]
CeilingDBoost: [True, 1]
BillyMays: [True, 1]
AlcatrazEscape: [True, 25]
ReverseGateGlitch: [True, 5]
ReverseGateGlitchHiJumpLess: [False, 0]
EarlyKraid: [True, 1]
XrayDboost: [False, 0]
XrayIce: [True, 10]
RedTowerClimb: [True, 25]
RonPopeilScrew: [False, 0]
OldMBWithSpeed: [False, 0]
Moondance: [False, 0]
HiJumpLessGauntletAccess: [True, 50]
HiJumpGauntletAccess: [True, 25]
LowGauntlet: [False, 0]
IceEscape: [False, 0]
WallJumpCathedralExit: [True, 5]
BubbleMountainWallJump: [True, 5]
NovaBoost: [False, 0]
NorfairReserveDBoost: [False, 0]
CrocPBsDBoost: [False, 0]
CrocPBsIce: [False, 0]
IceMissileFromCroc: [False, 0]
FrogSpeedwayWithoutSpeed: [False, 0]
LavaDive: [True, 50]
LavaDiveNoHiJump: [False, 0]
WorstRoomIceCharge: [False, 0]
ScrewAttackExit: [False, 0]
ScrewAttackExitWithoutScrew: [False, 0]
FirefleasWalljump: [True, 25]
ContinuousWallJump: [False, 0]
DiagonalBombJump: [False, 0]
MockballWs: [False, 0]
SpongeBathBombJump: [False, 0]
SpongeBathHiJump: [True, 1]
SpongeBathSpeed: [True, 5]
TediousMountEverest: [False, 0]
DoubleSpringBallJump: [False, 0]
BotwoonToDraygonWithIce: [False, 0]
DraygonRoomGrappleExit: [False, 0]
DraygonRoomCrystalFlash: [False, 0]
PreciousRoomXRayExit: [False, 0]
MochtroidClip: [True, 5]
PuyoClip: [False, 0]
PuyoClipXRay: [False, 0]
SnailClip: [False, 0]
SuitlessPuyoClip: [False, 0]
KillPlasmaPiratesWithSpark: [False, 0]
KillPlasmaPiratesWithCharge: [True, 5]
AccessSpringBallWithHiJump: [True, 1]
AccessSpringBallWithSpringBallBombJumps: [True, 10]
AccessSpringBallWithBombJumps: [False, 0]
AccessSpringBallWithSpringBallJump: [False, 0]
AccessSpringBallWithXRayClimb: [False, 0]
AccessSpringBallWithGravJump: [False, 0]
Controller:
A: Jump
B: Dash
X: Shoot
Y: Item Cancel
L: Angle Down
R: Angle Up
Select: Item Select
Moonwalk: False
Settings:
Ice: "Gimme energy"
MainUpperNorfair: "Gimme energy"
LowerNorfair: "Default"
Kraid: "Default"
Phantoon: "Default"
Draygon: "Default"
Ridley: "Default"
MotherBrain: "Default"
X-Ray: "I don't like spikes"
Gauntlet: "I don't like acid"
Subnautica: {}
Slay the Spire:
character: # Pick What Character you wish to play with.
@@ -481,13 +747,20 @@ A Link to the Past:
'on': 0 # Keys, items, and buttons hidden under pots in dungeons are shuffled with other pots in their supertile
'off': 50 # Default pot item locations
### End of Enemizer Section ###
beemizer: # Remove items from the global item pool and replace them with single bees (fill bottles) and bee traps
0: 50 # No bee traps are placed
1: 0 # 25% of rupees, bombs and arrows are replaced with bees, of which 60% are traps and 40% single bees
2: 0 # 50% of rupees, bombs and arrows are replaced with bees, of which 70% are traps and 30% single bees
3: 0 # 75% of rupees, bombs and arrows are replaced with bees, of which 80% are traps and 20% single bees
4: 0 # 100% of rupees, bombs and arrows are replaced with bees, of which 90% are traps and 10% single bees
5: 0 # 100% of rupees, bombs and arrows are replaced with bees, of which 100% are traps and 0% single bees
### Beemizer ###
# can add weights for any whole number between 0 and 100
beemizer_total_chance: # Remove items from the global item pool and replace them with single bees (fill bottles) and bee traps
0: 50 # No junk fill items are replaced (Beemizer is off)
25: 0 # 25% chance for each junk fill item (rupees, bombs and arrows) to be replaced with bees
50: 0 # 50% chance for each junk fill item (rupees, bombs and arrows) to be replaced with bees
75: 0 # 75% chance for each junk fill item (rupees, bombs and arrows) to be replaced with bees
100: 0 # All junk fill items (rupees, bombs and arrows) are replaced with bees
beemizer_trap_chance:
60: 50 # 60% chance for each beemizer replacement to be a trap, 40% chance to be a single bee
70: 0 # 70% chance for each beemizer replacement to be a trap, 30% chance to be a single bee
80: 0 # 80% chance for each beemizer replacement to be a trap, 20% chance to be a single bee
90: 0 # 90% chance for each beemizer replacement to be a trap, 10% chance to be a single bee
100: 0 # All beemizer replacements are traps
### Shop Settings ###
shop_item_slots: # Maximum amount of shop slots to be filled with regular item pool items (such as Moon Pearl)
0: 50
@@ -1408,4 +1681,4 @@ triggers:
percentage: 0 # AND has a 0 percent chance (meaning this is default disabled, just to show how it works)
options: # then inserts these options
A Link to the Past:
swordless: off
swordless: off

View File

@@ -1,8 +1,8 @@
colorama>=0.4.4
websockets>=10.0
PyYAML>=5.4.1
PyYAML>=6.0
fuzzywuzzy>=0.18.0
prompt_toolkit>=3.0.20
prompt_toolkit>=3.0.22
appdirs>=1.4.4
jinja2>=3.0.1
jinja2>=3.0.3
schema>=0.7.4

View File

@@ -7,8 +7,6 @@ import cx_Freeze
from kivy_deps import sdl2, glew
from Utils import version_tuple
is_64bits = sys.maxsize > 2 ** 32
arch_folder = "exe.{platform}-{version}".format(platform=sysconfig.get_platform(),
version=sysconfig.get_python_version())
buildfolder = Path("build", arch_folder)
@@ -75,8 +73,8 @@ scripts = {
"MultiServer.py": ("ArchipelagoServer", False, icon),
"Generate.py": ("ArchipelagoGenerate", False, icon),
"CommonClient.py": ("ArchipelagoTextClient", True, icon),
# LttP
"LttPClient.py": ("ArchipelagoLttPClient", True, icon),
# SNI
"SNIClient.py": ("ArchipelagoSNIClient", True, icon),
"LttPAdjuster.py": ("ArchipelagoLttPAdjuster", True, icon),
# Factorio
"FactorioClient.py": ("ArchipelagoFactorioClient", True, icon),
@@ -145,7 +143,14 @@ for data in extra_data:
installfile(Path(data))
os.makedirs(buildfolder / "Players", exist_ok=True)
shutil.copyfile("playerSettings.yaml", buildfolder / "Players" / "weightedSettings.yaml")
from WebHostLib.options import create
create()
from worlds.AutoWorld import AutoWorldRegister
for worldname, worldtype in AutoWorldRegister.world_types.items():
if not worldtype.hidden:
file_name = worldname+".yaml"
shutil.copyfile(os.path.join("WebHostLib", "static", "generated", "configs", file_name),
buildfolder / "Players" / file_name)
try:
from maseya import z3pr

View File

@@ -1,30 +0,0 @@
import unittest
from argparse import Namespace
from BaseClasses import MultiWorld
from worlds.AutoWorld import AutoWorldRegister, call_all
class TestBase(unittest.TestCase):
_state_cache = {}
gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill"]
def testAllStateCanReachEverything(self):
for game_name, world_type in AutoWorldRegister.world_types.items():
if game_name != "Ori and the Blind Forest": # TODO: fix Ori Logic
with self.subTest("Game", game=game_name):
world = MultiWorld(1)
world.game[1] = game_name
world.player_name = {1: "Tester"}
world.set_seed()
args = Namespace()
for name, option in world_type.options.items():
setattr(args, name, {1: option.from_any(option.default)})
world.set_options(args)
world.set_default_common_options()
for step in self.gen_steps:
call_all(world, step)
state = world.get_all_state(False)
for location in world.get_locations():
with self.subTest("Location should be reached", location=location):
self.assertTrue(location.can_reach(state))

12
test/general/TestItems.py Normal file
View File

@@ -0,0 +1,12 @@
import unittest
from worlds.AutoWorld import AutoWorldRegister
class TestBase(unittest.TestCase):
def testCreateItem(self):
for game_name, world_type in AutoWorldRegister.world_types.items():
proxy_world = world_type(None, 0) # this is identical to MultiServer.py creating worlds
for item_name in world_type.item_name_to_id:
with self.subTest("Create Item", item_name=item_name, game_name=game_name):
item = proxy_world.create_item(item_name)
self.assertEqual(item.name, item_name)

View File

@@ -0,0 +1,32 @@
import unittest
from BaseClasses import CollectionState
from worlds.AutoWorld import AutoWorldRegister
from . import setup_default_world
class TestBase(unittest.TestCase):
gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill"]
def testAllStateCanReachEverything(self):
for game_name, world_type in AutoWorldRegister.world_types.items():
if game_name != "Ori and the Blind Forest": # TODO: fix Ori Logic
with self.subTest("Game", game=game_name):
world = setup_default_world(world_type)
state = world.get_all_state(False)
for location in world.get_locations():
with self.subTest("Location should be reached", location=location):
self.assertTrue(location.can_reach(state))
def testEmptyStateCanReachSomething(self):
for game_name, world_type in AutoWorldRegister.world_types.items():
if game_name != "Archipelago":
with self.subTest("Game", game=game_name):
world = setup_default_world(world_type)
state = CollectionState(world)
locations = set()
for location in world.get_locations():
if location.can_reach(state):
locations.add(location)
self.assertGreater(len(locations), 0,
msg="Need to be able to reach at least one location to get started.")

View File

@@ -1,12 +1,8 @@
import unittest
from BaseClasses import MultiWorld
from worlds.AutoWorld import AutoWorldRegister
class TestBase(unittest.TestCase):
world: MultiWorld
_state_cache = {}
def testUniqueItems(self):
known_item_ids = set()
for gamename, world_type in AutoWorldRegister.world_types.items():

21
test/general/__init__.py Normal file
View File

@@ -0,0 +1,21 @@
from argparse import Namespace
from BaseClasses import MultiWorld
from worlds.AutoWorld import call_all
gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill"]
def setup_default_world(world_type):
world = MultiWorld(1)
world.game[1] = world_type.game
world.player_name = {1: "Tester"}
world.set_seed()
args = Namespace()
for name, option in world_type.options.items():
setattr(args, name, {1: option.from_any(option.default)})
world.set_options(args)
world.set_default_common_options()
for step in gen_steps:
call_all(world, step)
return world

View File

@@ -1132,9 +1132,14 @@ class TestAdvancements(TestMinecraft):
def test_42091(self):
self.run_location_tests([
["Overpowered", False, []],
["Overpowered", False, ['Progressive Resource Crafting'], ['Progressive Resource Crafting']],
["Overpowered", False, ['Progressive Tools'], ['Flint and Steel', 'Progressive Tools', 'Progressive Tools']],
["Overpowered", True, ['Progressive Resource Crafting', 'Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools']],
["Overpowered", True, ['Progressive Resource Crafting', 'Progressive Resource Crafting', 'Progressive Tools', 'Bucket', 'Flint and Steel']],
["Overpowered", False, [], ['Progressive Resource Crafting']],
["Overpowered", False, [], ['Flint and Steel']],
["Overpowered", False, ['Progressive Tools', 'Progressive Tools', 'Bucket', 'Flint and Steel']],
["Overpowered", False, [], ['Progressive Weapons']],
["Overpowered", False, [], ['Progressive Armor', 'Shield']],
["Overpowered", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Weapons', 'Progressive Armor']],
["Overpowered", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Progressive Armor']],
["Overpowered", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Weapons', 'Shield']],
["Overpowered", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons', 'Shield']],
])

View File

@@ -1,7 +1,8 @@
from __future__ import annotations
from typing import Dict, Set, Tuple, List, Optional
from typing import Dict, Set, Tuple, List, Optional, TextIO
from BaseClasses import MultiWorld, Item, CollectionState, Location
from Options import Option
class AutoWorldRegister(type):
@@ -67,7 +68,7 @@ class World(metaclass=AutoWorldRegister):
"""A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
A Game should have its own subclass of World in which it defines the required data structures."""
options: dict = {} # link your Options mapping
options: Dict[str, type(Option)] = {} # link your Options mapping
game: str # name the game
topology_present: bool = False # indicate if world type has any meaningful layout/pathing
all_names: Set[str] = frozenset() # gets automatically populated with all item, item group and location names
@@ -122,7 +123,7 @@ class World(metaclass=AutoWorldRegister):
self.player = player
# overridable methods that get called by Main.py, sorted by execution order
# can also be implemented as a classmethod and called "stage_<original_name",
# can also be implemented as a classmethod and called "stage_<original_name>",
# in that case the MultiWorld object is passed as an argument and it gets called once for the entire multiworld.
# An example of this can be found in alttp as stage_pre_fill
def generate_early(self):
@@ -144,6 +145,7 @@ class World(metaclass=AutoWorldRegister):
"""Optional method that is supposed to be used for special fill stages. This is run *after* plando."""
pass
@classmethod
def fill_hook(cls, progitempool: List[Item], nonexcludeditempool: List[Item],
localrestitempool: Dict[int, List[Item]], nonlocalrestitempool: Dict[int, List[Item]],
restitempool: List[Item], fill_locations: List[Location]):
@@ -168,11 +170,25 @@ class World(metaclass=AutoWorldRegister):
pass
def get_required_client_version(self) -> Tuple[int, int, int]:
return 0, 0, 3
return 0, 1, 6
# end of Main.py calls
# Spoiler writing is optional, these may not get called.
def write_spoiler_header(self, spoiler_handle: TextIO):
"""Write to the spoiler header. If individual it's right at the end of that player's options,
if as stage it's right under the common header before per-player options."""
pass
def collect_item(self, state: CollectionState, item: Item, remove=False) -> Optional[str]:
def write_spoiler(self, spoiler_handle: TextIO):
"""Write to the spoiler "middle", this is after the per-player options and before locations,
meant for useful or interesting info."""
pass
def write_spoiler_end(self, spoiler_handle: TextIO):
"""Write to the end of the spoiler"""
pass
# end of ordered Main.py calls
def collect_item(self, state: CollectionState, item: Item, remove: bool = False) -> Optional[str]:
"""Collect an item name into state. For speed reasons items that aren't logically useful get skipped.
Collect None to skip item.
:param remove: indicate if this is meant to remove from state instead of adding."""

View File

@@ -27,10 +27,6 @@ for world_name, world in AutoWorldRegister.world_types.items():
lookup_any_location_id_to_name.update(world.location_id_to_name)
network_data_package = {
# Remove with 0.2.0
"lookup_any_location_id_to_name": lookup_any_location_id_to_name, # legacy, to be removed
"lookup_any_item_id_to_name": lookup_any_item_id_to_name, # legacy, to be removed
"version": sum(world.data_version for world in AutoWorldRegister.world_types.values()),
"games": games,
}

View File

@@ -19,7 +19,7 @@ def parse_arguments(argv, no_defaults=False):
# we need to know how many players we have first
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument('--multi', default=defval(1), type=lambda value: min(max(int(value), 1), 255))
parser.add_argument('--multi', default=defval(1), type=lambda value: max(int(value), 1))
multiargs, _ = parser.parse_known_args(argv)
parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter)
@@ -221,7 +221,8 @@ def parse_arguments(argv, no_defaults=False):
parser.add_argument('--enemy_health', default=defval('default'),
choices=['default', 'easy', 'normal', 'hard', 'expert'])
parser.add_argument('--enemy_damage', default=defval('default'), choices=['default', 'shuffled', 'chaos'])
parser.add_argument('--beemizer', default=defval(0), type=lambda value: min(max(int(value), 0), 4))
parser.add_argument('--beemizer_total_chance', default=defval(0), type=lambda value: min(max(int(value), 0), 100))
parser.add_argument('--beemizer_trap_chance', default=defval(0), type=lambda value: min(max(int(value), 0), 100))
parser.add_argument('--shop_shuffle', default='', help='''\
combine letters for options:
g: generate default inventories for light and dark world shops, and unique shops
@@ -238,7 +239,7 @@ def parse_arguments(argv, no_defaults=False):
For unlit dark rooms, require the Lamp to be considered in logic by default.
Torches means additionally easily accessible Torches that can be lit with Fire Rod are considered doable.
None means full traversal through dark rooms without tools is considered doable.''')
parser.add_argument('--multi', default=defval(1), type=lambda value: min(max(int(value), 1), 255))
parser.add_argument('--multi', default=defval(1), type=lambda value: max(int(value), 1))
parser.add_argument('--names', default=defval(''))
parser.add_argument('--outputpath')
parser.add_argument('--game', default="A Link to the Past")
@@ -273,7 +274,7 @@ def parse_arguments(argv, no_defaults=False):
for name in ['logic', 'mode', 'goal', 'difficulty', 'item_functionality',
'shuffle', 'open_pyramid', 'timer',
'countdown_start_time', 'red_clock_time', 'blue_clock_time', 'green_clock_time',
'beemizer',
'beemizer_total_chance', 'beemizer_trap_chance',
'shufflebosses', 'enemy_health', 'enemy_damage',
'sprite',
"triforce_pieces_available",

View File

@@ -1,19 +1,20 @@
import typing
def GetBeemizerItem(world, player, item):
item_name = item if isinstance(item, str) else item.name
if world.beemizer[player] and item_name in trap_replaceable:
if world.random.random() < world.beemizer[player] * 0.25:
if world.random.random() < (0.5 + world.beemizer[player] * 0.1):
return "Bee Trap" if isinstance(item, str) else world.create_item("Bee Trap", player)
else:
return "Bee" if isinstance(item, str) else world.create_item("Bee", player)
else:
return item
else:
if item_name not in trap_replaceable:
return item
# first roll - replaceable item should be replaced, within beemizer_total_chance
if not world.beemizer_total_chance[player] or world.random.random() > (world.beemizer_total_chance[player] / 100):
return item
# second roll - bee replacement should be trap, within beemizer_trap_chance
if not world.beemizer_trap_chance[player] or world.random.random() > (world.beemizer_trap_chance[player] / 100):
return "Bee" if isinstance(item, str) else world.create_item("Bee", player)
else:
return "Bee Trap" if isinstance(item, str) else world.create_item("Bee Trap", player)
# should be replaced with direct world.create_item(item) call in the future
def ItemFactory(items, player: int):

View File

@@ -1,7 +1,6 @@
import typing
import random
from Options import Choice, Range, Option, Toggle, DefaultOnToggle
from Options import Choice, Range, Option, Toggle, DefaultOnToggle, DeathLink
class Logic(Choice):
@@ -231,13 +230,6 @@ class HeartColor(Choice):
option_green = 2
option_yellow = 3
@classmethod
def from_text(cls, text: str) -> Choice:
# remove when this becomes a base Choice feature
if text == "random":
return cls(random.randint(0, 3))
return super(HeartColor, cls).from_text(text)
class QuickSwap(DefaultOnToggle):
displayname = "L/R Quickswapping"
@@ -269,6 +261,26 @@ class TriforceHud(Choice):
option_hide_both = 3
class BeemizerRange(Range):
value: int
range_start = 0
range_end = 100
class BeemizerTotalChance(BeemizerRange):
"""Percentage chance for each junk-fill item (rupees, bombs, arrows) to be
replaced with either a bee swarm trap or a single bottle-filling bee."""
default = 0
displayname = "Beemizer Total Chance"
class BeemizerTrapChance(BeemizerRange):
"""Percentage chance for each replaced junk-fill item to be a bee swarm
trap; all other replaced items are single bottle-filling bees."""
default = 60
displayname = "Beemizer Trap Chance"
alttp_options: typing.Dict[str, type(Option)] = {
"crystals_needed_for_gt": CrystalsTower,
"crystals_needed_for_ganon": CrystalsGanon,
@@ -300,6 +312,9 @@ alttp_options: typing.Dict[str, type(Option)] = {
"music": Music,
"reduceflashing": ReduceFlashing,
"triforcehud": TriforceHud,
"glitch_boots": DefaultOnToggle
"glitch_boots": DefaultOnToggle,
"beemizer_total_chance": BeemizerTotalChance,
"beemizer_trap_chance": BeemizerTrapChance,
"death_link": DeathLink
}

View File

@@ -4,7 +4,8 @@ import Utils
from Patch import read_rom
JAP10HASH = '03a63945398191337e896e5771f77173'
RANDOMIZERBASEHASH = 'e397fef0e947d1bd760c68c4fe99a600'
RANDOMIZERBASEHASH = '9952c2a3ec1b421e408df0d20c8f0c7f'
ROM_PLAYER_LIMIT = 255
import io
import json
@@ -787,7 +788,7 @@ def patch_rom(world, rom, player, enemized):
rom.write_byte(location.player_address, 0xFF)
elif location.item.player != player:
if location.player_address is not None:
rom.write_byte(location.player_address, location.item.player)
rom.write_byte(location.player_address, min(location.item.player, ROM_PLAYER_LIMIT))
else:
itemid = 0x5A
location_address = old_location_address_to_new_location_address.get(location.address, location.address)
@@ -1653,8 +1654,10 @@ def patch_rom(world, rom, player, enemized):
rom.write_bytes(0x7FC0, rom.name)
# set player names
for p in range(1, min(world.players, 255) + 1):
for p in range(1, min(world.players, ROM_PLAYER_LIMIT) + 1):
rom.write_bytes(0x195FFC + ((p - 1) * 32), hud_format_text(world.player_name[p]))
if world.players > ROM_PLAYER_LIMIT:
rom.write_bytes(0x195FFC + ((ROM_PLAYER_LIMIT - 1) * 32), hud_format_text("Archipelago"))
# Write title screen Code
hashint = int(rom.get_hash(), 16)
@@ -1731,7 +1734,7 @@ def write_custom_shops(rom, world, player):
item_data = [shop_id, item_code] + price_data + \
[item['max'], ItemFactory(item['replacement'], player).code if item['replacement'] else 0xFF] + \
replacement_price_data + [0 if item['player'] == player else item['player']]
replacement_price_data + [0 if item['player'] == player else min(ROM_PLAYER_LIMIT, item['player'])]
items_data.extend(item_data)
rom.write_bytes(0x184800, shop_data)
@@ -1764,7 +1767,7 @@ def hud_format_text(text):
def apply_rom_settings(rom, beep, color, quickswap, menuspeed, music: bool, sprite: str, palettes_options,
world=None, player=1, allow_random_on_event=False, reduceflashing=False,
triforcehud: str = None):
triforcehud: str = None, deathlink: bool = False):
local_random = random if not world else world.slot_seeds[player]
disable_music: bool = not music
# enable instant item menu
@@ -1898,6 +1901,8 @@ def apply_rom_settings(rom, beep, color, quickswap, menuspeed, music: bool, spri
elif palettes_options['dungeon'] == 'random':
randomize_uw_palettes(rom, local_random)
rom.write_byte(0x18008D, int(deathlink))
apply_random_sprite_on_event(rom, sprite, local_random, allow_random_on_event,
world.sprite_pool[player] if world else [])
if isinstance(rom, LocalRom):

View File

@@ -15,8 +15,10 @@ def set_rules(world):
player = world.player
world = world.world
if world.logic[player] == 'nologic':
logging.info(
'WARNING! Seeds generated under this logic often require major glitches and may be impossible!')
if player == next(player_id for player_id in world.get_game_players("A Link to the Past")
if world.logic[player_id] == 'nologic'): # only warn one time
logging.info(
'WARNING! Seeds generated under this logic often require major glitches and may be impossible!')
if world.players == 1:
world.get_region('Menu', player).can_reach_private = lambda state: True
@@ -934,8 +936,9 @@ def set_trock_key_rules(world, player):
# A key is required in the Big Key Chest to prevent a possible softlock. Place an extra key to ensure 100% locations still works
item = ItemFactory('Small Key (Turtle Rock)', player)
item.world = world
world.push_item(world.get_location('Turtle Rock - Big Key Chest', player), item, False)
world.get_location('Turtle Rock - Big Key Chest', player).event = True
location = world.get_location('Turtle Rock - Big Key Chest', player)
location.place_locked_item(item)
location.event = True
toss_junk_item(world, player)
if world.accessibility[player] != 'locations':

View File

@@ -583,6 +583,6 @@ def price_to_funny_price(item: dict, world, player: int):
if any(x in item['item'] for x in price_blacklist[p_type]):
continue
else:
item['price'] = min(price_chart[p_type](item['price']) , 255)
item['price'] = min(price_chart[p_type](item['price']), 255)
item['price_type'] = p_type
break

View File

@@ -294,7 +294,8 @@ class ALTTPWorld(World):
world.sprite[player],
palettes_options, world, player, True,
reduceflashing=world.reduceflashing[player] or world.is_race,
triforcehud=world.triforcehud[player].current_key)
triforcehud=world.triforcehud[player].current_key,
deathlink=world.death_link[player])
outfilepname = f'_P{player}'
outfilepname += f"_{world.player_name[player].replace(' ', '_')}" \
@@ -323,7 +324,7 @@ class ALTTPWorld(World):
del (multidata["connect_names"][self.world.player_name[self.player]])
def get_required_client_version(self) -> tuple:
return max((0, 1, 4), super(ALTTPWorld, self).get_required_client_version())
return max((0, 2, 0), super(ALTTPWorld, self).get_required_client_version())
def create_item(self, name: str) -> Item:
return ALttPItem(name, self.player, **as_dict_item_table[name])

View File

@@ -29,7 +29,11 @@ base_info = {
"author": "Berserker",
"homepage": "https://archipelago.gg",
"description": "Integration client for the Archipelago Randomizer",
"factorio_version": "1.1"
"factorio_version": "1.1",
"dependencies": [
"base >= 1.1.0",
"? science-not-invited"
]
}
recipe_time_scales = {
@@ -73,7 +77,7 @@ def generate_mod(world, output_directory: str):
random = multiworld.slot_seeds[player]
def flop_random(low, high, base=None):
"""Guarentees 50% bwlo base and 50% above base, uniform distribution in each direction."""
"""Guarentees 50% below base and 50% above base, uniform distribution in each direction."""
if base:
distance = random.random()
if random.randint(0, 1):
@@ -95,7 +99,8 @@ def generate_mod(world, output_directory: str):
"free_sample_blacklist": {item : 1 for item in free_sample_blacklist},
"progressive_technology_table": {tech.name : tech.progressive for tech in
progressive_technology_table.values()},
"custom_recipes": world.custom_recipes}
"custom_recipes": world.custom_recipes,
"max_science_pack": multiworld.max_science_pack[player].value}
for factorio_option in Options.factorio_options:
template_data[factorio_option] = getattr(multiworld, factorio_option)[player].value

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import typing
from Options import Choice, OptionDict, Option, DefaultOnToggle, Range
from Options import Choice, OptionDict, ItemDict, Option, DefaultOnToggle, Range, DeathLink
from schema import Schema, Optional, And, Or
# schema helpers
@@ -120,8 +120,9 @@ class RecipeIngredients(Choice):
option_science_pack = 1
class FactorioStartItems(OptionDict):
class FactorioStartItems(ItemDict):
displayname = "Starting Items"
verify_item_name = False
default = {"burner-mining-drill": 19, "stone-furnace": 19}
@@ -299,4 +300,5 @@ factorio_options: typing.Dict[str, type(Option)] = {
"evolution_traps": EvolutionTrapCount,
"attack_traps": AttackTrapCount,
"evolution_trap_increase": EvolutionTrapIncrease,
"death_link": DeathLink
}

View File

@@ -85,7 +85,8 @@ class CustomTechnology(Technology):
def __init__(self, origin: Technology, world, allowed_packs: Set[str], player: int):
ingredients = origin.ingredients & allowed_packs
military_allowed = "military-science-pack" in allowed_packs \
and (ingredients & {"chemical-science-pack", "production-science-pack", "utility-science-pack"})
and ((ingredients & {"chemical-science-pack", "production-science-pack", "utility-science-pack"})
or origin.name == "rocket-silo")
self.player = player
if origin.name not in world.worlds[player].static_nodes:
if military_allowed:
@@ -156,10 +157,13 @@ class Recipe(FactorioElement):
total_energy = self.energy
for ingredient, cost in self.ingredients.items():
if ingredient in all_product_sources:
for ingredient_recipe in all_product_sources[ingredient]: # FIXME: this may select the wrong recipe
selected_recipe_energy = float('inf')
for ingredient_recipe in all_product_sources[ingredient]:
craft_count = max((n for name, n in ingredient_recipe.products.items() if name == ingredient))
total_energy += ingredient_recipe.total_energy / craft_count * cost
break
recipe_energy = ingredient_recipe.total_energy / craft_count * cost
if recipe_energy < selected_recipe_energy:
selected_recipe_energy = recipe_energy
total_energy += selected_recipe_energy
return total_energy

View File

@@ -167,6 +167,15 @@ class Factorio(World):
options = factorio_options
@classmethod
def stage_write_spoiler(cls, world, spoiler_handle):
factorio_players = world.get_game_players(cls.game)
spoiler_handle.write('\n\nFactorio Recipes:\n')
for player in factorio_players:
name = world.get_player_name(player)
for recipe in world.worlds[player].custom_recipes.values():
spoiler_handle.write(f"\n{recipe.name} ({name}): {recipe.ingredients} -> {recipe.products}")
def make_balanced_recipe(self, original: Recipe, pool: list, factor: float = 1) -> Recipe:
"""Generate a recipe from pool with time and cost similar to original * factor"""
new_ingredients = {}

View File

@@ -5,5 +5,9 @@
"author": "Berserker and Dewiniaid",
"homepage": "https://archipelago.gg",
"description": "Integration client for the Archipelago Randomizer",
"factorio_version": "1.1"
"factorio_version": "1.1",
"dependencies": [
"base >= 1.1.0",
"? science-not-invited"
]
}

View File

@@ -8,6 +8,9 @@ SLOT_NAME = "{{ slot_name }}"
SEED_NAME = "{{ seed_name }}"
FREE_SAMPLE_BLACKLIST = {{ dict_to_lua(free_sample_blacklist) }}
TRAP_EVO_FACTOR = {{ evolution_trap_increase }} / 100
DEATH_LINK = {{ death_link | int }}
CURRENTLY_DEATH_LOCK = 0
{% if not imported_blueprints -%}
function set_permissions()
@@ -57,6 +60,7 @@ function on_force_created(event)
local data = {}
data['earned_samples'] = {{ dict_to_lua(starting_items) }}
data["victory"] = 0
data["death_link_tick"] = 0
global.forcedata[event.force] = data
{%- if silo == 2 %}
check_spawn_silo(force)
@@ -200,6 +204,7 @@ end)
script.on_event(defines.events.on_research_finished, function(event)
local technology = event.research
if technology.researched and string.find(technology.name, "ap%-") == 1 then
-- check if it came from the server anyway, then we don't need to double send.
dumpInfo(technology.force) --is sendable
else
if FREE_SAMPLES == 0 then
@@ -249,6 +254,17 @@ function chain_lookup(table, ...)
return table
end
function kill_players(force)
CURRENTLY_DEATH_LOCK = 1
local current_character = nil
for _, player in ipairs(force.players) do
current_character = player.character
if current_character ~= nil then
current_character.die()
end
end
CURRENTLY_DEATH_LOCK = 0
end
function spawn_entity(surface, force, name, x, y, radius, randomize, avoid_ores)
local prototype = game.entity_prototypes[name]
@@ -350,6 +366,20 @@ function spawn_entity(surface, force, name, x, y, radius, randomize, avoid_ores)
end
if DEATH_LINK == 1 then
script.on_event(defines.events.on_entity_died, function(event)
if CURRENTLY_DEATH_LOCK == 1 then -- don't re-trigger on same event
return
end
local force = event.entity.force
global.forcedata[force.name].death_link_tick = game.tick
dumpInfo(force)
kill_players(force)
end, {LuaEntityDiedEventFilter = {["filter"] = "name", ["name"] = "character"}})
end
-- add / commands
commands.add_command("ap-sync", "Used by the Archipelago client to get progress information", function(call)
local force
@@ -362,7 +392,8 @@ commands.add_command("ap-sync", "Used by the Archipelago client to get progress
local data_collection = {
["research_done"] = research_done,
["victory"] = chain_lookup(global, "forcedata", force.name, "victory"),
}
["death_link_tick"] = chain_lookup(global, "forcedata", force.name, "death_link_tick")
}
for tech_name, tech in pairs(force.technologies) do
if tech.researched and string.find(tech_name, "ap%-") == 1 then
@@ -392,8 +423,8 @@ commands.add_command("ap-get-technology", "Grant a technology, used by the Archi
game.print({"", "Received [technology=" .. tech.name .. "] as it is already checked."})
game.play_sound({path="utility/research_completed"})
tech.researched = true
return
end
return
elseif progressive_technologies[item_name] ~= nil then
if global.index_sync[index] == nil then -- not yet received prog item
global.index_sync[index] = item_name
@@ -441,7 +472,7 @@ end)
commands.add_command("ap-rcon-info", "Used by the Archipelago client to get information", function(call)
rcon.print(game.table_to_json({["slot_name"] = SLOT_NAME, ["seed_name"] = SEED_NAME}))
rcon.print(game.table_to_json({["slot_name"] = SLOT_NAME, ["seed_name"] = SEED_NAME, ["death_link"] = DEATH_LINK}))
end)
@@ -452,5 +483,13 @@ end)
{% endif -%}
commands.add_command("ap-deathlink", "Kill all players", function(call)
local force = game.forces["player"]
local source = call.parameter or "Archipelago"
kill_players(force)
game.print("Death was granted by " .. source)
end)
-- data
progressive_technologies = {{ dict_to_lua(progressive_technology_table) }}

View File

@@ -26,11 +26,31 @@ template_tech.prerequisites = {}
function prep_copy(new_copy, old_tech)
old_tech.hidden = true
new_copy.unit = table.deepcopy(old_tech.unit)
local ingredient_filter = allowed_ingredients[old_tech.name]
if ingredient_filter ~= nil then
new_copy.unit.ingredients = filter_ingredients(new_copy.unit.ingredients, ingredient_filter)
new_copy.unit.ingredients = add_ingredients(new_copy.unit.ingredients, ingredient_filter)
if mods["science-not-invited"] then
local weights = {
["automation-science-pack"] = 0, -- Red science
["logistic-science-pack"] = 0, -- Green science
["military-science-pack"] = 0, -- Black science
["chemical-science-pack"] = 0, -- Blue science
["production-science-pack"] = 0, -- Purple science
["utility-science-pack"] = 0, -- Yellow science
["space-science-pack"] = 0 -- Space science
}
for key, value in pairs(ingredient_filter) do
weights[key] = value
end
SNI.setWeights(weights)
SNI.sendInvite(old_tech)
-- SCIENCE-not-invited could potentially make tech cost 9.223e+18.
old_tech.unit.count = math.min(10000, old_tech.unit.count)
end
new_copy.unit = table.deepcopy(old_tech.unit)
new_copy.unit.ingredients = filter_ingredients(new_copy.unit.ingredients, ingredient_filter)
new_copy.unit.ingredients = add_ingredients(new_copy.unit.ingredients, ingredient_filter)
else
new_copy.unit = table.deepcopy(old_tech.unit)
end
end

View File

@@ -1,2 +1,20 @@
{% from "macros.lua" import dict_to_lua %}
data.raw["map-gen-presets"].default["archipelago"] = {{ dict_to_lua({"default": False, "order": "a", "basic_settings": world_gen["basic"], "advanced_settings": world_gen["advanced"]}) }}
if mods["science-not-invited"] then
local weights = {
["automation-science-pack"] = 0, -- Red science
["logistic-science-pack"] = 0, -- Green science
["military-science-pack"] = 0, -- Black science
["chemical-science-pack"] = 0, -- Blue science
["production-science-pack"] = 0, -- Purple science
["utility-science-pack"] = 0, -- Yellow science
["space-science-pack"] = 0 -- Space science
}
{% if max_science_pack == 6 -%}
weights["space-science-pack"] = 1
{%- endif %}
{% for key in allowed_science_packs -%}
weights["{{key}}"] = 1
{% endfor %}
SNI.setWeights(weights)
end

View File

@@ -1,6 +1,8 @@
from typing import NamedTuple, Union
import logging
from BaseClasses import Item
from ..AutoWorld import World
@@ -11,11 +13,17 @@ class GenericWorld(World):
"Nothing": -1
}
location_name_to_id = {
"Cheat Console" : -1,
"Cheat Console": -1,
"Server": -2
}
hidden = True
def create_item(self, name: str) -> Item:
if name == "Nothing":
return Item(name, False, -1, self.player)
raise KeyError(name)
class PlandoItem(NamedTuple):
item: str
location: str

View File

@@ -107,7 +107,7 @@ advancement_table = {
"When Pigs Fly": AdvData(42088, 'Overworld'),
"Overkill": AdvData(42089, 'Nether Fortress'),
"Librarian": AdvData(42090, 'Overworld'),
"Overpowered": AdvData(42091, 'Overworld'),
"Overpowered": AdvData(42091, 'Bastion Remnant'),
"Blaze Spawner": AdvData(None, 'Nether Fortress'),
"Ender Dragon": AdvData(None, 'The End')
@@ -116,6 +116,7 @@ advancement_table = {
exclusion_table = {
"hard": {
"Very Very Frightening",
"A Furious Cocktail",
"Two by Two",
"Two Birds, One Arrow",
"Arbalistic",
@@ -125,13 +126,13 @@ exclusion_table = {
"Uneasy Alliance",
"Cover Me in Debris",
"A Complete Catalogue",
"Overpowered",
},
"insane": {
"How Did We Get Here?",
"Adventuring Time",
},
"postgame": {
"Free the End",
"The Next Generation",
"The End... Again...",
"You Need a Mint",

View File

@@ -101,7 +101,6 @@ class MinecraftLogic(LogicMixin):
def set_rules(world: MultiWorld, player: int):
def reachable_locations(state):
postgame_advancements = exclusion_table['postgame'].copy()
postgame_advancements.add('Free the End')
for event in events_table.keys():
postgame_advancements.add(event)
return [location for location in world.get_locations() if
@@ -248,4 +247,5 @@ def set_rules(world: MultiWorld, player: int):
set_rule(world.get_location("Overkill", player), lambda state: state._mc_can_brew_potions(player) and
(state.has("Progressive Weapons", player) or state.can_reach('The Nether', 'Region', player))) # strength 1 + stone axe crit OR strength 2 + wood axe crit
set_rule(world.get_location("Librarian", player), lambda state: state.has("Enchanting", player))
set_rule(world.get_location("Overpowered", player), lambda state: state.has("Progressive Resource Crafting", player, 2) and state._mc_has_gold_ingots(player))
set_rule(world.get_location("Overpowered", player), lambda state: state._mc_has_iron_ingots(player) and
state.has('Progressive Tools', player, 2) and state._mc_basic_combat(player)) # mine gold blocks w/ iron pick

View File

@@ -41,7 +41,7 @@ class MinecraftWorld(World):
'client_version': client_version,
'structures': {exit: self.world.get_entrance(exit, self.player).connected_region.name for exit in exits},
'advancement_goal': self.world.advancement_goal[self.player],
'egg_shards_required': self.world.egg_shards_required[self.player],
'egg_shards_required': min(self.world.egg_shards_required[self.player], self.world.egg_shards_available[self.player]),
'egg_shards_available': self.world.egg_shards_available[self.player],
'MC35': bool(self.world.send_defeated_mobs[self.player]),
'race': self.world.is_race

View File

@@ -5,8 +5,9 @@ from .Regions import TimeOfDay
class OOTEntrance(Entrance):
game: str = 'Ocarina of Time'
def __init__(self, player, name='', parent=None):
def __init__(self, player, world, name='', parent=None):
super(OOTEntrance, self).__init__(player, name, parent)
self.world = world
self.access_rules = []
self.reverse = None
self.replaces = None
@@ -17,3 +18,27 @@ class OOTEntrance(Entrance):
self.primary = False
self.always = False
self.never = False
def bind_two_way(self, other_entrance):
self.reverse = other_entrance
other_entrance.reverse = self
def disconnect(self):
self.connected_region.entrances.remove(self)
previously_connected = self.connected_region
self.connected_region = None
return previously_connected
def get_new_target(self):
root = self.world.get_region('Root Exits', self.player)
target_entrance = OOTEntrance(self.player, self.world, 'Root -> ' + self.connected_region.name, root)
target_entrance.connect(self.connected_region)
target_entrance.replaces = self
root.exits.append(target_entrance)
return target_entrance
def assume_reachable(self):
if self.assumed == None:
self.assumed = self.get_new_target()
self.disconnect()
return self.assumed

View File

@@ -1,25 +1,771 @@
from itertools import chain
import logging
from worlds.generic.Rules import set_rule
from .Hints import get_hint_area, HintAreaNotFound
from .Regions import TimeOfDay
def set_all_entrances_data(world, player):
for type, forward_entry, *return_entry in entrance_shuffle_table:
forward_entrance = world.get_entrance(forward_entry[0], player)
forward_entrance.data = forward_entry[1]
forward_entrance.type = type
forward_entrance.primary = True
if type == 'Grotto':
forward_entrance.data['index'] = 0x1000 + forward_entrance.data['grotto_id']
if return_entry:
return_entry = return_entry[0]
return_entrance = world.get_entrance(return_entry[0], player)
return_entrance.data = return_entry[1]
return_entrance.type = type
forward_entrance.bind_two_way(return_entrance)
if type == 'Grotto':
return_entrance.data['index'] = 0x7FFF
def assume_entrance_pool(entrance_pool, ootworld):
assumed_pool = []
for entrance in entrance_pool:
assumed_forward = entrance.assume_reachable()
if entrance.reverse != None:
assumed_return = entrance.reverse.assume_reachable()
if (entrance.type in ('Dungeon', 'Grotto', 'Grave') and entrance.reverse.name != 'Spirit Temple Lobby -> Desert Colossus From Spirit Lobby') or \
(entrance.type == 'Interior' and ootworld.shuffle_special_interior_entrances):
# In most cases, Dungeon, Grotto/Grave and Simple Interior exits shouldn't be assumed able to give access to their parent region
set_rule(assumed_return, lambda state, **kwargs: False)
assumed_forward.bind_two_way(assumed_return)
assumed_pool.append(assumed_forward)
return assumed_pool
def build_one_way_targets(world, types_to_include, exclude=(), target_region_names=()):
one_way_entrances = []
for pool_type in types_to_include:
one_way_entrances += world.get_shufflable_entrances(type=pool_type)
valid_one_way_entrances = list(filter(lambda entrance: entrance.name not in exclude, one_way_entrances))
if target_region_names:
return [entrance.get_new_target() for entrance in valid_one_way_entrances
if entrance.connected_region.name in target_region_names]
return [entrance.get_new_target() for entrance in valid_one_way_entrances]
# Abbreviations
# DMC Death Mountain Crater
# DMT Death Mountain Trail
# GC Goron City
# GF Gerudo Fortress
# GS Gold Skulltula
# GV Gerudo Valley
# HC Hyrule Castle
# HF Hyrule Field
# KF Kokiri Forest
# LH Lake Hylia
# LLR Lon Lon Ranch
# LW Lost Woods
# OGC Outside Ganon's Castle
# SFM Sacred Forest Meadow
# ToT Temple of Time
# ZD Zora's Domain
# ZF Zora's Fountain
# ZR Zora's River
entrance_shuffle_table = [
('Dungeon', ('KF Outside Deku Tree -> Deku Tree Lobby', { 'index': 0x0000 }),
('Deku Tree Lobby -> KF Outside Deku Tree', { 'index': 0x0209, 'blue_warp': 0x0457 })),
('Dungeon', ('Death Mountain -> Dodongos Cavern Beginning', { 'index': 0x0004 }),
('Dodongos Cavern Beginning -> Death Mountain', { 'index': 0x0242, 'blue_warp': 0x047A })),
('Dungeon', ('Zoras Fountain -> Jabu Jabus Belly Beginning', { 'index': 0x0028 }),
('Jabu Jabus Belly Beginning -> Zoras Fountain', { 'index': 0x0221, 'blue_warp': 0x010E })),
('Dungeon', ('SFM Forest Temple Entrance Ledge -> Forest Temple Lobby', { 'index': 0x0169 }),
('Forest Temple Lobby -> SFM Forest Temple Entrance Ledge', { 'index': 0x0215, 'blue_warp': 0x0608 })),
('Dungeon', ('DMC Fire Temple Entrance -> Fire Temple Lower', { 'index': 0x0165 }),
('Fire Temple Lower -> DMC Fire Temple Entrance', { 'index': 0x024A, 'blue_warp': 0x0564 })),
('Dungeon', ('Lake Hylia -> Water Temple Lobby', { 'index': 0x0010 }),
('Water Temple Lobby -> Lake Hylia', { 'index': 0x021D, 'blue_warp': 0x060C })),
('Dungeon', ('Desert Colossus -> Spirit Temple Lobby', { 'index': 0x0082 }),
('Spirit Temple Lobby -> Desert Colossus From Spirit Lobby', { 'index': 0x01E1, 'blue_warp': 0x0610 })),
('Dungeon', ('Graveyard Warp Pad Region -> Shadow Temple Entryway', { 'index': 0x0037 }),
('Shadow Temple Entryway -> Graveyard Warp Pad Region', { 'index': 0x0205, 'blue_warp': 0x0580 })),
('Dungeon', ('Kakariko Village -> Bottom of the Well', { 'index': 0x0098 }),
('Bottom of the Well -> Kakariko Village', { 'index': 0x02A6 })),
('Dungeon', ('ZF Ice Ledge -> Ice Cavern Beginning', { 'index': 0x0088 }),
('Ice Cavern Beginning -> ZF Ice Ledge', { 'index': 0x03D4 })),
('Dungeon', ('Gerudo Fortress -> Gerudo Training Grounds Lobby', { 'index': 0x0008 }),
('Gerudo Training Grounds Lobby -> Gerudo Fortress', { 'index': 0x03A8 })),
('Interior', ('Kokiri Forest -> KF Midos House', { 'index': 0x0433 }),
('KF Midos House -> Kokiri Forest', { 'index': 0x0443 })),
('Interior', ('Kokiri Forest -> KF Sarias House', { 'index': 0x0437 }),
('KF Sarias House -> Kokiri Forest', { 'index': 0x0447 })),
('Interior', ('Kokiri Forest -> KF House of Twins', { 'index': 0x009C }),
('KF House of Twins -> Kokiri Forest', { 'index': 0x033C })),
('Interior', ('Kokiri Forest -> KF Know It All House', { 'index': 0x00C9 }),
('KF Know It All House -> Kokiri Forest', { 'index': 0x026A })),
('Interior', ('Kokiri Forest -> KF Kokiri Shop', { 'index': 0x00C1 }),
('KF Kokiri Shop -> Kokiri Forest', { 'index': 0x0266 })),
('Interior', ('Lake Hylia -> LH Lab', { 'index': 0x0043 }),
('LH Lab -> Lake Hylia', { 'index': 0x03CC })),
('Interior', ('LH Fishing Island -> LH Fishing Hole', { 'index': 0x045F }),
('LH Fishing Hole -> LH Fishing Island', { 'index': 0x0309 })),
('Interior', ('GV Fortress Side -> GV Carpenter Tent', { 'index': 0x03A0 }),
('GV Carpenter Tent -> GV Fortress Side', { 'index': 0x03D0 })),
('Interior', ('Market Entrance -> Market Guard House', { 'index': 0x007E }),
('Market Guard House -> Market Entrance', { 'index': 0x026E })),
('Interior', ('Market -> Market Mask Shop', { 'index': 0x0530 }),
('Market Mask Shop -> Market', { 'index': 0x01D1, 'addresses': [0xC6DA5E] })),
('Interior', ('Market -> Market Bombchu Bowling', { 'index': 0x0507 }),
('Market Bombchu Bowling -> Market', { 'index': 0x03BC })),
('Interior', ('Market -> Market Potion Shop', { 'index': 0x0388 }),
('Market Potion Shop -> Market', { 'index': 0x02A2 })),
('Interior', ('Market -> Market Treasure Chest Game', { 'index': 0x0063 }),
('Market Treasure Chest Game -> Market', { 'index': 0x01D5 })),
('Interior', ('Market Back Alley -> Market Bombchu Shop', { 'index': 0x0528 }),
('Market Bombchu Shop -> Market Back Alley', { 'index': 0x03C0 })),
('Interior', ('Market Back Alley -> Market Man in Green House', { 'index': 0x043B }),
('Market Man in Green House -> Market Back Alley', { 'index': 0x0067 })),
('Interior', ('Kakariko Village -> Kak Carpenter Boss House', { 'index': 0x02FD }),
('Kak Carpenter Boss House -> Kakariko Village', { 'index': 0x0349 })),
('Interior', ('Kakariko Village -> Kak House of Skulltula', { 'index': 0x0550 }),
('Kak House of Skulltula -> Kakariko Village', { 'index': 0x04EE })),
('Interior', ('Kakariko Village -> Kak Impas House', { 'index': 0x039C }),
('Kak Impas House -> Kakariko Village', { 'index': 0x0345 })),
('Interior', ('Kak Impas Ledge -> Kak Impas House Back', { 'index': 0x05C8 }),
('Kak Impas House Back -> Kak Impas Ledge', { 'index': 0x05DC })),
('Interior', ('Kak Backyard -> Kak Odd Medicine Building', { 'index': 0x0072 }),
('Kak Odd Medicine Building -> Kak Backyard', { 'index': 0x034D })),
('Interior', ('Graveyard -> Graveyard Dampes House', { 'index': 0x030D }),
('Graveyard Dampes House -> Graveyard', { 'index': 0x0355 })),
('Interior', ('Goron City -> GC Shop', { 'index': 0x037C }),
('GC Shop -> Goron City', { 'index': 0x03FC })),
('Interior', ('Zoras Domain -> ZD Shop', { 'index': 0x0380 }),
('ZD Shop -> Zoras Domain', { 'index': 0x03C4 })),
('Interior', ('Lon Lon Ranch -> LLR Talons House', { 'index': 0x004F }),
('LLR Talons House -> Lon Lon Ranch', { 'index': 0x0378 })),
('Interior', ('Lon Lon Ranch -> LLR Stables', { 'index': 0x02F9 }),
('LLR Stables -> Lon Lon Ranch', { 'index': 0x042F })),
('Interior', ('Lon Lon Ranch -> LLR Tower', { 'index': 0x05D0 }),
('LLR Tower -> Lon Lon Ranch', { 'index': 0x05D4 })),
('Interior', ('Market -> Market Bazaar', { 'index': 0x052C }),
('Market Bazaar -> Market', { 'index': 0x03B8, 'addresses': [0xBEFD74] })),
('Interior', ('Market -> Market Shooting Gallery', { 'index': 0x016D }),
('Market Shooting Gallery -> Market', { 'index': 0x01CD, 'addresses': [0xBEFD7C] })),
('Interior', ('Kakariko Village -> Kak Bazaar', { 'index': 0x00B7 }),
('Kak Bazaar -> Kakariko Village', { 'index': 0x0201, 'addresses': [0xBEFD72] })),
('Interior', ('Kakariko Village -> Kak Shooting Gallery', { 'index': 0x003B }),
('Kak Shooting Gallery -> Kakariko Village', { 'index': 0x0463, 'addresses': [0xBEFD7A] })),
('Interior', ('Desert Colossus -> Colossus Great Fairy Fountain', { 'index': 0x0588 }),
('Colossus Great Fairy Fountain -> Desert Colossus', { 'index': 0x057C, 'addresses': [0xBEFD82] })),
('Interior', ('Hyrule Castle Grounds -> HC Great Fairy Fountain', { 'index': 0x0578 }),
('HC Great Fairy Fountain -> Castle Grounds', { 'index': 0x0340, 'addresses': [0xBEFD80] })),
('Interior', ('Ganons Castle Grounds -> OGC Great Fairy Fountain', { 'index': 0x04C2 }),
('OGC Great Fairy Fountain -> Castle Grounds', { 'index': 0x0340, 'addresses': [0xBEFD6C] })),
('Interior', ('DMC Lower Nearby -> DMC Great Fairy Fountain', { 'index': 0x04BE }),
('DMC Great Fairy Fountain -> DMC Lower Local', { 'index': 0x0482, 'addresses': [0xBEFD6A] })),
('Interior', ('Death Mountain Summit -> DMT Great Fairy Fountain', { 'index': 0x0315 }),
('DMT Great Fairy Fountain -> Death Mountain Summit', { 'index': 0x045B, 'addresses': [0xBEFD68] })),
('Interior', ('Zoras Fountain -> ZF Great Fairy Fountain', { 'index': 0x0371 }),
('ZF Great Fairy Fountain -> Zoras Fountain', { 'index': 0x0394, 'addresses': [0xBEFD7E] })),
('SpecialInterior', ('Kokiri Forest -> KF Links House', { 'index': 0x0272 }),
('KF Links House -> Kokiri Forest', { 'index': 0x0211 })),
('SpecialInterior', ('ToT Entrance -> Temple of Time', { 'index': 0x0053 }),
('Temple of Time -> ToT Entrance', { 'index': 0x0472 })),
('SpecialInterior', ('Kakariko Village -> Kak Windmill', { 'index': 0x0453 }),
('Kak Windmill -> Kakariko Village', { 'index': 0x0351 })),
('SpecialInterior', ('Kakariko Village -> Kak Potion Shop Front', { 'index': 0x0384 }),
('Kak Potion Shop Front -> Kakariko Village', { 'index': 0x044B })),
('SpecialInterior', ('Kak Backyard -> Kak Potion Shop Back', { 'index': 0x03EC }),
('Kak Potion Shop Back -> Kak Backyard', { 'index': 0x04FF })),
('Grotto', ('Desert Colossus -> Colossus Grotto', { 'grotto_id': 0x00, 'entrance': 0x05BC, 'content': 0xFD, 'scene': 0x5C }),
('Colossus Grotto -> Desert Colossus', { 'grotto_id': 0x00 })),
('Grotto', ('Lake Hylia -> LH Grotto', { 'grotto_id': 0x01, 'entrance': 0x05A4, 'content': 0xEF, 'scene': 0x57 }),
('LH Grotto -> Lake Hylia', { 'grotto_id': 0x01 })),
('Grotto', ('Zora River -> ZR Storms Grotto', { 'grotto_id': 0x02, 'entrance': 0x05BC, 'content': 0xEB, 'scene': 0x54 }),
('ZR Storms Grotto -> Zora River', { 'grotto_id': 0x02 })),
('Grotto', ('Zora River -> ZR Fairy Grotto', { 'grotto_id': 0x03, 'entrance': 0x036D, 'content': 0xE6, 'scene': 0x54 }),
('ZR Fairy Grotto -> Zora River', { 'grotto_id': 0x03 })),
('Grotto', ('Zora River -> ZR Open Grotto', { 'grotto_id': 0x04, 'entrance': 0x003F, 'content': 0x29, 'scene': 0x54 }),
('ZR Open Grotto -> Zora River', { 'grotto_id': 0x04 })),
('Grotto', ('DMC Lower Nearby -> DMC Hammer Grotto', { 'grotto_id': 0x05, 'entrance': 0x05A4, 'content': 0xF9, 'scene': 0x61 }),
('DMC Hammer Grotto -> DMC Lower Local', { 'grotto_id': 0x05 })),
('Grotto', ('DMC Upper Nearby -> DMC Upper Grotto', { 'grotto_id': 0x06, 'entrance': 0x003F, 'content': 0x7A, 'scene': 0x61 }),
('DMC Upper Grotto -> DMC Upper Local', { 'grotto_id': 0x06 })),
('Grotto', ('GC Grotto Platform -> GC Grotto', { 'grotto_id': 0x07, 'entrance': 0x05A4, 'content': 0xFB, 'scene': 0x62 }),
('GC Grotto -> GC Grotto Platform', { 'grotto_id': 0x07 })),
('Grotto', ('Death Mountain -> DMT Storms Grotto', { 'grotto_id': 0x08, 'entrance': 0x003F, 'content': 0x57, 'scene': 0x60 }),
('DMT Storms Grotto -> Death Mountain', { 'grotto_id': 0x08 })),
('Grotto', ('Death Mountain Summit -> DMT Cow Grotto', { 'grotto_id': 0x09, 'entrance': 0x05FC, 'content': 0xF8, 'scene': 0x60 }),
('DMT Cow Grotto -> Death Mountain Summit', { 'grotto_id': 0x09 })),
('Grotto', ('Kak Backyard -> Kak Open Grotto', { 'grotto_id': 0x0A, 'entrance': 0x003F, 'content': 0x28, 'scene': 0x52 }),
('Kak Open Grotto -> Kak Backyard', { 'grotto_id': 0x0A })),
('Grotto', ('Kakariko Village -> Kak Redead Grotto', { 'grotto_id': 0x0B, 'entrance': 0x05A0, 'content': 0xE7, 'scene': 0x52 }),
('Kak Redead Grotto -> Kakariko Village', { 'grotto_id': 0x0B })),
('Grotto', ('Hyrule Castle Grounds -> HC Storms Grotto', { 'grotto_id': 0x0C, 'entrance': 0x05B8, 'content': 0xF6, 'scene': 0x5F }),
('HC Storms Grotto -> Castle Grounds', { 'grotto_id': 0x0C })),
('Grotto', ('Hyrule Field -> HF Tektite Grotto', { 'grotto_id': 0x0D, 'entrance': 0x05C0, 'content': 0xE1, 'scene': 0x51 }),
('HF Tektite Grotto -> Hyrule Field', { 'grotto_id': 0x0D })),
('Grotto', ('Hyrule Field -> HF Near Kak Grotto', { 'grotto_id': 0x0E, 'entrance': 0x0598, 'content': 0xE5, 'scene': 0x51 }),
('HF Near Kak Grotto -> Hyrule Field', { 'grotto_id': 0x0E })),
('Grotto', ('Hyrule Field -> HF Fairy Grotto', { 'grotto_id': 0x0F, 'entrance': 0x036D, 'content': 0xFF, 'scene': 0x51 }),
('HF Fairy Grotto -> Hyrule Field', { 'grotto_id': 0x0F })),
('Grotto', ('Hyrule Field -> HF Near Market Grotto', { 'grotto_id': 0x10, 'entrance': 0x003F, 'content': 0x00, 'scene': 0x51 }),
('HF Near Market Grotto -> Hyrule Field', { 'grotto_id': 0x10 })),
('Grotto', ('Hyrule Field -> HF Cow Grotto', { 'grotto_id': 0x11, 'entrance': 0x05A8, 'content': 0xE4, 'scene': 0x51 }),
('HF Cow Grotto -> Hyrule Field', { 'grotto_id': 0x11 })),
('Grotto', ('Hyrule Field -> HF Inside Fence Grotto', { 'grotto_id': 0x12, 'entrance': 0x059C, 'content': 0xE6, 'scene': 0x51 }),
('HF Inside Fence Grotto -> Hyrule Field', { 'grotto_id': 0x12 })),
('Grotto', ('Hyrule Field -> HF Open Grotto', { 'grotto_id': 0x13, 'entrance': 0x003F, 'content': 0x03, 'scene': 0x51 }),
('HF Open Grotto -> Hyrule Field', { 'grotto_id': 0x13 })),
('Grotto', ('Hyrule Field -> HF Southeast Grotto', { 'grotto_id': 0x14, 'entrance': 0x003F, 'content': 0x22, 'scene': 0x51 }),
('HF Southeast Grotto -> Hyrule Field', { 'grotto_id': 0x14 })),
('Grotto', ('Lon Lon Ranch -> LLR Grotto', { 'grotto_id': 0x15, 'entrance': 0x05A4, 'content': 0xFC, 'scene': 0x63 }),
('LLR Grotto -> Lon Lon Ranch', { 'grotto_id': 0x15 })),
('Grotto', ('SFM Entryway -> SFM Wolfos Grotto', { 'grotto_id': 0x16, 'entrance': 0x05B4, 'content': 0xED, 'scene': 0x56 }),
('SFM Wolfos Grotto -> SFM Entryway', { 'grotto_id': 0x16 })),
('Grotto', ('Sacred Forest Meadow -> SFM Storms Grotto', { 'grotto_id': 0x17, 'entrance': 0x05BC, 'content': 0xEE, 'scene': 0x56 }),
('SFM Storms Grotto -> Sacred Forest Meadow', { 'grotto_id': 0x17 })),
('Grotto', ('Sacred Forest Meadow -> SFM Fairy Grotto', { 'grotto_id': 0x18, 'entrance': 0x036D, 'content': 0xFF, 'scene': 0x56 }),
('SFM Fairy Grotto -> Sacred Forest Meadow', { 'grotto_id': 0x18 })),
('Grotto', ('LW Beyond Mido -> LW Scrubs Grotto', { 'grotto_id': 0x19, 'entrance': 0x05B0, 'content': 0xF5, 'scene': 0x5B }),
('LW Scrubs Grotto -> LW Beyond Mido', { 'grotto_id': 0x19 })),
('Grotto', ('Lost Woods -> LW Near Shortcuts Grotto', { 'grotto_id': 0x1A, 'entrance': 0x003F, 'content': 0x14, 'scene': 0x5B }),
('LW Near Shortcuts Grotto -> Lost Woods', { 'grotto_id': 0x1A })),
('Grotto', ('Kokiri Forest -> KF Storms Grotto', { 'grotto_id': 0x1B, 'entrance': 0x003F, 'content': 0x2C, 'scene': 0x55 }),
('KF Storms Grotto -> Kokiri Forest', { 'grotto_id': 0x1B })),
('Grotto', ('Zoras Domain -> ZD Storms Grotto', { 'grotto_id': 0x1C, 'entrance': 0x036D, 'content': 0xFF, 'scene': 0x58 }),
('ZD Storms Grotto -> Zoras Domain', { 'grotto_id': 0x1C })),
('Grotto', ('Gerudo Fortress -> GF Storms Grotto', { 'grotto_id': 0x1D, 'entrance': 0x036D, 'content': 0xFF, 'scene': 0x5D }),
('GF Storms Grotto -> Gerudo Fortress', { 'grotto_id': 0x1D })),
('Grotto', ('GV Fortress Side -> GV Storms Grotto', { 'grotto_id': 0x1E, 'entrance': 0x05BC, 'content': 0xF0, 'scene': 0x5A }),
('GV Storms Grotto -> GV Fortress Side', { 'grotto_id': 0x1E })),
('Grotto', ('GV Grotto Ledge -> GV Octorok Grotto', { 'grotto_id': 0x1F, 'entrance': 0x05AC, 'content': 0xF2, 'scene': 0x5A }),
('GV Octorok Grotto -> GV Grotto Ledge', { 'grotto_id': 0x1F })),
('Grotto', ('LW Beyond Mido -> Deku Theater', { 'grotto_id': 0x20, 'entrance': 0x05C4, 'content': 0xF3, 'scene': 0x5B }),
('Deku Theater -> LW Beyond Mido', { 'grotto_id': 0x20 })),
('Grave', ('Graveyard -> Graveyard Shield Grave', { 'index': 0x004B }),
('Graveyard Shield Grave -> Graveyard', { 'index': 0x035D })),
('Grave', ('Graveyard -> Graveyard Heart Piece Grave', { 'index': 0x031C }),
('Graveyard Heart Piece Grave -> Graveyard', { 'index': 0x0361 })),
('Grave', ('Graveyard -> Graveyard Composers Grave', { 'index': 0x002D }),
('Graveyard Composers Grave -> Graveyard', { 'index': 0x050B })),
('Grave', ('Graveyard -> Graveyard Dampes Grave', { 'index': 0x044F }),
('Graveyard Dampes Grave -> Graveyard', { 'index': 0x0359 })),
('Overworld', ('Kokiri Forest -> LW Bridge From Forest', { 'index': 0x05E0 }),
('LW Bridge -> Kokiri Forest', { 'index': 0x020D })),
('Overworld', ('Kokiri Forest -> Lost Woods', { 'index': 0x011E }),
('LW Forest Exit -> Kokiri Forest', { 'index': 0x0286 })),
('Overworld', ('Lost Woods -> GC Woods Warp', { 'index': 0x04E2 }),
('GC Woods Warp -> Lost Woods', { 'index': 0x04D6 })),
('Overworld', ('Lost Woods -> Zora River', { 'index': 0x01DD }),
('Zora River -> Lost Woods', { 'index': 0x04DA })),
('Overworld', ('LW Beyond Mido -> SFM Entryway', { 'index': 0x00FC }),
('SFM Entryway -> LW Beyond Mido', { 'index': 0x01A9 })),
('Overworld', ('LW Bridge -> Hyrule Field', { 'index': 0x0185 }),
('Hyrule Field -> LW Bridge', { 'index': 0x04DE })),
('Overworld', ('Hyrule Field -> Lake Hylia', { 'index': 0x0102 }),
('Lake Hylia -> Hyrule Field', { 'index': 0x0189 })),
('Overworld', ('Hyrule Field -> Gerudo Valley', { 'index': 0x0117 }),
('Gerudo Valley -> Hyrule Field', { 'index': 0x018D })),
('Overworld', ('Hyrule Field -> Market Entrance', { 'index': 0x0276 }),
('Market Entrance -> Hyrule Field', { 'index': 0x01FD })),
('Overworld', ('Hyrule Field -> Kakariko Village', { 'index': 0x00DB }),
('Kakariko Village -> Hyrule Field', { 'index': 0x017D })),
('Overworld', ('Hyrule Field -> ZR Front', { 'index': 0x00EA }),
('ZR Front -> Hyrule Field', { 'index': 0x0181 })),
('Overworld', ('Hyrule Field -> Lon Lon Ranch', { 'index': 0x0157 }),
('Lon Lon Ranch -> Hyrule Field', { 'index': 0x01F9 })),
('Overworld', ('Lake Hylia -> Zoras Domain', { 'index': 0x0328 }),
('Zoras Domain -> Lake Hylia', { 'index': 0x0560 })),
('Overworld', ('GV Fortress Side -> Gerudo Fortress', { 'index': 0x0129 }),
('Gerudo Fortress -> GV Fortress Side', { 'index': 0x022D })),
('Overworld', ('GF Outside Gate -> Wasteland Near Fortress', { 'index': 0x0130 }),
('Wasteland Near Fortress -> GF Outside Gate', { 'index': 0x03AC })),
('Overworld', ('Wasteland Near Colossus -> Desert Colossus', { 'index': 0x0123 }),
('Desert Colossus -> Wasteland Near Colossus', { 'index': 0x0365 })),
('Overworld', ('Market Entrance -> Market', { 'index': 0x00B1 }),
('Market -> Market Entrance', { 'index': 0x0033 })),
('Overworld', ('Market -> Castle Grounds', { 'index': 0x0138 }),
('Castle Grounds -> Market', { 'index': 0x025A })),
('Overworld', ('Market -> ToT Entrance', { 'index': 0x0171 }),
('ToT Entrance -> Market', { 'index': 0x025E })),
('Overworld', ('Kakariko Village -> Graveyard', { 'index': 0x00E4 }),
('Graveyard -> Kakariko Village', { 'index': 0x0195 })),
('Overworld', ('Kak Behind Gate -> Death Mountain', { 'index': 0x013D }),
('Death Mountain -> Kak Behind Gate', { 'index': 0x0191 })),
('Overworld', ('Death Mountain -> Goron City', { 'index': 0x014D }),
('Goron City -> Death Mountain', { 'index': 0x01B9 })),
('Overworld', ('GC Darunias Chamber -> DMC Lower Local', { 'index': 0x0246 }),
('DMC Lower Nearby -> GC Darunias Chamber', { 'index': 0x01C1 })),
('Overworld', ('Death Mountain Summit -> DMC Upper Local', { 'index': 0x0147 }),
('DMC Upper Nearby -> Death Mountain Summit', { 'index': 0x01BD })),
('Overworld', ('ZR Behind Waterfall -> Zoras Domain', { 'index': 0x0108 }),
('Zoras Domain -> ZR Behind Waterfall', { 'index': 0x019D })),
('Overworld', ('ZD Behind King Zora -> Zoras Fountain', { 'index': 0x0225 }),
('Zoras Fountain -> ZD Behind King Zora', { 'index': 0x01A1 })),
('OwlDrop', ('LH Owl Flight -> Hyrule Field', { 'index': 0x027E, 'addresses': [0xAC9F26] })),
('OwlDrop', ('DMT Owl Flight -> Kak Impas Rooftop', { 'index': 0x0554, 'addresses': [0xAC9EF2] })),
('Spawn', ('Child Spawn -> KF Links House', { 'index': 0x00BB, 'addresses': [0xB06342] })),
('Spawn', ('Adult Spawn -> Temple of Time', { 'index': 0x05F4, 'addresses': [0xB06332] })),
('WarpSong', ('Minuet of Forest Warp -> Sacred Forest Meadow', { 'index': 0x0600, 'addresses': [0xBF023C] })),
('WarpSong', ('Bolero of Fire Warp -> DMC Central Local', { 'index': 0x04F6, 'addresses': [0xBF023E] })),
('WarpSong', ('Serenade of Water Warp -> Lake Hylia', { 'index': 0x0604, 'addresses': [0xBF0240] })),
('WarpSong', ('Requiem of Spirit Warp -> Desert Colossus', { 'index': 0x01F1, 'addresses': [0xBF0242] })),
('WarpSong', ('Nocturne of Shadow Warp -> Graveyard Warp Pad Region', { 'index': 0x0568, 'addresses': [0xBF0244] })),
('WarpSong', ('Prelude of Light Warp -> Temple of Time', { 'index': 0x05F4, 'addresses': [0xBF0246] })),
('Extra', ('ZD Eyeball Frog Timeout -> Zoras Domain', { 'index': 0x0153 })),
('Extra', ('ZR Top of Waterfall -> Zora River', { 'index': 0x0199 })),
]
# Basically, the entrances in the list above that go to:
# - DMC Central Local (child access for the bean and skull)
# - Desert Colossus (child access to colossus and spirit)
# - Graveyard Warp Pad Region (access to shadow, plus the gossip stone)
# We will always need to pick one from each list to receive a one-way entrance
# if shuffling warp songs (depending on other settings).
# Table maps: short key -> ([target regions], [allowed types])
priority_entrance_table = {
'Bolero': (['DMC Central Local'], ['OwlDrop', 'WarpSong']),
'Nocturne': (['Graveyard Warp Pad Region'], ['OwlDrop', 'Spawn', 'WarpSong']),
'Requiem': (['Desert Colossus', 'Desert Colossus From Spirit Lobby'], ['OwlDrop', 'Spawn', 'WarpSong']),
}
class EntranceShuffleError(Exception):
pass
def shuffle_random_entrances(ootworld):
world = ootworld.world
player = ootworld.player
# Gather locations to keep reachable for validation
all_state = world.get_all_state(use_cache=True)
locations_to_ensure_reachable = {loc for loc in world.get_reachable_locations(all_state, player) if not (loc.type == 'Drop' or (loc.type == 'Event' and 'Subrule' in loc.name))}
# Set entrance data for all entrances
set_all_entrances_data(world, player)
# Determine entrance pools based on settings
one_way_entrance_pools = {}
entrance_pools = {}
one_way_priorities = {}
if ootworld.owl_drops:
one_way_entrance_pools['OwlDrop'] = ootworld.get_shufflable_entrances(type='OwlDrop')
if ootworld.spawn_positions:
one_way_entrance_pools['Spawn'] = ootworld.get_shufflable_entrances(type='Spawn')
if ootworld.warp_songs:
one_way_entrance_pools['WarpSong'] = ootworld.get_shufflable_entrances(type='WarpSong')
if world.accessibility[player].current_key != 'minimal' and ootworld.logic_rules == 'glitchless':
one_way_priorities['Bolero'] = priority_entrance_table['Bolero']
one_way_priorities['Nocturne'] = priority_entrance_table['Nocturne']
if not ootworld.shuffle_dungeon_entrances and not ootworld.shuffle_overworld_entrances:
one_way_priorities['Requiem'] = priority_entrance_table['Requiem']
if ootworld.shuffle_dungeon_entrances:
entrance_pools['Dungeon'] = ootworld.get_shufflable_entrances(type='Dungeon', only_primary=True)
if ootworld.open_forest == 'closed':
entrance_pools['Dungeon'].remove(world.get_entrance('KF Outside Deku Tree -> Deku Tree Lobby', player))
if ootworld.shuffle_interior_entrances != 'off':
entrance_pools['Interior'] = ootworld.get_shufflable_entrances(type='Interior', only_primary=True)
if ootworld.shuffle_special_interior_entrances:
entrance_pools['Interior'] += ootworld.get_shufflable_entrances(type='SpecialInterior', only_primary=True)
if ootworld.shuffle_grotto_entrances:
entrance_pools['GrottoGrave'] = ootworld.get_shufflable_entrances(type='Grotto', only_primary=True)
entrance_pools['GrottoGrave'] += ootworld.get_shufflable_entrances(type='Grave', only_primary=True)
if ootworld.shuffle_overworld_entrances:
entrance_pools['Overworld'] = ootworld.get_shufflable_entrances(type='Overworld')
# Mark shuffled entrances
for entrance in chain(chain.from_iterable(one_way_entrance_pools.values()), chain.from_iterable(entrance_pools.values())):
entrance.shuffled = True
if entrance.reverse:
entrance.reverse.shuffled = True
# Build target entrance pools
one_way_target_entrance_pools = {}
for pool_type, entrance_pool in one_way_entrance_pools.items():
if pool_type == 'OwlDrop':
valid_target_types = ('WarpSong', 'OwlDrop', 'Overworld', 'Extra')
one_way_target_entrance_pools[pool_type] = build_one_way_targets(ootworld, valid_target_types, exclude=['Prelude of Light Warp -> Temple of Time'])
for target in one_way_target_entrance_pools[pool_type]:
set_rule(target, lambda state: state._oot_reach_as_age(target.parent_region, 'child', player))
elif pool_type in {'Spawn', 'WarpSong'}:
valid_target_types = ('Spawn', 'WarpSong', 'OwlDrop', 'Overworld', 'Interior', 'SpecialInterior', 'Extra')
one_way_target_entrance_pools[pool_type] = build_one_way_targets(ootworld, valid_target_types)
# Ensure that the last entrance doesn't assume the rest of the targets are reachable?
# Disconnect one-way entrances for priority placement
for entrance in chain.from_iterable(one_way_entrance_pools.values()):
entrance.disconnect()
target_entrance_pools = {}
for pool_type, entrance_pool in entrance_pools.items():
target_entrance_pools[pool_type] = assume_entrance_pool(entrance_pool, ootworld)
# Build all_state and none_state
all_state = ootworld.get_state_with_complete_itempool()
none_state = all_state.copy()
for item_tuple in none_state.prog_items:
if item_tuple[1] == player:
none_state.prog_items[item_tuple] = 0
# Plando entrances?
# Place priority entrances
shuffle_one_way_priority_entrances(ootworld, one_way_priorities, one_way_entrance_pools, one_way_target_entrance_pools, locations_to_ensure_reachable, all_state, none_state, retry_count=2)
# Delete priority targets from one-way pools
replaced_entrances = [entrance.replaces for entrance in chain.from_iterable(one_way_entrance_pools.values())]
for remaining_target in chain.from_iterable(one_way_target_entrance_pools.values()):
if remaining_target.replaces in replaced_entrances:
delete_target_entrance(remaining_target)
for pool_type, entrance_pool in one_way_entrance_pools.items():
shuffle_entrance_pool(ootworld, entrance_pool, one_way_target_entrance_pools[pool_type], locations_to_ensure_reachable, all_state, none_state, check_all=True, retry_count=5)
replaced_entrances = [entrance.replaces for entrance in entrance_pool]
for remaining_target in chain.from_iterable(one_way_target_entrance_pools.values()):
if remaining_target.replaces in replaced_entrances:
delete_target_entrance(remaining_target)
for unused_target in one_way_target_entrance_pools[pool_type]:
delete_target_entrance(unused_target)
# Shuffle all entrance pools, in order
for pool_type, entrance_pool in entrance_pools.items():
shuffle_entrance_pool(ootworld, entrance_pool, target_entrance_pools[pool_type], locations_to_ensure_reachable, all_state, none_state)
# Verification steps:
# All entrances are properly connected to a region
# Multiple checks after shuffling to ensure everything is OK
# Check that all entrances hook up correctly
for entrance in ootworld.get_shuffled_entrances():
if entrance.connected_region == None:
logging.getLogger('').error(f'{entrance} was shuffled but is not connected to any region')
if entrance.replaces == None:
logging.getLogger('').error(f'{entrance} was shuffled but does not replace any entrance')
if len(ootworld.get_region('Root Exits').exits) > 8:
for exit in ootworld.get_region('Root Exits').exits:
logging.getLogger('').error(f'Root Exit: {exit} -> {exit.connected_region}')
logging.getLogger('').error(f'Root has too many entrances left after shuffling entrances')
# Game is beatable
new_all_state = world.get_all_state(use_cache=False)
if not world.has_beaten_game(new_all_state, player):
raise EntranceShuffleError('Cannot beat game')
# Validate world
validate_world(ootworld, None, locations_to_ensure_reachable, all_state, none_state)
def replace_entrance(ootworld, entrance, target, rollbacks, locations_to_ensure_reachable, all_state, none_state):
try:
check_entrances_compatibility(entrance, target, rollbacks)
change_connections(entrance, target)
validate_world(ootworld, entrance, locations_to_ensure_reachable, all_state, none_state)
rollbacks.append((entrance, target))
return True
except EntranceShuffleError as e:
logging.getLogger('').debug(f'Failed to connect {entrance} to {target}, reason: {e}')
if entrance.connected_region:
restore_connections(entrance, target)
return False
def shuffle_one_way_priority_entrances(ootworld, one_way_priorities, one_way_entrance_pools, one_way_target_entrance_pools,
locations_to_ensure_reachable, all_state, none_state, retry_count=2):
ootworld.priority_entrances = []
while retry_count:
retry_count -= 1
rollbacks = []
try:
for key, (regions, types) in one_way_priorities.items():
place_one_way_priority_entrance(ootworld, key, regions, types, rollbacks, locations_to_ensure_reachable,
all_state, none_state, one_way_entrance_pools, one_way_target_entrance_pools)
for entrance, target in rollbacks:
confirm_replacement(entrance, target)
return
except EntranceShuffleError as error:
for entrance, target in rollbacks:
restore_connections(entrance, target)
logging.getLogger('').debug(f'Failed to place all priority one-way entrances, retrying {retry_count} more times')
raise EntranceShuffleError(f'Priority one-way entrance placement attempt count exceeded for world {ootworld.player}')
def place_one_way_priority_entrance(ootworld, priority_name, allowed_regions, allowed_types, rollbacks, locations_to_ensure_reachable,
all_state, none_state, one_way_entrance_pools, one_way_target_entrance_pools):
avail_pool = list(chain.from_iterable(one_way_entrance_pools[t] for t in allowed_types if t in one_way_entrance_pools))
ootworld.world.random.shuffle(avail_pool)
for entrance in avail_pool:
if entrance.replaces:
continue
if entrance.parent_region.name == 'Adult Spawn' and (priority_name != 'Nocturne' or ootworld.hints == 'mask'):
continue
if not ootworld.shuffle_dungeon_entrances and priority_name == 'Nocturne':
if entrance.type != 'WarpSong' and entrance.parent_region.name != 'Adult Spawn':
continue
for target in one_way_target_entrance_pools[entrance.type]:
if target.connected_region and target.connected_region.name in allowed_regions:
if replace_entrance(ootworld, entrance, target, rollbacks, locations_to_ensure_reachable, all_state, none_state):
logging.getLogger('').debug(f'Priority placing {entrance} as {target} for {priority_name}')
ootworld.priority_entrances.append(entrance)
return
raise EntranceShuffleError(f'Unable to place priority one-way entrance for {priority_name} in world {ootworld.player}')
def shuffle_entrance_pool(ootworld, entrance_pool, target_entrances, locations_to_ensure_reachable, all_state, none_state, check_all=False, retry_count=20):
restrictive_entrances, soft_entrances = split_entrances_by_requirements(ootworld, entrance_pool, target_entrances)
while retry_count:
retry_count -= 1
rollbacks = []
try:
shuffle_entrances(ootworld, restrictive_entrances, target_entrances, rollbacks, locations_to_ensure_reachable, all_state, none_state)
if check_all:
shuffle_entrances(ootworld, soft_entrances, target_entrances, rollbacks, locations_to_ensure_reachable, all_state, none_state)
else:
shuffle_entrances(ootworld, soft_entrances, target_entrances, rollbacks, set(), all_state, none_state)
validate_world(ootworld, None, locations_to_ensure_reachable, all_state, none_state)
for entrance, target in rollbacks:
confirm_replacement(entrance, target)
return
except EntranceShuffleError as e:
for entrance, target in rollbacks:
restore_connections(entrance, target)
logging.getLogger('').debug(f'Failed to place all entrances in pool, retrying {retry_count} more times')
raise EntranceShuffleError(f'Entrance placement attempt count exceeded for world {ootworld.player}')
def shuffle_entrances(ootworld, entrances, target_entrances, rollbacks, locations_to_ensure_reachable, all_state, none_state):
ootworld.world.random.shuffle(entrances)
for entrance in entrances:
if entrance.connected_region != None:
continue
ootworld.world.random.shuffle(target_entrances)
for target in target_entrances:
if target.connected_region == None:
continue
if replace_entrance(ootworld, entrance, target, rollbacks, locations_to_ensure_reachable, all_state, none_state):
break
if entrance.connected_region == None:
raise EntranceShuffleError('No more valid entrances')
def split_entrances_by_requirements(ootworld, entrances_to_split, assumed_entrances):
world = ootworld.world
player = ootworld.player
# Disconnect all root assumed entrances and save original connections
original_connected_regions = {}
entrances_to_disconnect = set(assumed_entrances).union(entrance.reverse for entrance in assumed_entrances if entrance.reverse)
for entrance in entrances_to_disconnect:
if entrance.connected_region:
original_connected_regions[entrance] = entrance.disconnect()
all_state = world.get_all_state(use_cache=False)
restrictive_entrances = []
soft_entrances = []
for entrance in entrances_to_split:
all_state.age[player] = 'child'
if not all_state.can_reach(entrance, 'Entrance', player):
restrictive_entrances.append(entrance)
continue
all_state.age[player] = 'adult'
if not all_state.can_reach(entrance, 'Entrance', player):
restrictive_entrances.append(entrance)
continue
all_state.age[player] = None
if not all_state._oot_reach_at_time(entrance.parent_region.name, TimeOfDay.ALL, [], player):
restrictive_entrances.append(entrance)
continue
soft_entrances.append(entrance)
# Reconnect assumed entrances
for entrance in entrances_to_disconnect:
if entrance in original_connected_regions:
entrance.connect(original_connected_regions[entrance])
return restrictive_entrances, soft_entrances
# Check to ensure the world is valid.
# TODO: improve this function
def validate_world(ootworld, entrance_placed, locations_to_ensure_reachable, all_state_orig, none_state_orig):
world = ootworld.world
player = ootworld.player
all_state = all_state_orig.copy()
none_state = none_state_orig.copy()
all_state.sweep_for_events()
none_state.sweep_for_events()
if ootworld.shuffle_interior_entrances or ootworld.shuffle_overworld_entrances or ootworld.spawn_positions:
time_travel_state = none_state.copy()
time_travel_state.collect(ootworld.create_item('Time Travel'), event=True)
time_travel_state._oot_update_age_reachable_regions(player)
# For various reasons, we don't want the player to end up through certain entrances as the wrong age
# This means we need to hard check that none of the relevant entrances are ever reachable as that age
# This is mostly relevant when shuffling special interiors (such as windmill or kak potion shop)
# Warp Songs and Overworld Spawns can also end up inside certain indoors so those need to be handled as well
CHILD_FORBIDDEN = ['OGC Great Fairy Fountain -> Castle Grounds', 'GV Carpenter Tent -> GV Fortress Side']
ADULT_FORBIDDEN = ['HC Great Fairy Fountain -> Castle Grounds', 'HC Storms Grotto -> Castle Grounds']
for entrance in ootworld.get_shufflable_entrances():
if entrance.shuffled and entrance.replaces:
if entrance.replaces.name in CHILD_FORBIDDEN and not entrance_unreachable_as(entrance, 'child', already_checked=[entrance.replaces.reverse]):
raise EntranceShuffleError(f'{entrance.replaces.name} replaced by an entrance with potential child access')
if entrance.replaces.name in ADULT_FORBIDDEN and not entrance_unreachable_as(entrance, 'adult', already_checked=[entrance.replaces.reverse]):
raise EntranceShuffleError(f'{entrance.replaces.name} replaced by an entrance with potential adult access')
else:
if entrance.name in CHILD_FORBIDDEN and not entrance_unreachable_as(entrance, 'child', already_checked=[entrance.reverse]):
raise EntranceShuffleError(f'{entrance.name} potentially accessible as child')
if entrance.name in ADULT_FORBIDDEN and not entrance_unreachable_as(entrance, 'adult', already_checked=[entrance.reverse]):
raise EntranceShuffleError(f'{entrance.name} potentially accessible as adult')
# Check if all locations are reachable if not beatable-only or game is not yet complete
if locations_to_ensure_reachable:
if world.accessibility[player].current_key != 'minimal' or not world.can_beat_game(all_state):
for loc in locations_to_ensure_reachable:
if not all_state.can_reach(loc, 'Location', player):
raise EntranceShuffleError(f'{loc} is unreachable')
if ootworld.shuffle_interior_entrances and (entrance_placed == None or entrance_placed.type in ['Interior', 'SpecialInterior']):
# Ensure Kak Potion Shop entrances are in the same hint area so there is no ambiguity as to which entrance is used for hints
potion_front_entrance = get_entrance_replacing(world.get_region('Kak Potion Shop Front', player), 'Kakariko Village -> Kak Potion Shop Front', player)
potion_back_entrance = get_entrance_replacing(world.get_region('Kak Potion Shop Back', player), 'Kak Backyard -> Kak Potion Shop Back', player)
if potion_front_entrance is not None and potion_back_entrance is not None and not same_hint_area(potion_front_entrance, potion_back_entrance):
raise EntranceShuffleError('Kak Potion Shop entrances are not in the same hint area')
# When cows are shuffled, ensure the same thing for Impa's House, since the cow is reachable from both sides
if ootworld.shuffle_cows:
impas_front_entrance = get_entrance_replacing(world.get_region('Kak Impas House', player), 'Kakariko Village -> Kak Impas House', player)
impas_back_entrance = get_entrance_replacing(world.get_region('Kak Impas House Back', player), 'Kak Impas Ledge -> Kak Impas House Back', player)
if impas_front_entrance is not None and impas_back_entrance is not None and not same_hint_area(impas_front_entrance, impas_back_entrance):
raise EntranceShuffleError('Kak Impas House entrances are not in the same hint area')
# Check basic refills, time passing, return to ToT
if (ootworld.shuffle_special_interior_entrances or ootworld.shuffle_overworld_entrances or ootworld.spawn_positions) and \
(entrance_placed == None or entrance_placed.type in ['SpecialInterior', 'Overworld', 'Spawn', 'WarpSong', 'OwlDrop']):
valid_starting_regions = {'Kokiri Forest', 'Kakariko Village'}
if not any(region for region in valid_starting_regions if none_state.can_reach(region, 'Region', player)):
raise EntranceShuffleError('Invalid starting area')
if not (any(region for region in time_travel_state.child_reachable_regions[player] if region.time_passes) and
any(region for region in time_travel_state.adult_reachable_regions[player] if region.time_passes)):
raise EntranceShuffleError('Time passing is not guaranteed as both ages')
if ootworld.starting_age == 'child' and (world.get_region('Temple of Time', player) not in time_travel_state.adult_reachable_regions[player]):
raise EntranceShuffleError('Path to ToT as adult not guaranteed')
if ootworld.starting_age == 'adult' and (world.get_region('Temple of Time', player) not in time_travel_state.child_reachable_regions[player]):
raise EntranceShuffleError('Path to ToT as child not guaranteed')
if (ootworld.shuffle_interior_entrances or ootworld.shuffle_overworld_entrances) and \
(entrance_placed == None or entrance_placed.type in ['Interior', 'SpecialInterior', 'Overworld', 'Spawn', 'WarpSong', 'OwlDrop']):
# Ensure big poe shop is always reachable as adult
if world.get_region('Market Guard House', player) not in time_travel_state.adult_reachable_regions[player]:
raise EntranceShuffleError('Big Poe Shop access not guaranteed as adult')
if ootworld.shopsanity == 'off':
# Ensure that Goron and Zora shops are accessible as adult
if world.get_region('GC Shop', player) not in all_state.adult_reachable_regions[player]:
raise EntranceShuffleError('Goron City Shop not accessible as adult')
if world.get_region('ZD Shop', player) not in all_state.adult_reachable_regions[player]:
raise EntranceShuffleError('Zora\'s Domain Shop not accessible as adult')
# Recursively check if a given entrance is unreachable as a given age
def entrance_unreachable_as(entrance, age, already_checked=[]):
already_checked.append(entrance)
if entrance.type in {'WarpSong', 'Overworld'}:
return False
elif entrance.type == 'OwlDrop':
return age == 'adult'
elif entrance.name == 'Child Spawn -> KF Links House':
return age == 'adult'
elif entrance.name == 'Adult Spawn -> Temple of Time':
return age == 'child'
for parent_entrance in entrance.parent_region.entrances:
if parent_entrance in already_checked:
continue
unreachable = entrance_unreachable_as(parent_entrance, age, already_checked)
if not unreachable:
return False
return True
def same_hint_area(first, second):
try:
return get_hint_area(first) == get_hint_area(second)
except HintAreaNotFound:
return False
def get_entrance_replacing(region, entrance_name, player):
original_entrance = region.world.get_entrance(entrance_name, player)
if not original_entrance.shuffled:
return original_entrance
try:
return next(filter(lambda entrance: entrance.replaces and entrance.replaces.name == entrance_name and \
entrance.parent_region and entrance.parent_region.name != 'Root Exits' and \
entrance.type not in ('OwlDrop', 'Spawn', 'WarpSong') and entrance.player == player,
region.entrances))
except StopIteration:
return None
def change_connections(entrance, target):
entrance.connect(target.disconnect())
entrance.replaces = target.replaces
if entrance.reverse:
target.replaces.reverse.connect(entrance.reverse.assumed.disconnect())
target.replaces.reverse.replaces = entrance.reverse
def restore_connections(entrance, target):
target.connect(entrance.disconnect())
entrance.replaces = None
if entrance.reverse:
entrance.reverse.assumed.connect(target.replaces.reverse.disconnect())
target.replaces.reverse.replaces = None
def check_entrances_compatibility(entrance, target, rollbacks):
# An entrance shouldn't be connected to its own scene
if entrance.parent_region.get_scene() and entrance.parent_region.get_scene() == target.connected_region.get_scene():
raise EntranceShuffleError('Self-scene connections are forbidden')
# One-way entrances shouldn't lead to the same scene as other one-ways
if entrance.type in {'OwlDrop', 'Spawn', 'WarpSong'} and \
any([rollback[0].connected_region.get_scene() == target.connected_region.get_scene() for rollback in rollbacks]):
raise EntranceShuffleError('Another one-way entrance leads to the same scene')
def confirm_replacement(entrance, target):
delete_target_entrance(target)
logging.getLogger('').debug(f'Connected {entrance} to {entrance.connected_region}')
if entrance.reverse:
replaced_reverse = target.replaces.reverse
delete_target_entrance(entrance.reverse.assumed)
logging.getLogger('').debug(f'Connected {replaced_reverse} to {replaced_reverse.connected_region}')
def delete_target_entrance(target):
if target.connected_region != None:
target.disconnect()
if target.parent_region != None:
target.parent_region.exits.remove(target)
target.parent_region = None

View File

@@ -397,6 +397,8 @@ def get_barren_hint(world, checked):
return None
area_weights = [world.empty_areas[area]['weight'] for area in areas]
if not any(area_weights):
return None
area = world.hint_rng.choices(areas, weights=area_weights)[0]
if world.empty_areas[area]['dungeon']:
@@ -637,8 +639,6 @@ hint_dist_keys = {
# builds out general hints based on location and whether an item is required or not
def buildWorldGossipHints(world, checkedLocations=None):
# Seed the RNG
world.hint_rng = world.world.slot_seeds[world.player]
# rebuild hint exclusion list
hintExclusions(world, clear_cache=True)

View File

@@ -727,6 +727,14 @@ known_logic_tricks = {
To kill it, the logic normally guarantees one of
Hookshot, Bow, or Magic.
'''},
'Skip King Zora as Adult with Nothing': {
'name' : 'logic_king_zora_skip',
'tags' : ("Zora's Domain",),
'tooltip' : '''\
With a precise jump as adult, it is possible to
get on the fence next to King Zora from the front
to access Zora's Fountain.
'''},
'Shadow Temple River Statue with Bombchu': {
'name' : 'logic_shadow_statue',
'tags' : ("Shadow Temple",),

View File

@@ -1,5 +1,5 @@
import typing
from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionList
from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionList, DeathLink
from .ColorSFXOptions import *
@@ -94,12 +94,37 @@ class StartingAge(Choice):
option_adult = 1
# TODO: document and name ER options
class InteriorEntrances(Choice):
"""Shuffles interior entrances. "Simple" shuffles houses and Great Fairies; "All" includes Windmill, Link's House, Temple of Time, and Kak potion shop."""
option_off = 0
option_simple = 1
option_all = 2
alias_false = 0
alias_true = 2
class GrottoEntrances(Toggle):
"""Shuffles grotto and grave entrances."""
class DungeonEntrances(Toggle):
"""Shuffles dungeon entrances, excluding Ganon's Castle. Opens Deku, Fire and BotW to both ages."""
class OverworldEntrances(Toggle):
"""Shuffles overworld loading zones."""
class OwlDrops(Toggle):
"""Randomizes owl drops from Lake Hylia or Death Mountain Trail as child."""
class WarpSongs(Toggle):
"""Randomizes warp song destinations."""
class SpawnPositions(Toggle):
"""Randomizes the starting position on loading a save. Consistent between savewarps."""
class TriforceHunt(Toggle):
@@ -138,13 +163,13 @@ class MQDungeons(Range):
world_options: typing.Dict[str, type(Option)] = {
"starting_age": StartingAge,
# "shuffle_interior_entrances": InteriorEntrances,
# "shuffle_grotto_entrances": Toggle,
# "shuffle_dungeon_entrances": Toggle,
# "shuffle_overworld_entrances": Toggle,
# "owl_drops": Toggle,
# "warp_songs": Toggle,
# "spawn_positions": Toggle,
"shuffle_interior_entrances": InteriorEntrances,
"shuffle_grotto_entrances": GrottoEntrances,
"shuffle_dungeon_entrances": DungeonEntrances,
"shuffle_overworld_entrances": OverworldEntrances,
"owl_drops": OwlDrops,
"warp_songs": WarpSongs,
"spawn_positions": SpawnPositions,
"triforce_hunt": TriforceHunt,
"triforce_goal": TriforceGoal,
"extra_triforce_percentage": ExtraTriforces,
@@ -765,6 +790,13 @@ sfx_options: typing.Dict[str, type(Option)] = {
}
class LogicTricks(OptionList):
"""Set various tricks for logic in Ocarina of Time.
Format as a comma-separated list of "nice" names: ["Fewer Tunic Requirements", "Hidden Grottos without Stone of Agony"].
A full list of supported tricks can be found at https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/oot/LogicTricks.py"""
displayname = "Logic Tricks"
# All options assembled into a single dict
oot_options: typing.Dict[str, type(Option)] = {
"logic_rules": Logic,
@@ -780,5 +812,6 @@ oot_options: typing.Dict[str, type(Option)] = {
**itempool_options,
**cosmetic_options,
**sfx_options,
"logic_tricks": OptionList,
"logic_tricks": LogicTricks,
"death_link": DeathLink,
}

View File

@@ -3,6 +3,7 @@ import itertools
import re
import zlib
from collections import defaultdict
from functools import partial
from .LocationList import business_scrubs
from .Hints import writeGossipStoneHints, buildAltarHints, \
@@ -1321,9 +1322,12 @@ def patch_rom(world, rom):
# Write item overrides
override_table = get_override_table(world)
rom.write_bytes(rom.sym('cfg_item_overrides'), get_override_table_bytes(override_table))
rom.write_byte(rom.sym('PLAYER_ID'), world.player) # Write player ID
rom.write_byte(rom.sym('PLAYER_ID'), min(world.player, 255)) # Write player ID
rom.write_bytes(rom.sym('AP_PLAYER_NAME'), bytearray(world.world.get_player_name(world.player), 'ascii'))
if world.death_link:
rom.write_byte(rom.sym('DEATH_LINK'), 0x01)
# Revert Song Get Override Injection
if not songs_as_items:
# general get song
@@ -1804,7 +1808,7 @@ def write_rom_item(rom, item_id, item):
def get_override_table(world):
return list(filter(lambda val: val != None, map(get_override_entry, world.world.get_filled_locations(world.player))))
return list(filter(lambda val: val != None, map(partial(get_override_entry, world.player), world.world.get_filled_locations(world.player))))
override_struct = struct.Struct('>xBBBHBB') # match override_t in get_items.c
@@ -1812,10 +1816,10 @@ def get_override_table_bytes(override_table):
return b''.join(sorted(itertools.starmap(override_struct.pack, override_table)))
def get_override_entry(location):
def get_override_entry(player_id, location):
scene = location.scene
default = location.default
player_id = location.item.player
player_id = 0 if player_id == location.item.player else min(location.item.player, 255)
if location.item.game != 'Ocarina of Time':
# This is an AP sendable. It's guaranteed to not be None.
looks_like_item_id = 0

View File

@@ -451,14 +451,16 @@ class Rule_AST_Transformer(ast.NodeTransformer):
if self.world.ensure_tod_access:
# tod has DAY or (tod == NONE and (ss or find a path from a provider))
# parsing is better than constructing this expression by hand
return ast.parse("(tod & TimeOfDay.DAY) if tod else (state.has_all(('Ocarina', 'Suns Song')) or state.search.can_reach(spot.parent_region, age=age, tod=TimeOfDay.DAY))", mode='eval').body
r = self.current_spot if type(self.current_spot) == OOTRegion else self.current_spot.parent_region
return ast.parse(f"(state.has('Ocarina', player) and state.has('Suns Song', player)) or state._oot_reach_at_time('{r.name}', TimeOfDay.DAY, [], player)", mode='eval').body
return ast.NameConstant(True)
def at_dampe_time(self, node):
if self.world.ensure_tod_access:
# tod has DAMPE or (tod == NONE and (find a path from a provider))
# parsing is better than constructing this expression by hand
return ast.parse("(tod & TimeOfDay.DAMPE) if tod else state.search.can_reach(spot.parent_region, age=age, tod=TimeOfDay.DAMPE)", mode='eval').body
r = self.current_spot if type(self.current_spot) == OOTRegion else self.current_spot.parent_region
return ast.parse(f"state._oot_reach_at_time('{r.name}', TimeOfDay.DAMPE, [], player)", mode='eval').body
return ast.NameConstant(True)
def at_night(self, node):
@@ -468,7 +470,8 @@ class Rule_AST_Transformer(ast.NodeTransformer):
if self.world.ensure_tod_access:
# tod has DAMPE or (tod == NONE and (ss or find a path from a provider))
# parsing is better than constructing this expression by hand
return ast.parse("(tod & TimeOfDay.DAMPE) if tod else (state.has_all(('Ocarina', 'Suns Song')) or state.search.can_reach(spot.parent_region, age=age, tod=TimeOfDay.DAMPE))", mode='eval').body
r = self.current_spot if type(self.current_spot) == OOTRegion else self.current_spot.parent_region
return ast.parse(f"(state.has('Ocarina', player) and state.has('Suns Song', player)) or state._oot_reach_at_time('{r.name}', TimeOfDay.DAMPE, [], player)", mode='eval').body
return ast.NameConstant(True)

View File

@@ -2,6 +2,7 @@ from collections import deque
import logging
from .SaveContext import SaveContext
from .Regions import TimeOfDay
from BaseClasses import CollectionState
from worlds.generic.Rules import set_rule, add_rule, add_item_rule, forbid_item
@@ -42,6 +43,36 @@ class OOTLogic(LogicMixin):
return can_reach
return self.age[player] == age
def _oot_reach_at_time(self, regionname, tod, already_checked, player):
name_map = {
TimeOfDay.DAY: self.day_reachable_regions[player],
TimeOfDay.DAMPE: self.dampe_reachable_regions[player],
TimeOfDay.ALL: self.day_reachable_regions[player].intersection(self.dampe_reachable_regions[player])
}
if regionname in name_map[tod]:
return True
region = self.world.get_region(regionname, player)
if region.provides_time == TimeOfDay.ALL or regionname == 'Root':
self.day_reachable_regions[player].add(regionname)
self.dampe_reachable_regions[player].add(regionname)
return True
if region.provides_time == TimeOfDay.DAMPE:
self.dampe_reachable_regions[player].add(regionname)
return tod == TimeOfDay.DAMPE
for entrance in region.entrances:
if entrance.parent_region.name in already_checked:
continue
if self._oot_reach_at_time(entrance.parent_region.name, tod, already_checked + [regionname], player):
if tod == TimeOfDay.DAY:
self.day_reachable_regions[player].add(regionname)
elif tod == TimeOfDay.DAMPE:
self.dampe_reachable_regions[player].add(regionname)
elif tod == TimeOfDay.ALL:
self.day_reachable_regions[player].add(regionname)
self.dampe_reachable_regions[player].add(regionname)
return True
return False
# Store the age before calling this!
def _oot_update_age_reachable_regions(self, player):
self.stale[player] = False
@@ -62,6 +93,8 @@ class OOTLogic(LogicMixin):
while queue:
connection = queue.popleft()
new_region = connection.connected_region
if new_region is None:
continue
if new_region in rrp:
bc.remove(connection)
elif connection.can_reach(self):

View File

@@ -7,7 +7,7 @@ logger = logging.getLogger("Ocarina of Time")
from .Location import OOTLocation, LocationFactory, location_name_to_id
from .Entrance import OOTEntrance
from .EntranceShuffle import shuffle_random_entrances
from .EntranceShuffle import shuffle_random_entrances, entrance_shuffle_table, EntranceShuffleError
from .Items import OOTItem, item_table, oot_data_to_ap_id
from .ItemPool import generate_itempool, add_dungeon_items, get_junk_item, get_junk_pool
from .Regions import OOTRegion, TimeOfDay
@@ -39,6 +39,11 @@ i_o_limiter = threading.Semaphore(2)
class OOTWorld(World):
"""
The Legend of Zelda: Ocarina of Time is a 3D action/adventure game. Travel through Hyrule in two time periods,
learn magical ocarina songs, and explore twelve dungeons on your quest. Use Link's many items and abilities
to rescue the Seven Sages, and then confront Ganondorf to save Hyrule!
"""
game: str = "Ocarina of Time"
options: dict = oot_options
topology_present: bool = True
@@ -61,6 +66,8 @@ class OOTWorld(World):
self.adult_reachable_regions = {player: set() for player in range(1, parent.players + 1)}
self.child_blocked_connections = {player: set() for player in range(1, parent.players + 1)}
self.adult_blocked_connections = {player: set() for player in range(1, parent.players + 1)}
self.day_reachable_regions = {player: set() for player in range(1, parent.players + 1)}
self.dampe_reachable_regions = {player: set() for player in range(1, parent.players + 1)}
self.age = {player: None for player in range(1, parent.players + 1)}
def oot_copy(self):
@@ -73,6 +80,10 @@ class OOTWorld(World):
range(1, self.world.players + 1)}
ret.adult_blocked_connections = {player: copy.copy(self.adult_blocked_connections[player]) for player in
range(1, self.world.players + 1)}
ret.day_reachable_regions = {player: copy.copy(self.adult_reachable_regions[player]) for player in
range(1, self.world.players + 1)}
ret.dampe_reachable_regions = {player: copy.copy(self.adult_reachable_regions[player]) for player in
range(1, self.world.players + 1)}
return ret
CollectionState.__init__ = oot_init
@@ -83,6 +94,8 @@ class OOTWorld(World):
world.state.adult_reachable_regions = {player: set() for player in range(1, world.players + 1)}
world.state.child_blocked_connections = {player: set() for player in range(1, world.players + 1)}
world.state.adult_blocked_connections = {player: set() for player in range(1, world.players + 1)}
world.state.day_reachable_regions = {player: set() for player in range(1, world.players + 1)}
world.state.dampe_reachable_regions = {player: set() for player in range(1, world.players + 1)}
world.state.age = {player: None for player in range(1, world.players + 1)}
return super().__new__(cls)
@@ -173,14 +186,6 @@ class OOTWorld(World):
self.mq_dungeons_random = False # this will be a deprecated option later
self.ocarina_songs = False # just need to pull in the OcarinaSongs module
self.big_poe_count = 1 # disabled due to client-side issues for now
# ER options
self.shuffle_interior_entrances = 'off'
self.shuffle_grotto_entrances = False
self.shuffle_dungeon_entrances = False
self.shuffle_overworld_entrances = False
self.owl_drops = False
self.warp_songs = False
self.spawn_positions = False
# Set internal names used by the OoT generator
self.keysanity = self.shuffle_smallkeys in ['keysanity', 'remove', 'any_dungeon', 'overworld']
@@ -313,7 +318,7 @@ class OOTWorld(World):
new_location.show_in_spoiler = False
if 'exits' in region:
for exit, rule in region['exits'].items():
new_exit = OOTEntrance(self.player, '%s => %s' % (new_region.name, exit), new_region)
new_exit = OOTEntrance(self.player, self.world, '%s -> %s' % (new_region.name, exit), new_region)
new_exit.vanilla_connected_region = exit
new_exit.rule_string = rule
if self.world.logic_rules != 'none':
@@ -411,7 +416,8 @@ class OOTWorld(World):
def create_item(self, name: str):
if name in item_table:
return OOTItem(name, self.player, item_table[name], False, (name in self.nonadvancement_items))
return OOTItem(name, self.player, item_table[name], False,
(name in self.nonadvancement_items if getattr(self, 'nonadvancement_items', None) else False))
return OOTItem(name, self.player, ('Event', True, None, None), True, False)
def make_event_item(self, name, location, item=None):
@@ -431,7 +437,7 @@ class OOTWorld(World):
world_type = 'Glitched World'
overworld_data_path = data_path(world_type, 'Overworld.json')
menu = OOTRegion('Menu', None, None, self.player)
start = OOTEntrance(self.player, 'New Game', menu)
start = OOTEntrance(self.player, self.world, 'New Game', menu)
menu.exits.append(start)
self.world.regions.append(menu)
self.load_regions_from_json(overworld_data_path)
@@ -443,14 +449,10 @@ class OOTWorld(World):
self.random_shop_prices()
self.set_scrub_prices()
# logger.info('Setting Entrances.')
# set_entrances(self)
# Enforce vanilla for now
# Bind entrances to vanilla
for region in self.regions:
for exit in region.exits:
exit.connect(self.world.get_region(exit.vanilla_connected_region, self.player))
if self.entrance_shuffle:
shuffle_random_entrances(self)
def create_items(self):
# Generate itempool
@@ -481,6 +483,50 @@ class OOTWorld(World):
self.remove_from_start_inventory.extend(removed_items)
def set_rules(self):
# This has to run AFTER creating items but BEFORE set_entrances_based_rules
if self.entrance_shuffle:
# 10 attempts at shuffling entrances
tries = 10
while tries:
try:
shuffle_random_entrances(self)
except EntranceShuffleError as e:
tries -= 1
logging.getLogger('').debug(f"Failed shuffling entrances for world {self.player}, retrying {tries} more times")
if tries == 0:
raise e
# Restore original state and delete assumed entrances
for entrance in self.get_shuffled_entrances():
entrance.connect(self.world.get_region(entrance.vanilla_connected_region, self.player))
if entrance.assumed:
assumed_entrance = entrance.assumed
if assumed_entrance.connected_region is not None:
assumed_entrance.disconnect()
del assumed_entrance
entrance.reverse = None
entrance.replaces = None
entrance.assumed = None
entrance.shuffled = False
# Clean up root entrances
root = self.get_region("Root Exits")
root.exits = root.exits[:8]
else:
break
# Write entrances to spoiler log
all_entrances = self.get_shuffled_entrances()
all_entrances.sort(key=lambda x: x.name)
all_entrances.sort(key=lambda x: x.type)
for loadzone in all_entrances:
if loadzone.primary:
entrance = loadzone
else:
entrance = loadzone.reverse
if entrance.reverse is not None:
self.world.spoiler.set_entrance(entrance, entrance.replaces, 'both', self.player)
else:
self.world.spoiler.set_entrance(entrance, entrance.replaces, 'entrance', self.player)
set_rules(self)
set_entrances_based_rules(self)
@@ -506,7 +552,7 @@ class OOTWorld(World):
all_locations = self.get_locations()
reachable = self.world.get_reachable_locations(all_state, self.player)
unreachable = [loc for loc in all_locations if
loc.internal and loc.event and loc.locked and loc not in reachable]
(loc.internal or loc.type == 'Drop') and loc.event and loc.locked and loc not in reachable]
for loc in unreachable:
loc.parent_region.locations.remove(loc)
# Exception: Sell Big Poe is an event which is only reachable if Bottle with Big Poe is in the item pool.
@@ -618,18 +664,35 @@ class OOTWorld(World):
songs = list(filter(lambda item: item.player == self.player and item.type == 'Song', self.world.itempool))
for song in songs:
self.world.itempool.remove(song)
important_warps = (self.shuffle_special_interior_entrances or self.shuffle_overworld_entrances or
self.warp_songs or self.spawn_positions)
song_order = {
'Zeldas Lullaby': 1,
'Eponas Song': 1,
'Sarias Song': 3 if important_warps else 0,
'Suns Song': 0,
'Song of Time': 0,
'Song of Storms': 3,
'Minuet of Forest': 2 if important_warps else 0,
'Bolero of Fire': 2 if important_warps else 0,
'Serenade of Water': 2 if important_warps else 0,
'Requiem of Spirit': 2,
'Nocturne of Shadow': 2,
'Prelude of Light': 2 if important_warps else 0,
}
songs.sort(key=lambda song: song_order.get(song.name, 0))
while tries:
try:
self.world.random.shuffle(songs) # shuffling songs makes it less likely to fail by placing ZL last
self.world.random.shuffle(song_locations)
fill_restrictive(self.world, self.world.get_all_state(False), song_locations[:], songs[:],
True, True)
logger.debug(f"Successfully placed songs for player {self.player} after {6 - tries} attempt(s)")
tries = 0
except FillError as e:
tries -= 1
if tries == 0:
raise e
raise Exception(f"Failed placing songs for player {self.player}. Error cause: {e}")
logger.debug(f"Failed placing songs for player {self.player}. Retries left: {tries}")
# undo what was done
for song in songs:
@@ -639,6 +702,8 @@ class OOTWorld(World):
location.item = None
location.locked = False
location.event = False
else:
break
# Place shop items
# fast fill will fail because there is some logic on the shop items. we'll gather them up and place the shop items
@@ -712,6 +777,9 @@ class OOTWorld(World):
for trap in ice_traps:
trap.looks_like_item = self.create_item(self.world.slot_seeds[self.player].choice(self.fake_items).name)
# Seed hint RNG, used for ganon text lines also
self.hint_rng = self.world.slot_seeds[self.player]
outfile_name = f"AP_{self.world.seed_name}_P{self.player}_{self.world.get_player_name(self.player)}"
rom = Rom(file=get_options()['oot_options']['rom_file'])
if self.hints != 'none':
@@ -787,6 +855,23 @@ class OOTWorld(World):
autoworld.hint_data_available.set()
def modify_multidata(self, multidata: dict):
hint_entrances = set()
for entrance in entrance_shuffle_table:
hint_entrances.add(entrance[1][0])
if len(entrance) > 2:
hint_entrances.add(entrance[2][0])
def get_entrance_to_region(region):
if region.name == 'Root':
return None
for entrance in region.entrances:
if entrance.name in hint_entrances:
return entrance
for entrance in region.entrances:
return get_entrance_to_region(entrance.parent_region)
# Remove undesired items from start_inventory
for item_name in self.remove_from_start_inventory:
item_id = self.item_name_to_id.get(item_name, None)
try:
@@ -794,10 +879,26 @@ class OOTWorld(World):
except ValueError as e:
logger.warning(f"Attempted to remove nonexistent item id {item_id} from OoT precollected items ({item_name})")
# Add ER hint data
if self.shuffle_interior_entrances != 'off' or self.shuffle_dungeon_entrances or self.shuffle_grotto_entrances:
er_hint_data = {}
for region in self.regions:
main_entrance = get_entrance_to_region(region)
if main_entrance is not None and main_entrance.shuffled:
for location in region.locations:
if type(location.address) == int:
er_hint_data[location.address] = main_entrance.name
multidata['er_hint_data'][self.player] = er_hint_data
# Helper functions
def get_shuffled_entrances(self):
return [] # later this will return all entrances modified by ER. patching process needs it now though
def get_shufflable_entrances(self, type=None, only_primary=False):
return [entrance for entrance in self.world.get_entrances() if (entrance.player == self.player and
(type == None or entrance.type == type) and
(not only_primary or entrance.primary))]
def get_shuffled_entrances(self, type=None, only_primary=False):
return [entrance for entrance in self.get_shufflable_entrances(type=type, only_primary=only_primary) if entrance.shuffled]
def get_locations(self):
for region in self.regions:
@@ -810,6 +911,9 @@ class OOTWorld(World):
def get_region(self, region):
return self.world.get_region(region, self.player)
def get_entrance(self, entrance):
return self.world.get_entrance(entrance, self.player)
def is_major_item(self, item: OOTItem):
if item.type == 'Token':
return self.bridge == 'tokens' or self.lacs_condition == 'tokens'
@@ -835,3 +939,29 @@ class OOTWorld(World):
return False
return True
# Specifically ensures that only real items are gotten, not any events.
# In particular, ensures that Time Travel needs to be found.
def get_state_with_complete_itempool(self):
all_state = self.world.get_all_state(use_cache=False)
# Remove event progression items
for item, player in all_state.prog_items:
if (item not in item_table or item_table[item][2] is None) and player == self.player:
all_state.prog_items[(item, player)] = 0
# Remove all events and checked locations
all_state.locations_checked = {loc for loc in all_state.locations_checked if loc.player != self.player}
all_state.events = {loc for loc in all_state.events if loc.player != self.player}
# If free_scarecrow give Scarecrow Song
if self.free_scarecrow:
all_state.collect(self.create_item("Scarecrow Song"), event=True)
# Invalidate caches
all_state.child_reachable_regions[self.player] = set()
all_state.adult_reachable_regions[self.player] = set()
all_state.child_blocked_connections[self.player] = set()
all_state.adult_blocked_connections[self.player] = set()
all_state.day_reachable_regions[self.player] = set()
all_state.dampe_reachable_regions[self.player] = set()
all_state.stale[self.player] = True
return all_state

View File

@@ -1720,7 +1720,8 @@
"Lake Hylia": "is_child and can_dive",
"ZD Behind King Zora": "
Deliver_Letter or zora_fountain == 'open' or
(zora_fountain == 'adult' and is_adult)",
(zora_fountain == 'adult' and is_adult) or
(logic_king_zora_skip and is_adult)",
"ZD Shop": "is_child or Blue_Fire",
"ZD Storms Grotto": "can_open_storm_grotto"
}

File diff suppressed because it is too large Load Diff

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