Compare commits

...

268 Commits
0.2.4 ... 0.3.1

Author SHA1 Message Date
Chris Wilson
07ff0f1026 [WebHost] Fix /user-content styles (#408) 2022-04-03 20:16:15 -04:00
Fabian Dill
a080288e3e Core: update version (#407) 2022-04-03 19:39:01 -04:00
Fabian Dill
71bd87f293 HK: don't flag maps as progression 2022-04-03 19:38:39 -04:00
Fabian Dill
574e2abba8 HK: write shop prices to spoiler log 2022-04-03 19:38:39 -04:00
Hussein Farran
cffa772801 Fix unit test and generation failures. Whoops. 2022-04-03 19:38:39 -04:00
Fabian Dill
66bd793306 HK: add item name groups 2022-04-03 19:38:39 -04:00
Hussein Farran
0eb37883ca Add docstrings to hollow knight YAML options. 2022-04-03 19:38:39 -04:00
Hussein Farran
356384ab05 Add Hollow Knight setup guide, game info, and to README 2022-04-03 19:38:39 -04:00
Fabian Dill
8c2c6877b6 HK: sort shop contents by cost 2022-04-03 19:38:39 -04:00
Fabian Dill
d1d40d8a60 HK: ignore relics logic
HK: write sets ordered, to reduce history changes
2022-04-03 19:38:39 -04:00
Fabian Dill
b026a0a372 HK: write charm costs to spoiler 2022-04-03 19:38:39 -04:00
Fabian Dill
73bcd0058a HK: force disabled options to actually be disabled 2022-04-03 19:38:39 -04:00
Fabian Dill
0cf396e5d6 HK: account for "Start" location in another place 2022-04-03 19:38:39 -04:00
Fabian Dill
1bc09d4292 make black sliver happy 2022-04-03 19:38:39 -04:00
Fabian Dill
97d0c51db1 HK: allow webgen 2022-04-03 19:38:39 -04:00
Fabian Dill
ed1c11267c Options: loudly crash if random text is not recognized, instead of de… (#401)
* Options: loudly crash if random text is not recognized, instead of defaulting to full "random"

* Update Options.py

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

Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-04-03 19:37:57 -04:00
Fabian Dill
a3e1ac896f Generate: don't fail on marked utf-8 files (#399)
utf-8-sig will fallback to non-sig automatically
2022-04-03 15:55:46 -04:00
Zach Parks
37d9eb2752 Added filesafe player name function and updated generator functions in all worlds to use filesafe player name during output
Thanks Windows for your bad filesystem.
2022-04-03 20:45:44 +02:00
CaitSith2
05e267a0bd Prevent use of old collection clients without boss collection blocklist. (#406) 2022-04-03 14:45:06 -04:00
Fabian Dill
d1f0a29a02 OoT: fix patching deltas when run from another folder 2022-04-03 20:44:27 +02:00
Fabian Dill
fb2e780c56 LttP/SMZ3: some more file ending fixes (#393) 2022-04-03 13:42:18 -04:00
Fabian Dill
ba3257f850 ItemLinks: prevent attempts at cross-game (#402) 2022-04-03 13:09:05 -04:00
Fabian Dill
215d5e9adf AutoWorld: ensure WebWorld is instantiated, preventing an easy mistake. (#404) 2022-04-03 13:08:50 -04:00
black-sliver
5392b32d5c SoE: WebWorld theme and fix long standing bug (#397) 2022-04-03 04:48:43 +02:00
alwaysintreble
4dd0a75914 multiworld tracker: properly fix item link breaking tracker 2022-04-03 02:03:48 +02:00
CaitSith2
a2212002ae Link to the Past Block collection of bosses. (#395) 2022-04-03 01:39:28 +02:00
lordlou
91ccee3513 [SM] remote item back compat fix (#400) 2022-04-03 01:36:31 +02:00
black-sliver
2a593d5d0a CI: add windows build action
set setuptools to 60.x until the issue is resolved
change retention to 7 days
2022-04-02 04:49:42 +02:00
black-sliver
a93b3d79aa Minecraft fixes (#388) 2022-04-02 04:49:27 +02:00
black-sliver
938ab32cda CI: bigger unittest matrix 2022-04-02 00:17:53 +02:00
Jarno Westhof
6f5ab05345 [Docs] Added WebWorld Theme (#387) 2022-04-01 22:39:39 +02:00
Chris Wilson
95f8647f09 Added 50 items to ArchipIDLE (#385) 2022-04-01 10:04:42 -04:00
jonloveslegos
06c8caa3cc Fixed checksfinder client failing when getting an item before sending one, and fixed checksfinder client not appearing in the installer (#383) 2022-04-01 07:55:06 +02:00
Fabian Dill
d206a562df rename ChecksFinder folder (#380) 2022-04-01 01:17:46 -04:00
Fabian Dill
a0a290e481 Setup: fix OoT conditions for file page (#381) 2022-04-01 01:17:26 -04:00
Fabian Dill
266ff0c520 Setup: fix apparently forgotten file endings (#382)
I feel like I did this... mh.
2022-04-01 01:16:54 -04:00
Fabian Dill
931bf7da16 SMZ3: fix loading TextScript on systems that don't default to something utf-8 compatible (#384) 2022-04-01 01:14:20 -04:00
black-sliver
fe4a26d034 CI: add Generate.py tests
* allows ModuleUpdate to be run outside of local_dir
* adds windows-latest to the unittest matrix
2022-04-01 06:16:13 +02:00
Zach Parks
dca70a99ad Webhost - Update copyright year 2022-04-01 04:46:02 +02:00
Fabian Dill
1a24a73ccd add forgotten file (#378) 2022-03-31 22:14:51 -04:00
Fabian Dill
ae163319e0 More bug fixes 2022-04-01 03:54:30 +02:00
Fabian Dill
65864e273b Fix bugs 2022-04-01 03:54:30 +02:00
Fabian Dill
199b778d2b Bug Squashing 2022-04-01 03:54:30 +02:00
Fabian Dill
70e3c47120 Core: update version 2022-04-01 03:54:30 +02:00
Fabian Dill
eddc5d6524 Options: some more typing 2022-04-01 03:21:31 +02:00
strotlog
fae3068c25 Documentation: Add RetroArch emu to SNES games (#365)
* Documentation: Add RetroArch emu to SNES games

* Documentation: fix screenshot alt text
2022-03-31 18:09:13 -04:00
Fabian Dill
b9014b2a60 Setup: update cx-Freeze (#370) 2022-03-31 18:08:48 -04:00
Chris Wilson
6b07b6407c Add ArchipIDLE setup guide (#375) 2022-03-31 18:08:13 -04:00
black-sliver
a10b987f1c CI: add release action 2022-03-31 23:29:56 +02:00
Fabian Dill
1f16310797 Options: fix "toggle: random" always being True 2022-03-31 22:57:19 +02:00
Jarno Westhof
0fd59063d9 [Timespinner] Fixed typo 2022-03-31 22:16:23 +02:00
Jarno Westhof
aab477b874 Value is not actually a member of a Set package 2022-03-31 20:16:04 +02:00
black-sliver
098d939653 Generate: fix windows support (#368) 2022-03-31 08:22:01 +02:00
black-sliver
7d830362a7 Setup, Launcher, Linux Support (#359) 2022-03-31 05:08:15 +02:00
Fabian Dill
0db1660369 MultiServer: fix crash when hint_location hits a location that can exist, but did not exist in this multiworld. 2022-03-30 19:59:34 -07:00
strotlog
c471a70b35 Documentation: Fix order, title, and link to SMZ3 2022-03-31 04:57:52 +02:00
alwaysintreble
6aef6f2c11 Revert "increment data version since progression of items changed"
This reverts commit 2243686847.
2022-03-31 04:57:01 +02:00
alwaysintreble
000f0bf2f1 clean, concise method for flagging dio's as progression
Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-03-31 04:57:01 +02:00
alwaysintreble
0f1c08b43a increment data version since progression of items changed 2022-03-31 04:57:01 +02:00
alwaysintreble
76ffb5cd53 fix event location and victory rules. 2022-03-31 04:57:01 +02:00
alwaysintreble
23d245d43c Update RoR2 logic to use event locations 2022-03-31 04:57:01 +02:00
Chris Wilson
aabc86fc01 ArchipIDLE "Improvements" (#366)
* Increase ArchipIDLE location count to 150

* Reduce location count per seed to 100. Game will now complete between 50 and 100 minutes.

* Add 50 more items to ArchipIDLE

* Update data_version to 2
2022-03-30 21:34:39 -04:00
Fabian Dill
cebd7fb545 Core: check for key-only once in sweep (#361) 2022-03-30 21:30:06 -04:00
Fabian Dill
8337689640 Options: more feedback on bad compares (#362) 2022-03-30 21:29:45 -04:00
Fabian Dill
0263130126 SM: fix Nothing type crash (#363) 2022-03-30 21:29:08 -04:00
jonloveslegos
c472d740ec fixed the checksfinder game info saying the wrong number 2022-03-29 09:19:13 +02:00
jonloveslegos
0fd244eee0 Added to the ChecksFinder docs 2022-03-29 09:19:13 +02:00
Chris Wilson
7dcb6f66da Website Style Upgrade (#353)
* [WebHost] Update WebHost to include modular themes system, remove unused and outdated assets

* Landing Page Updates

* Markdown updates, colors coming later

* Remove testing theme from FF1

* Color updates for markdown styles

* Updates to generated pages, so many updates

* [WebHost] Update WebHost to include modular themes system, remove unused and outdated assets

* Landing Page Updates

* Markdown updates, colors coming later

* Remove testing theme from FF1

* Color updates for markdown styles

* Updates to generated pages, so many updates

* Seed download page improvements

* Add styles to weighted-settings page

* Minor adjustments to styles

* Revert base theme to grass

* Add more items to ArchipIDLE

* [WebHost] Update WebHost to include modular themes system, remove unused and outdated assets

* Landing Page Updates

* Markdown updates, colors coming later

* Remove testing theme from FF1

* Color updates for markdown styles

* Updates to generated pages, so many updates

* Seed download page improvements

* [WebHost] Update WebHost to include modular themes system, remove unused and outdated assets

* Landing Page Updates

* Markdown updates, colors coming later

* Remove testing theme from FF1

* Color updates for markdown styles

* Updates to generated pages, so many updates

* Add styles to weighted-settings page

* Minor adjustments to styles

* Revert base theme to grass

* Add more items to ArchipIDLE

* Improve Archipidle item name

* [WebHost] Update background images, waiting on jungle.png, added partyTime theme

* [WebHost] Fix tab ordering on landing page, remove islands on screen scale, fix tutorial page width scaling

* [WebHost] Final touches to WebHost

* Improve get_world_theme function, add partyTime theme to ArchipIDLE WebWorld

* Remove sending_visible from AutoWorld

* AP Ocarina of Time Client (#352)

* Core: update jinja (#351)

* some typing and cleaning, mostly in Fill.py (#349)

* some typing and cleaning, mostly in Fill.py

* address missing Option types

* resolve a few TODOs discussed in pull request

* SM: Optimize a bit (#350)

* SM: Optimize a bit

* SM: init bosses only once

* New World Order (#355)

* Core: update jinja

* SM: Optimize a bit

* AutoWorld: import worlds in alphabetical order, to be predictable rather than arbitrary

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

* Remove references to Z5Client in English OoT setup guide

* Prevent markdown code blocks from overflowing their container

Co-authored-by: espeon65536 <81029175+espeon65536@users.noreply.github.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-03-28 20:12:17 -04:00
Fabian Dill
14956d27bd Core: don't sweep excluded locations for accessibility check, as they are forbidden from having progression anyway. (#357) 2022-03-28 20:03:57 -04:00
Fabian Dill
420be2c44f New World Order (#355)
* Core: update jinja

* SM: Optimize a bit

* AutoWorld: import worlds in alphabetical order, to be predictable rather than arbitrary

Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-03-27 21:45:14 -04:00
Fabian Dill
3bb3a902b3 SM: Optimize a bit (#350)
* SM: Optimize a bit

* SM: init bosses only once
2022-03-27 19:50:58 -04:00
Doug Hoskisson
2b138ac940 some typing and cleaning, mostly in Fill.py (#349)
* some typing and cleaning, mostly in Fill.py

* address missing Option types

* resolve a few TODOs discussed in pull request
2022-03-27 19:47:47 -04:00
Fabian Dill
b6eeef1db6 Core: update jinja (#351) 2022-03-27 19:44:25 -04:00
espeon65536
469dda7d85 AP Ocarina of Time Client (#352) 2022-03-27 21:44:22 +02:00
Fabian Dill
3c2933d587 V6: fix area cost always referencing last area cost, instead of current index (#348)
* V6: fix area cost always referencing last area cost, instead of current index
* V6: autoformat Rules.py
* V6: correct a location name for rule application
2022-03-26 10:16:28 +01:00
Alchav
3b128c8512 SM - Option to remove empty locations (#323) 2022-03-26 07:26:55 +01:00
lordlou
fb1be7b003 [SM] min client version change (#347) 2022-03-26 02:36:13 +01:00
lordlou
e0aa52ed27 [SMZ3] player count fix (#346) 2022-03-26 02:35:55 +01:00
Fabian Dill
64ac619b46 Core: use assert correctly (#345)
Core: add some more types to State and add count() method
2022-03-25 20:12:54 -04:00
Fabian Dill
902472be32 Core: fix place_locked_item not setting location back-reference (#344) 2022-03-25 17:57:00 -04:00
Fabian Dill
cb024b00d9 Fill: don't crash before debug output in case of unfilled locations (#342) 2022-03-24 12:47:20 -04:00
Fabian Dill
75de616465 Core: remove sending_visible (#339)
* Core: remove sending_visible
Only used by Factorio and that use predates start_location_hints, which works perfectly fine for this purpose.

* Factorio: minor cleanup
2022-03-24 12:15:52 -04:00
Fabian Dill
c12d8e2f46 WebHost: remove duplicate file ending dot (#343) 2022-03-24 12:03:05 -04:00
strotlog
d8087660e6 SM: remove SNIClient read of duplicative ROM name (#340) 2022-03-24 11:40:02 -04:00
alwaysintreble
87a8e6e20c Documentation: minor updates (#320)
* documentation: add links to other guides in adding games.md

* documentation: add webworld to api.md

* documentation: point people to docs folder and discord for help with adding games

* tutorial: go a bit more in depth on downloading a template yaml

* Make Ijwu happy

* point to baseclasses.py in api.md and reformat links a bit
2022-03-24 09:21:08 -04:00
black-sliver
b599a7607d SoE: mark traps as being traps 2022-03-24 01:49:45 +01:00
black-sliver
a6b22d1f41 Doc: rewrite patch section (#336)
this gets rid of a lot of information that is not required
and somewhat adds best practice to it
2022-03-23 19:47:27 -04:00
Fabian Dill
8e59761b03 BaseClasses: more type annotations (#337) 2022-03-23 19:46:26 -04:00
Jarno Westhof
8599506497 [Docs] Datastorage (#333) 2022-03-23 22:20:55 +01:00
Fabian Dill
e4ab10fe92 MultiServer: try to import tkinter, then provide some feedback (#329)
* MultiServer: try to import tkinter, then provide some feedback

TK may not be installed alongside python on some systems, like minimal linux installations.

* specify tkinter package

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

Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-03-23 08:53:35 -04:00
Fabian Dill
171c297d1b Options: implement additional assert checking for duplicate option ID (#332)
Options: change "random" prevention to assert, so it doesn't get checked in compiled version, as it's a source-code-time issue.
2022-03-22 21:28:15 -04:00
black-sliver
5eccb0ed49 api.md: clarify get_required_client_version (#334) 2022-03-22 21:22:58 -04:00
black-sliver
f326de2686 SoE: require client 0.2.6
Require latest https://github.com/black-sliver/ap-soeclient/
currently hosted on evermizer.com/apclient.beta
2022-03-23 02:21:47 +01:00
black-sliver
2ca6b7f929 SoE: add traps and death link 2022-03-23 02:21:47 +01:00
black-sliver
79afae17e7 SoE: add item groups 2022-03-23 02:21:47 +01:00
black-sliver
cb4d9dc365 SoE: some cleanup 2022-03-23 02:21:47 +01:00
jonloveslegos
4bf8b98681 Added my game made specifically for AP, ChecksFinder (Minesweeper) (#302) 2022-03-22 23:30:10 +01:00
Fabian Dill
7f1371ec00 SNIClient: provide example full connect command when required and some pep8 (#330) 2022-03-22 14:13:04 -04:00
espeon65536
cb3db8ae16 ALttP: fix ROM crash when loading mail/shield overflow sprite in hard/expert 2022-03-22 18:59:47 +01:00
Fabian Dill
cf2e37f92d Options: sort values when displaying OptionSet (#326) 2022-03-22 10:25:34 -04:00
Fabian Dill
92319b0e31 Options: implement item name groups for item sets options (#325)
* Options: implement item name groups for item sets options

* Options: update outdated comments; verify is done by the verify mixin parent class nowadays
2022-03-21 15:49:54 -04:00
Fabian Dill
d4ff653937 Clients: change scouted locations_info to full NetworkItem (#324) 2022-03-21 10:26:38 -04:00
lordlou
7df12930ef [SM] Add support for Remote Items (#317) 2022-03-21 05:34:47 +01:00
lordlou
9ba70951d5 [SMZ3] tutorial (#322) 2022-03-20 16:12:53 +01:00
espeon65536
2d25369d06 Core: fix division by zero in case of spectator slot 2022-03-20 16:08:22 +01:00
Alchav
affcaf1c02 ItemLink - ensure no extra fillers are created (#316) 2022-03-20 16:07:51 +01:00
Fabian Dill
7e314c0d7a Multidata: don't include start inventory events in sendable items (#319) 2022-03-18 13:19:21 -04:00
Fabian Dill
1266ca314c Options: some display name renames that were missed (#318) 2022-03-18 13:17:19 -04:00
Fabian Dill
7394598aff Patch: update to version 4 (#312) 2022-03-18 04:53:09 +01:00
Felix R
b02a710bc5 Add Meritous (#278) 2022-03-18 04:30:47 +01:00
Fabian Dill
ce6966a823 WebHost: update modules (#314) 2022-03-16 08:53:11 -04:00
Alchav
689183edc0 [RL] Specify list of available classes (#262) 2022-03-16 02:31:14 +01:00
Hussein Farran
43113c7844 Merge pull request #308 from ArchipelagoMW/quick-recipe
Quick recipe
2022-03-15 13:19:34 -04:00
Hussein Farran
fb8879a919 Merge pull request #307 from ArchipelagoMW/energy-bridge
Factorio: increase cost of Energy Bridge
2022-03-15 13:18:52 -04:00
Hussein Farran
136b9f9138 Merge pull request #309 from ArchipelagoMW/update-requirements
Requirements: update modules and move bsdiff4 to be a common module
2022-03-15 13:17:17 -04:00
Hussein Farran
eea326561e Merge pull request #310 from ArchipelagoMW/lttp-client-version
LttP: update required client version as behaviour changes were introd…
2022-03-15 13:16:30 -04:00
Fabian Dill
e3781c68be Requirements: update modules and move bsdiff4 to be a common module 2022-03-15 14:17:03 +01:00
Fabian Dill
d2927dc68f LttP: update required client version as behaviour changes were introduced with location check writes to savegame 2022-03-15 14:07:32 +01:00
Fabian Dill
ca95d47127 Factorio: improve generation speed of make_quick_recipe slightly 2022-03-15 14:02:05 +01:00
Fabian Dill
a5a0c94a2c Factorio: increase cost of Energy Bridge 2022-03-15 14:01:15 +01:00
lordlou
cfa49ee757 Add SMZ3 support (#270) 2022-03-15 13:55:57 +01:00
jtoyoda
8921baecd0 Adding in support for bizhawk 2.8 2022-03-14 23:29:02 +01:00
Fabian Dill
8b78477c69 WebHost: order guides by alphabet 2022-03-14 21:30:18 +01:00
Fabian Dill
14633724f2 MultiServer: don't count groups as players in status message 2022-03-14 20:31:57 +01:00
Fabian Dill
8d3ea9c50f Factorio: write Group names to mod 2022-03-14 20:26:16 +01:00
Fabian Dill
32a58b1adb Progression Balancing: fix ItemLinks and Spectator interactions 2022-03-14 20:10:49 +01:00
Fabian Dill
f01a31ce56 Factorio: add recipe for energy bridge 2022-03-14 19:40:35 +01:00
Chris Wilson
3f69c3a2ab Merge pull request #304 from LegendaryLinux/webhost-archipidle
[WebHost] Add docblock and FAQ pages for ArchipIDLE
2022-03-13 23:52:32 -04:00
Chris Wilson
e0f3d6d0d7 [WebHost] Add docblock and FAQ pages for ArchipIDLE 2022-03-13 23:44:30 -04:00
Chris Wilson
a8f148acac Merge pull request #303 from LegendaryLinux/archipidle
Fix generation issues with ArchipIDLE
2022-03-13 23:17:48 -04:00
Chris Wilson
0c57af40dc [ArchipIDLE] Rename locations to indicate the time required to wait 2022-03-13 22:56:46 -04:00
Chris Wilson
0714be6b73 [ArchipIDLE] Prevent overwriting global item pool 2022-03-13 20:44:08 -04:00
Chris Wilson
b5ce6f0bb0 [ArchipIDLE] Fix inefficiency caused by indentation error 2022-03-13 20:42:20 -04:00
Chris Wilson
67d59067eb [ArchipIDLE] Use shuffled item_table during generation 2022-03-13 20:39:13 -04:00
Chris Wilson
f1984a103d [ArchipIDLE] Set only 20 items as progressive 2022-03-13 15:31:27 -04:00
Chris Wilson
41fd7a8a56 Fixed failing tests 2022-03-13 14:37:56 -04:00
Chris Wilson
14ac139d03 Added world for ArchipIDLE 2022-03-13 04:04:12 -04:00
Yussur Mustafa Oraji
97b1ae5ee9 v6,sm64ex: Add support for offline singeplayer seeds (#301) 2022-03-12 22:05:54 +01:00
espeon65536
15e0763ed5 Update progression balancing algorithm (#300)
* New progression balancing algo: computes based on percentage of locations available rather than absolute number of locations
2022-03-12 22:05:03 +01:00
CaitSith2
3ce5d14210 changes
* Fix bug in overworld collected item checks.
* Don't mark checks as checked on the same cycle that its written just in case write fails for some reason. It will be later confirmed by a successful read of the newly written value on a future cycle.
2022-03-07 17:32:28 -08:00
CaitSith2
2c884e2ca5 Mark LttP items as collected in game if item is not owned by player. 2022-03-07 14:10:07 -08:00
CaitSith2
c204fb9b14 Fix LocationInfo packet handling. 2022-03-07 11:21:29 -08:00
Fabian Dill
69721d2d04 MultiServer: remove no longer needed value check from Set packet 2022-03-04 22:48:27 +01:00
Fabian Dill
73b14d3826 Factorio: rename "data" to "keys" to make EnergyLink work 2022-03-04 21:41:07 +01:00
Fabian Dill
7ca6f24e6c MultiServer: allow multiple, ordered operations
MultiServer: rename "data" on Get, Retrieved and SetNotify to "keys"
MultiServer: add some more operators
SniClient: some pep8 cleanup
2022-03-04 21:36:18 +01:00
lordlou
2c3e3f0d43 Sm/slot data (#299) 2022-03-02 19:41:03 +01:00
Alchav
3b68c6902c Save game options with server save data (#294) 2022-03-02 00:39:58 +01:00
espeon65536
c5926fcf2b OoT: rename all option displayname to display_name 2022-03-02 00:38:24 +01:00
lordlou
e6546eea85 Sm/slot data (#298)
for trackers
2022-03-02 00:37:52 +01:00
lordlou
892357cc2c Sm/item link support (#297) 2022-03-02 00:37:11 +01:00
CaitSith2
7c6fb26eb7 Filter new line characters from connect bar text input. 2022-02-28 18:25:07 -08:00
Fabian Dill
491530ad60 LttP: fix reveal bytes for Mysery Mire Prize 2022-02-24 23:43:33 +01:00
Fabian Dill
6667c1f03d Factorio: set parenthesis correctly 2022-02-24 22:50:51 +01:00
Fabian Dill
e985fc41ce Factorio: make EnergyLink an option 2022-02-24 22:40:16 +01:00
CaitSith2
508eb04e94 Tweak energy bridge values
ENERGY_INCREMENT now set dynamically by whatever the ap-energy-bridge buffer capacity ends up being.
2022-02-24 13:16:18 -08:00
Fabian Dill
68e9368bb3 EnergyLink: cleanup the second 2022-02-24 06:17:51 +01:00
CaitSith2
db152e6790 Fix deathlink killing the game watcher on startup. 2022-02-23 21:13:17 -08:00
Fabian Dill
6bf2f5611a EnergyLink: lots of cleanup 2022-02-24 04:47:01 +01:00
CaitSith2
11a13967d5 Report precisely what item link is invalid instead of ALL of them. 2022-02-23 16:21:53 -08:00
Fabian Dill
05fe423ef1 Factorio: implement EnergyLink 2022-02-24 00:51:31 +01:00
CaitSith2
6e0165986f Move duplicate name item link check to verify. 2022-02-23 15:17:24 -08:00
t3hf1gm3nt
f167e11905 Update ALttP in-game hints (#289) 2022-02-23 19:29:37 +01:00
Jarno Westhof
727cae902a [Subnautica] I guess someone had todo it 2022-02-23 19:26:17 +01:00
Fabian Dill
f38f9a47da Webhost: support groups without loading multidata on every /room request 2022-02-23 19:16:45 +01:00
CaitSith2
7708d3d157 Don't list item_link on neither trackers nor main patch download page. 2022-02-23 01:51:49 -08:00
Fabian Dill
4c64c5ad05 Spectator: fix data type 2022-02-23 04:02:11 +01:00
Fabian Dill
534ce179ec MultiServer: fix sending items_handling warning 2022-02-23 03:35:24 +01:00
espeon65536
1b73bacde1 Minecraft: add death_link attr to test world 2022-02-23 02:44:47 +01:00
espeon65536
a13ad32ec5 Minecraft: save some memory with static rules on Locations 2022-02-23 02:44:47 +01:00
espeon65536
13a6c86077 Minecraft: require bed for can_adventure if death link is on by default 2022-02-23 02:44:47 +01:00
espeon65536
5fc1b760f4 Minecraft: only add egg shards to the pool if at least 1 is required 2022-02-23 02:44:47 +01:00
jtoyoda
a6d78d9af7 Adding in the ability to disable messages in the client 2022-02-23 02:44:27 +01:00
CaitSith2
48669e96d1 Remove players from item_link pool if they don't contribute any items to the pool. 2022-02-22 16:35:41 -08:00
CaitSith2
071161176e Deny same item_link name from same player. Also report which player caused the item_link errors. 2022-02-22 16:32:37 -08:00
CaitSith2
f046d76c59 make sure starting location hints also apply to all applicable item_link players. 2022-02-22 12:49:43 -08:00
Fabian Dill
53ab224fba MultiServer: rip Store, Modify -> Set, Retrieve -> Get, Modified -> SetReply, ModifyNotify -> SetNotify 2022-02-22 12:17:21 +01:00
Fabian Dill
5faf1f27de MultiServer: add network commands Store, Retrieve, Modify and ModifyNotify 2022-02-22 11:48:08 +01:00
Fabian Dill
f38b970ea2 ItemLinks: hopefully fix remaining generation issues 2022-02-22 10:14:26 +01:00
Fabian Dill
5dbccfcbbd ItemLinks: fix all_state not collecting event locations 2022-02-22 09:49:01 +01:00
CaitSith2
de5249f99e start_hints now work for items in item_link pools. 2022-02-21 15:33:39 -08:00
CaitSith2
420320f896 Fix item_links not even rolling 2022-02-21 14:59:01 -08:00
Hussein Farran
06ac2d1805 Merge pull request #290 from N00byKing/patch-1
sm64ex: Documentation Updates
2022-02-21 11:35:14 -05:00
jtoyoda
cdc0b7a649 Fixing unit tests for FFR by excluding tests that use Default settings as FFR logic is controlled by the original randomizer 2022-02-21 00:01:27 +01:00
jtoyoda
6c7be51221 Adding in check to ensure there is at least one item in the FFR item pool 2022-02-21 00:01:27 +01:00
Fabian Dill
1159137c0d FF1: set up special settings page (remote website) 2022-02-20 21:54:00 +01:00
Fabian Dill
a98cb040b7 Core: Region type hints and some init optimization 2022-02-20 19:19:56 +01:00
Fabian Dill
170213e6d4 Core: reduce memory use of "Entrance" class
SM64: reduce count of lambda creations (memory/cpu speedup)
2022-02-20 19:10:08 +01:00
Yussur Mustafa Oraji
129c6d2d1e sm64ex: Documentation Updates 2022-02-20 12:41:16 +01:00
Fabian Dill
ad75ee8c50 Multiserver: warn about missing items_handling 2022-02-20 04:17:27 +01:00
Fabian Dill
e94b99da65 SNIClient: make address optional for multi-snes 2022-02-20 04:17:27 +01:00
CaitSith2
4f47709d32 Add entrance info to start hints. 2022-02-19 10:52:05 -08:00
Fabian Dill
71ea8d7148 Multiserver: provide compat for 0.2.3 and somewhat older multidata 2022-02-19 17:50:56 +01:00
Fabian Dill
919223cd2f Super Metroid: fix start_inventory 2022-02-19 17:43:16 +01:00
CaitSith2
fd8cace362 Reworked hints for item_link 2022-02-18 13:03:55 -08:00
Fabian Dill
18d937d83e Core: shuffle around AutoWorld imports 2022-02-18 20:29:44 +01:00
Yussur Mustafa Oraji
1d19868119 v6: Update NPC Trinket Rule 2022-02-18 19:32:36 +01:00
Fabian Dill
840e634161 update docs with NetworkSlot and create_as_hint 2022-02-18 18:54:26 +01:00
Fabian Dill
731eef8c2f bump version 2022-02-18 17:58:45 +01:00
CaitSith2
135ee018a9 update Copyright 2022-02-17 19:03:11 -08:00
Fabian Dill
7633392eea update Copyright 2022-02-17 08:21:26 +01:00
Fabian Dill
daea0f3e5e Core: provide a way to add to CollectionState init and copy
SM: use that way
OoT: use that way
2022-02-17 07:07:34 +01:00
Fabian Dill
c525c80b49 ItemLinks: move item links to events, mess up their logic in doing so and lock them behind plando option "item_links" until they're fixed. 2022-02-17 06:07:20 +01:00
N00byKing
311fb04647 sm64ex: Add option for Bob-omb Buddy Checks 2022-02-16 19:46:28 +01:00
Hussein Farran
219bd9c10e Merge pull request #285 from JarnoWesthof/add_reference_to_cpp_lib
[Docs] Added reference to the cpp lib
2022-02-16 09:01:00 -05:00
Jarno Westhof
6d704eadd7 [Docs] Added reference to the cpp lib 2022-02-16 13:05:47 +01:00
Hussein Farran
32da1993e1 Merge pull request #283 from alwaysintreble/tutorials
Tutorials: Clean up plando guide a bit; explain datapackage page. Add…
2022-02-15 15:58:03 -05:00
alwaysintreble
d4cad980e5 Tutorials: remove /api 2022-02-15 14:17:17 -06:00
Fabian Dill
53340ab22c Core: remove legacy "dynamic_regions", as all regions are now dynamic 2022-02-15 06:29:57 +01:00
alwaysintreble
2d3767a35c Tutorials: Clean up plando guide a bit; explain datapackage page. Add link to the weighted settings page in advanced tutorial. 2022-02-14 17:21:19 -06:00
Fabian Dill
aaa9bc906e WebHost: update dependencies 2022-02-14 21:37:50 +01:00
N00byKing
7503317d49 sm64ex: Add DeathLink Support 2022-02-14 16:37:49 +01:00
Fabian Dill
3fc93a33c8 WebHost: check for duplicate names
Generate: use Counter for duplicate names to make finding the dupes easier
2022-02-14 04:58:21 +01:00
Fabian Dill
d7d1d54a0b Core: generalize pre_fill item pool handling 2022-02-13 23:02:18 +01:00
Fabian Dill
34b9344084 ItemLink; correct validation to allow for None replacement item 2022-02-13 20:19:17 +01:00
espeon65536
779f3a8a61 OoT: regions are not barren if they contain never-exclude items 2022-02-12 17:29:06 +01:00
espeon65536
8c1690ef65 OoT: invert logic of previous commit 2022-02-12 17:29:06 +01:00
espeon65536
85f32d9a97 OoT: make Farore's Wind a never-exclude item if the relevant trick is off 2022-02-12 17:29:06 +01:00
espeon65536
54c7ec5873 OoT: ice traps have the trap attribute 2022-02-12 17:29:06 +01:00
espeon65536
8d260708d3 OoT: ER fixes
Don't allow beatable only to influence priority placements
Shuffle spawns after warp songs to prevent spawn points going to Desert Colossus
Prevent child spawn from priority placing at Colossus if overworld ER is off
2022-02-12 17:29:06 +01:00
espeon65536
f8009e4b84 OoT: certain ER options convert closed forest into closed deku + child start 2022-02-12 17:29:06 +01:00
Alchav
a2260ee6b2 [SM] Fix "No Energy" bugs 2022-02-12 17:28:23 +01:00
Bondo
6193eafb7b Update Text.py (#274)
Changed the Houlihan hint tile to list the winner of the SGLive 2021 tournament in similar style to alttp tournament winners.
2022-02-12 03:01:41 +01:00
black-sliver
a4eea3325f Document id range for items and locations 2022-02-12 03:00:09 +01:00
Jarno Westhof
b93e61b758 [Timnespinner] Implemented get_filler_item_name 2022-02-09 21:08:07 +01:00
Fabian Dill
14448ad97e Multidata: allow SoE/SM/LttP to connect via player name for use in Tracker/Text clients 2022-02-09 21:06:50 +01:00
Yussur Mustafa Oraji
3d17f0d588 sm64ex: Add Course Randomizer and Progressive Keys (#256) 2022-02-09 20:57:38 +01:00
CaitSith2
ee5ea09cbc Add an autofill !hint_location for clicking on a Missing: line, when user uses !missing. 2022-02-08 14:29:24 -08:00
Fabian Dill
aac8ca97ed Core: define unreachables as set 2022-02-07 00:26:44 +01:00
Fabian Dill
e4d6da47a4 LttP: fix rom writing crash because I accidentally defaulted to pep8 naming 2022-02-06 21:44:19 +01:00
Fabian Dill
9f7dbb394e LttP: convert overflow progressive items into highest-allowed-tier of non-progressive item 2022-02-06 20:11:40 +01:00
Fabian Dill
f98063b97a Options: move name verification into class methods, out of Generate.py 2022-02-06 16:37:21 +01:00
black-sliver
ed607bdc37 Fix wrong message when loading apsave
from doubling received_items that happened when moving from world-based to client-based remote_items
2022-02-06 12:28:46 +01:00
N00byKing
a3c3e4cbd4 v6: Add Area Cost Shuffle 2022-02-05 20:24:42 +01:00
ScootyPuffJr1
bffb8a034e [SM]Update Options.py (#268)
* [SM] Update Options.py
2022-02-05 20:23:17 +01:00
Fabian Dill
8242d4fe92 ItemLink: fix wrong variable use 2022-02-05 20:15:56 +01:00
Fabian Dill
279b682ac2 ItemLink: hopefully fix coop functionality 2022-02-05 17:35:12 +01:00
Fabian Dill
43ff476d98 AutoWorld: add "Everything" item_name_group to all worlds 2022-02-05 16:55:11 +01:00
Fabian Dill
28201a6c38 Core: implement first version of ItemLinks 2022-02-05 15:49:19 +01:00
N00byKing
6923800081 v6: Music Randomizer 2022-02-04 23:04:05 +01:00
Jarno Westhof
700b83572e [Timespinner] Added new shop options (#264)
* [Timespinner] Added new shop options
2022-02-04 21:53:47 +01:00
Fabian Dill
6e53cb2deb V6: some cleanup 2022-02-04 21:34:39 +01:00
Yussur Mustafa Oraji
8e04182b3f v6: Add Area Randomizer (#249)
* v6: Add Area Randomizer
2022-02-04 21:22:26 +01:00
Jarno Westhof
9fd6d1b81f [Server] Broadcast hint_cost and location_check_points update changes via RoomUpdate 2022-02-03 13:09:59 +01:00
Fabian Dill
60379d9ae6 LttP: when generating hint tiles, no longer consider Single Arrow as useful, but do consider all varieties of Bow. Additionally, don't create hints for Universal Small Keys 2022-02-03 10:41:31 +01:00
black-sliver
29ba1d4809 Doc: change displayname to display_name in api.md 2022-02-02 23:38:00 +01:00
Fabian Dill
dc4b064c73 Options: change displayname to display_name 2022-02-02 16:29:29 +01:00
Fabian Dill
0f20888563 Options: allow yaml access to Priority Locations 2022-02-01 16:36:14 +01:00
Brad Humphrey
2361f8f9d3 Use logic when placing non-excluded items 2022-02-01 16:35:18 +01:00
Chris Wilson
feba54d5d2 Fix filename for Super Mario 64 info page 2022-01-31 18:39:17 -05:00
Brad Humphrey
3cecab25c7 Add unplaced_items into the fill sweep 2022-01-31 19:17:06 +01:00
Brad Humphrey
814851ba60 Don't require every item to fill 2022-01-31 19:17:06 +01:00
Fabian Dill
6333cc3bea Server: optimize send_multiple 2022-01-31 19:05:00 +01:00
N00byKing
00bf9c569a Add send_multiple command 2022-01-31 18:56:46 +01:00
Jarno
6def1bce25 [Docs] Made LocationInfoPacket more specific 2022-01-31 18:55:20 +01:00
Jarno Westhof
3ab5c90d7c [Docs] updated description on player property of NetworkItem 2022-01-31 18:55:20 +01:00
N00byKing
0507d6923e sm64ex: Add Option to limit stars, replace with junk 2022-01-31 18:54:54 +01:00
N00byKing
e85baa8068 sm64ex: Link to release page 2022-01-31 10:57:57 +01:00
N00byKing
cbed5a0c14 sm64ex,v6: Add Note regarding spaces in arguments 2022-01-31 10:57:43 +01:00
Fabian Dill
e0628ec6c9 WebHost: correct some texts 2022-01-31 10:11:39 +01:00
Chris Wilson
82637ff072 [WebHost] Add version notice to /generate and /uploads 2022-01-30 20:06:03 -05:00
Chris Wilson
a95a18a8b5 [WebHost] weighted-settings: Add cursor hover to user-message 2022-01-30 16:53:53 -05:00
Chris Wilson
d36637ed13 Fix a bug causing the /weighted-settings page to fail to detect a change in the source JSON file 2022-01-30 16:50:04 -05:00
N00byKing
dd5e5dcda7 v6: Add missing info regarding Server Port 2022-01-30 18:49:39 +01:00
Jarno Westhof
0ff7fe8479 [Generation] Fixed creation of new Slot-Info 2022-01-30 17:09:10 +01:00
Fabian Dill
8c638bcfd8 Server: allow LocationScouts to create free hints 2022-01-30 14:14:49 +01:00
Fabian Dill
0bd252e7f5 Server: add slot_info key to Connected 2022-01-30 13:57:12 +01:00
Jarno Westhof
ddd3073132 [Docs] Fixed typo 2022-01-30 13:52:51 +01:00
N00byKing
1788422abc v6: Link to release instead of actions 2022-01-30 10:58:48 +01:00
388 changed files with 20304 additions and 3770 deletions

98
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,98 @@
# This workflow will build a release-like distribution when manually dispatched
name: Build
on: workflow_dispatch
jobs:
# build-release-macos: # LF volunteer
build-win-py38: # RCs will still be built and signed by hand
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
- name: Install python
uses: actions/setup-python@v3
with:
python-version: '3.8'
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.79/sni-v0.0.79-windows-amd64.zip -OutFile sni.zip
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/6.4/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
- name: Build
run: |
python -m pip install --upgrade pip setuptools==60.10.0 # 61 does not work with the current layout
pip install -r requirements.txt
python setup.py build --yes
$NAME="$(ls build)".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
New-Item -Path dist -ItemType Directory -Force
cd build
Rename-Item exe.$NAME Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
- name: Store 7z
uses: actions/upload-artifact@v2
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
retention-days: 7 # keep for 7 days, should be enough
build-ubuntu1804:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v3
with:
python-version: '3.9'
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.9" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
wget -nv https://github.com/black-sliver/sni/releases/download/v0.0.78-2/sni-v0.0.78-2-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz
rm sni-*.tar.xz
mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/6.4/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
run: |
# pygobject is an optional dependency for kivy that's not in requirements
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools==60.10.0 # setuptools same as windows
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python setup.py build --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
- name: Store AppImage
uses: actions/upload-artifact@v2
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
retention-days: 7
- name: Store .tar.gz
uses: actions/upload-artifact@v2
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}
retention-days: 7

84
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,84 @@
# This workflow will create a release and store builds to it when an x.y.z tag is pushed
name: Release
on:
push:
tags:
- '*.*.*'
jobs:
create-release:
runs-on: ubuntu-latest
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV # tag x.y.z will become "Archipelago x.y.z"
- name: Create Release
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
with:
draft: true # don't publish right away, especially since windows build is added by hand
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer
build-release-ubuntu1804:
runs-on: ubuntu-latest
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v2
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v3
with:
python-version: '3.9'
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.9" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
wget -nv https://github.com/black-sliver/sni/releases/download/v0.0.78-2/sni-v0.0.78-2-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz
rm sni-*.tar.xz
mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/6.4/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
run: |
"${{ env.PYTHON }}" -m pip install --upgrade pip setuptools virtualenv PyGObject # pygobject should probably move to requirements
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python setup.py build --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
- name: Add to Release
uses: softprops/action-gh-release@b7e450da2a4b4cb4bfbae528f788167786cfcedf
with:
draft: true # see above
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
files: |
dist/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -7,17 +7,22 @@ on: [push, pull_request]
jobs:
build:
runs-on: ${{ matrix.os }}
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
runs-on: ubuntu-latest
name: Test Python ${{ matrix.python.version }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
python:
- {version: '3.8'}
- {version: '3.9'}
#- {version: '3.10'}
- {version: '3.10'}
include:
- python: {version: '3.8'} # win7 compat
os: windows-latest
- python: {version: '3.10'} # current
os: windows-latest
steps:
- uses: actions/checkout@v2

View File

@@ -6,12 +6,22 @@ import logging
import json
import functools
from collections import OrderedDict, Counter, deque
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable
import secrets
import random
import Options
import Utils
import NetUtils
class Group(TypedDict, total=False):
name: str
game: str
world: auto_world
players: Set[int]
item_pool: Set[str]
replacement_items: Dict[int, Optional[str]]
class MultiWorld():
@@ -23,11 +33,19 @@ class MultiWorld():
dark_room_logic: Dict[int, str]
restrict_dungeon_item_on_boss: Dict[int, bool]
plando_texts: List[Dict[str, str]]
plando_items: List
plando_items: List[List[Dict[str, Any]]]
plando_connections: List
worlds: Dict[int, Any]
groups: Dict[int, Group]
itempool: List[Item]
is_race: bool = False
precollected_items: Dict[int, List[Item]]
state: CollectionState
accessibility: Dict[int, Options.Accessibility]
local_items: Dict[int, Options.LocalItems]
non_local_items: Dict[int, Options.NonLocalItems]
progression_balancing: Dict[int, Options.ProgressionBalancing]
class AttributeProxy():
def __init__(self, rule):
@@ -39,20 +57,21 @@ class MultiWorld():
def __init__(self, players: int):
self.random = random.Random() # world-local random state is saved for multiple generations running concurrently
self.players = players
self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids}
self.glitch_triforce = False
self.algorithm = 'balanced'
self.dungeons: Dict[Tuple[str, int], Dungeon] = {}
self.groups = {}
self.regions = []
self.shops = []
self.itempool = []
self.seed = None
self.seed_name: str = "Unavailable"
self.precollected_items = {player: [] for player in self.player_ids}
self.state = CollectionState(self)
self._cached_entrances = None
self._cached_locations = None
self._entrance_cache = {}
self._location_cache = {}
self._location_cache: Dict[Tuple[str, int], Location] = {}
self.required_locations = []
self.light_world_light_cone = False
self.dark_world_light_cone = False
@@ -63,8 +82,6 @@ class MultiWorld():
self.custom = False
self.customitemarray = []
self.shuffle_ganon = True
self.dynamic_regions = []
self.dynamic_locations = []
self.spoiler = Spoiler(self)
self.fix_trock_doors = self.AttributeProxy(
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
@@ -74,7 +91,6 @@ class MultiWorld():
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
self.fix_trock_exit = self.AttributeProxy(
lambda player: self.shuffle[player] not in ['vanilla', 'simple', 'restricted', 'dungeonssimple'])
self.NOTCURSED = self.AttributeProxy(lambda player: not self.CURSED[player])
for player in range(1, players + 1):
def set_player_attr(attr, val):
@@ -130,6 +146,42 @@ class MultiWorld():
self.worlds = {}
self.slot_seeds = {}
def get_all_ids(self):
return self.player_ids + tuple(self.groups)
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
"""Create a group with name and return the assigned player ID and group.
If a group of this name already exists, the set of players is extended instead of creating a new one."""
for group_id, group in self.groups.items():
if group["name"] == name:
group["players"] |= players
return group_id, group
new_id: int = self.players + len(self.groups) + 1
self.game[new_id] = game
self.custom_data[new_id] = {}
self.player_types[new_id] = NetUtils.SlotType.group
self._region_cache[new_id] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[game]
for option_key, option in world_type.options.items():
getattr(self, option_key)[new_id] = option(option.default)
for option_key, option in Options.common_options.items():
getattr(self, option_key)[new_id] = option(option.default)
for option_key, option in Options.per_game_common_options.items():
getattr(self, option_key)[new_id] = option(option.default)
self.worlds[new_id] = world_type(self, new_id)
self.worlds[new_id].collect_item = classmethod(AutoWorld.World.collect_item).__get__(self.worlds[new_id])
self.player_name[new_id] = name
new_group = self.groups[new_id] = Group(name=name, game=game, players=players,
world=self.worlds[new_id])
return new_id, new_group
def get_player_groups(self, player) -> Set[int]:
return {group_id for group_id, group in self.groups.items() if player in group["players"]}
def set_seed(self, seed: Optional[int] = None, secure: bool = False, name: Optional[str] = None):
self.seed = get_seed(seed)
if secure:
@@ -141,24 +193,58 @@ class MultiWorld():
range(1, self.players + 1)}
def set_options(self, args):
from worlds import AutoWorld
for option_key in Options.common_options:
setattr(self, option_key, getattr(args, option_key, {}))
for option_key in Options.per_game_common_options:
setattr(self, option_key, getattr(args, option_key, {}))
for player in self.player_ids:
self.custom_data[player] = {}
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
for option_key in world_type.options:
setattr(self, option_key, getattr(args, option_key, {}))
for option_key in Options.common_options:
setattr(self, option_key, getattr(args, option_key, {}))
for option_key in Options.per_game_common_options:
setattr(self, option_key, getattr(args, option_key, {}))
self.worlds[player] = world_type(self, player)
def set_item_links(self):
item_links = {}
for player in self.player_ids:
for item_link in self.item_links[player].value:
if item_link["name"] in item_links:
if item_link["game"] != self.game[player]:
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
item_links[item_link["name"]]["players"][player] = item_link["replacement_item"]
item_links[item_link["name"]]["item_pool"] &= set(item_link["item_pool"])
else:
if item_link["name"] in self.player_name.values():
raise Exception(f"Cannot name a ItemLink group the same as a player ({item_link['name']}) ({self.get_player_name(player)}).")
item_links[item_link["name"]] = {
"players": {player: item_link["replacement_item"]},
"item_pool": set(item_link["item_pool"]),
"game": self.game[player]
}
for name, item_link in item_links.items():
current_item_name_groups = AutoWorld.AutoWorldRegister.world_types[item_link["game"]].item_name_groups
pool = set()
for item in item_link["item_pool"]:
pool |= current_item_name_groups.get(item, {item})
item_link["item_pool"] = pool
for group_name, item_link in item_links.items():
game = item_link["game"]
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
group["item_pool"] = item_link["item_pool"]
group["replacement_items"] = item_link["players"]
# intended for unittests
def set_default_common_options(self):
for option_key, option in Options.common_options.items():
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
for option_key, option in Options.per_game_common_options.items():
setattr(self, option_key, {player_id: option(option.default) for player_id in self.player_ids})
self.state = CollectionState(self)
def secure(self):
self.random = secrets.SystemRandom()
@@ -174,7 +260,8 @@ class MultiWorld():
@functools.lru_cache()
def get_game_worlds(self, game_name: str):
return tuple(world for player, world in self.worlds.items() if self.game[player] == game_name)
return tuple(world for player, world in self.worlds.items() if
player not in self.groups and self.game[player] == game_name)
def get_name_string_for_object(self, obj) -> str:
return obj.name if self.players == 1 else f'{obj.name} ({self.get_player_name(obj.player)})'
@@ -182,6 +269,9 @@ class MultiWorld():
def get_player_name(self, player: int) -> str:
return self.player_name[player]
def get_file_safe_player_name(self, player: int) -> str:
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
def initialize_regions(self, regions=None):
for region in regions if regions else self.regions:
region.world = self
@@ -193,6 +283,7 @@ class MultiWorld():
def _recache(self):
"""Rebuild world cache"""
self._cached_locations = None
for region in self.regions:
player = region.player
self._region_cache[player][region.name] = region
@@ -241,10 +332,9 @@ class MultiWorld():
for item in self.itempool:
self.worlds[item.player].collect(ret, item)
from worlds.alttp.Dungeons import get_dungeon_item_pool
for item in get_dungeon_item_pool(self):
subworld = self.worlds[item.player]
if item.name in subworld.dungeon_local_item_names:
for player in self.player_ids:
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
ret.sweep_for_events()
@@ -252,7 +342,7 @@ class MultiWorld():
self._all_state = ret
return ret
def get_items(self) -> list:
def get_items(self) -> List[Item]:
return [loc.item for loc in self.get_filled_locations()] + self.itempool
def find_item_locations(self, item, player: int) -> List[Location]:
@@ -307,7 +397,7 @@ class MultiWorld():
def clear_location_cache(self):
self._cached_locations = None
def get_unfilled_locations(self, player=None) -> List[Location]:
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]:
if player is not None:
return [location for location in self.get_locations() if
location.player == player and not location.item]
@@ -316,13 +406,13 @@ class MultiWorld():
def get_unfilled_dungeon_locations(self):
return [location for location in self.get_locations() if not location.item and location.parent_region.dungeon]
def get_filled_locations(self, player=None) -> List[Location]:
def get_filled_locations(self, player: Optional[int] = None) -> List[Location]:
if player is not None:
return [location for location in self.get_locations() if
location.player == player and location.item is not None]
return [location for location in self.get_locations() if location.item is not None]
def get_reachable_locations(self, state=None, player=None) -> List[Location]:
def get_reachable_locations(self, state: Optional[CollectionState] = None, player: Optional[int] = None) -> List[Location]:
if state is None:
state = self.state
return [location for location in self.get_locations() if
@@ -334,7 +424,7 @@ class MultiWorld():
return [location for location in self.get_locations() if
(player is None or location.player == player) and location.item is None and location.can_reach(state)]
def get_unfilled_locations_for_players(self, locations, players: Iterable[int]):
def get_unfilled_locations_for_players(self, locations: List[str], players: Iterable[int]):
for player in players:
if len(locations) == 0:
locations = [location.name for location in self.get_unfilled_locations(player)]
@@ -343,7 +433,7 @@ class MultiWorld():
if location is not None and location.item is None:
yield location
def unlocks_new_location(self, item) -> bool:
def unlocks_new_location(self, item: Item) -> bool:
temp_state = self.state.copy()
temp_state.collect(item, True)
@@ -353,7 +443,7 @@ class MultiWorld():
return False
def has_beaten_game(self, state, player: Optional[int] = None):
def has_beaten_game(self, state: CollectionState, player: Optional[int] = None) -> bool:
if player:
return self.completion_condition[player](state)
else:
@@ -432,8 +522,9 @@ class MultiWorld():
def location_relevant(location: Location):
"""Determine if this location is relevant to sweep."""
if location.player in players["locations"] or location.event or \
(location.item and location.item.advancement):
if location.progress_type != LocationProgressType.EXCLUDED \
and (location.player in players["locations"] or location.event
or (location.item and location.item.advancement)):
return True
return False
@@ -471,17 +562,21 @@ class MultiWorld():
return False
class CollectionState(object):
class CollectionState():
additional_init_functions: List[Callable] = []
additional_copy_functions: List[Callable] = []
def __init__(self, parent: MultiWorld):
self.prog_items = Counter()
self.world = parent
self.reachable_regions = {player: set() for player in range(1, parent.players + 1)}
self.blocked_connections = {player: set() for player in range(1, parent.players + 1)}
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
self.blocked_connections = {player: set() for player in parent.get_all_ids()}
self.events = set()
self.path = {}
self.locations_checked = set()
self.stale = {player: True for player in range(1, parent.players + 1)}
self.stale = {player: True for player in parent.get_all_ids()}
for function in self.additional_init_functions:
function(self, parent)
for items in parent.precollected_items.values():
for item in items:
self.collect(item, True)
@@ -523,16 +618,21 @@ class CollectionState(object):
ret = CollectionState(self.world)
ret.prog_items = self.prog_items.copy()
ret.reachable_regions = {player: copy.copy(self.reachable_regions[player]) for player in
range(1, self.world.players + 1)}
self.reachable_regions}
ret.blocked_connections = {player: copy.copy(self.blocked_connections[player]) for player in
range(1, self.world.players + 1)}
self.blocked_connections}
ret.events = copy.copy(self.events)
ret.path = copy.copy(self.path)
ret.locations_checked = copy.copy(self.locations_checked)
for function in self.additional_copy_functions:
ret = function(self, ret)
return ret
def can_reach(self, spot, resolution_hint=None, player=None) -> bool:
if not hasattr(spot, "spot_type"):
def can_reach(self,
spot: Union[Location, Entrance, Region, str],
resolution_hint: Optional[str] = None,
player: Optional[int] = None) -> bool:
if not hasattr(spot, "can_reach"):
# try to resolve a name
if resolution_hint == 'Location':
spot = self.world.get_location(spot, player)
@@ -543,30 +643,32 @@ class CollectionState(object):
spot = self.world.get_region(spot, player)
return spot.can_reach(self)
def sweep_for_events(self, key_only: bool = False, locations=None):
def sweep_for_events(self, key_only: bool = False, locations: Set[Location] = None):
if locations is None:
locations = self.world.get_filled_locations()
new_locations = True
# since the loop has a good chance to run more than once, only filter the events once
locations = {location for location in locations if location.event}
locations = {location for location in locations if location.event and
not key_only or getattr(location.item, "locked_dungeon_item", False)}
while new_locations:
reachable_events = {location for location in locations if
(not key_only or getattr(location.item, "locked_dungeon_item", False))
and location.can_reach(self)}
reachable_events = {location for location in locations if location.can_reach(self)}
new_locations = reachable_events - self.events
for event in new_locations:
self.events.add(event)
self.collect(event.item, True, event)
def has(self, item, player: int, count: int = 1):
def has(self, item: str, player: int, count: int = 1) -> bool:
return self.prog_items[item, player] >= count
def has_all(self, items: Set[str], player: int):
def has_all(self, items: Set[str], player: int) -> bool:
return all(self.prog_items[item, player] for item in items)
def has_any(self, items: Set[str], player: int):
def has_any(self, items: Set[str], player: int) -> bool:
return any(self.prog_items[item, player] for item in items)
def count(self, item: str, player: int) -> int:
return self.prog_items[item, player]
def has_group(self, item_name_group: str, player: int, count: int = 1):
found: int = 0
for item_name in self.world.worlds[player].item_name_groups[item_name_group]:
@@ -641,7 +743,7 @@ class CollectionState(object):
basemagic = basemagic + basemagic * self.bottle_count(player)
return basemagic >= smallmagic
def can_kill_most_things(self, player: int, enemies=5) -> bool:
def can_kill_most_things(self, player: int, enemies: int = 5) -> bool:
return (self.has_melee_weapon(player)
or self.has('Cane of Somaria', player)
or (self.has('Cane of Byrna', player) and (enemies < 6 or self.can_extend_magic(player)))
@@ -716,26 +818,26 @@ class CollectionState(object):
def has_turtle_rock_medallion(self, player: int) -> bool:
return self.has(self.world.required_medallions[player][1], player)
def can_boots_clip_lw(self, player: int):
def can_boots_clip_lw(self, player: int) -> bool:
if self.world.mode[player] == 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player)
def can_boots_clip_dw(self, player: int):
def can_boots_clip_dw(self, player: int) -> bool:
if self.world.mode[player] != 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player)
def can_get_glitched_speed_lw(self, player: int):
def can_get_glitched_speed_lw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.world.mode[player] == 'inverted':
rules.append(self.has('Moon Pearl', player))
return all(rules)
def can_superbunny_mirror_with_sword(self, player: int):
def can_superbunny_mirror_with_sword(self, player: int) -> bool:
return self.has('Magic Mirror', player) and self.has_sword(player)
def can_get_glitched_speed_dw(self, player: int):
def can_get_glitched_speed_dw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.world.mode[player] != 'inverted':
rules.append(self.has('Moon Pearl', player))
@@ -744,7 +846,7 @@ class CollectionState(object):
def can_bomb_clip(self, region: Region, player: int) -> bool:
return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player)
def collect(self, item: Item, event: bool = False, location: Location = None) -> bool:
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
if location:
self.locations_checked.add(location)
@@ -784,19 +886,30 @@ class RegionType(int, Enum):
return self in (RegionType.Cave, RegionType.Dungeon)
class Region(object):
def __init__(self, name: str, type_: RegionType, hint, player: int, world: Optional[MultiWorld] = None):
class Region:
name: str
type: RegionType
hint_text: str
player: int
world: Optional[MultiWorld]
entrances: List[Entrance]
exits: List[Entrance]
locations: List[Location]
dungeon: Optional[Dungeon] = None
shop: Optional = None
# LttP specific. TODO: move to a LttPRegion
# will be set after making connections.
is_light_world: bool = False
is_dark_world: bool = False
def __init__(self, name: str, type_: RegionType, hint: str, player: int, world: Optional[MultiWorld] = None):
self.name = name
self.type = type_
self.entrances = []
self.exits = []
self.locations: List[Location] = []
self.dungeon = None
self.shop = None
self.locations = []
self.world = world
self.is_light_world = False # will be set after making connections.
self.is_dark_world = False
self.spot_type = 'Region'
self.hint_text = hint
self.player = player
@@ -820,18 +933,21 @@ class Region(object):
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
class Entrance(object):
spot_type = 'Entrance'
class Entrance:
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
hide_path: bool = False
player: int
name: str
parent_region: Optional[Region]
connected_region: Optional[Region] = None
# LttP specific, TODO: should make a LttPEntrance
addresses = None
target = None
def __init__(self, player: int, name: str = '', parent=None):
def __init__(self, player: int, name: str = '', parent: Region = None):
self.name = name
self.parent_region = parent
self.connected_region = None
self.target = None
self.addresses = None
self.access_rule = lambda state: True
self.player = player
self.hide_path = False
def can_reach(self, state: CollectionState) -> bool:
if self.parent_region.can_reach(state) and self.access_rule(state):
@@ -841,7 +957,7 @@ class Entrance(object):
return False
def connect(self, region: Region, addresses=None, target = None):
def connect(self, region: Region, addresses=None, target=None):
self.connected_region = region
self.target = target
self.addresses = addresses
@@ -856,7 +972,8 @@ class Entrance(object):
class Dungeon(object):
def __init__(self, name: str, regions, big_key, small_keys, dungeon_items, player: int):
def __init__(self, name: str, regions: List[Region], big_key: Item, small_keys: List[Item],
dungeon_items: List[Item], player: int):
self.name = name
self.regions = regions
self.big_key = big_key
@@ -875,11 +992,11 @@ class Dungeon(object):
self.bosses[None] = value
@property
def keys(self):
def keys(self) -> List[Item]:
return self.small_keys + ([self.big_key] if self.big_key else [])
@property
def all_items(self):
def all_items(self) -> List[Item]:
return self.dungeon_items + self.keys
def is_dungeon_item(self, item: Item) -> bool:
@@ -898,7 +1015,7 @@ class Dungeon(object):
class Boss():
def __init__(self, name: str, enemizer_name: str, defeat_rule, player: int):
def __init__(self, name: str, enemizer_name: str, defeat_rule: Callable, player: int):
self.name = name
self.enemizer_name = enemizer_name
self.defeat_rule = defeat_rule
@@ -916,28 +1033,28 @@ class LocationProgressType(Enum):
PRIORITY = 2
EXCLUDED = 3
class Location():
class Location:
# If given as integer, then this is the shop's inventory index
shop_slot: Optional[int] = None
shop_slot_disabled: bool = False
event: bool = False
locked: bool = False
spot_type = 'Location'
game: str = "Generic"
show_in_spoiler: bool = True
excluded: bool = False
crystal: bool = False
progress_type: LocationProgressType = LocationProgressType.DEFAULT
always_allow = staticmethod(lambda item, state: False)
access_rule = staticmethod(lambda state: True)
item_rule = staticmethod(lambda item: True)
item: Optional[Item] = None
parent_region: Optional[Region]
def __init__(self, player: int, name: str = '', address: int = None, parent=None):
self.name: str = name
self.address: Optional[int] = address
self.parent_region: Region = parent
self.parent_region = parent
self.player: int = player
self.item: Optional[Item] = None
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return self.always_allow(state, item) or (self.item_rule(item) and (not check_access or self.can_reach(state)))
@@ -952,6 +1069,7 @@ class Location():
if self.item:
raise Exception(f"Location {self} already filled.")
self.item = item
item.location = self
self.event = item.advancement
self.item.world = self.parent_region.world
self.locked = True
@@ -966,7 +1084,7 @@ class Location():
def __hash__(self):
return hash((self.name, self.player))
def __lt__(self, other):
def __lt__(self, other: Location):
return (self.player, self.name) < (other.player, other.name)
@property
@@ -975,7 +1093,7 @@ class Location():
return self.item and self.item.game == self.game
@property
def hint_text(self):
def hint_text(self) -> str:
hint_text = getattr(self, "_hint_text", None)
if hint_text:
return hint_text
@@ -993,6 +1111,8 @@ class Item():
trap: bool = False
# change manually to ensure that a specific non-progression item never goes on an excluded location
never_exclude = False
# item is not considered by progression balancing despite being progression
skip_in_prog_balancing: bool = False
# need to find a decent place for these to live and to allow other games to register texts if they want.
pedestal_credit_text: str = "and the Unknown Item"
@@ -1028,7 +1148,7 @@ class Item():
def __eq__(self, other):
return self.name == other.name and self.player == other.player
def __lt__(self, other):
def __lt__(self, other: Item):
if other.player != self.player:
return other.player < self.player
return self.name < other.name
@@ -1045,6 +1165,7 @@ class Item():
class Spoiler():
world: MultiWorld
unreachables: Set[Location]
def __init__(self, world):
self.world = world
@@ -1052,19 +1173,19 @@ class Spoiler():
self.entrances = OrderedDict()
self.medallions = {}
self.playthrough = {}
self.unreachables = []
self.unreachables = set()
self.locations = {}
self.paths = {}
self.shops = []
self.bosses = OrderedDict()
def set_entrance(self, entrance, exit, direction, player):
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
if self.world.players == 1:
self.entrances[(entrance, direction, player)] = OrderedDict(
[('entrance', entrance), ('exit', exit), ('direction', direction)])
[('entrance', entrance), ('exit', exit_), ('direction', direction)])
else:
self.entrances[(entrance, direction, player)] = OrderedDict(
[('player', player), ('entrance', entrance), ('exit', exit), ('direction', direction)])
[('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)])
def parse_data(self):
self.medallions = OrderedDict()
@@ -1193,8 +1314,7 @@ class Spoiler():
return json.dumps(out)
def to_file(self, filename):
from worlds.AutoWorld import call_all, call_single, call_stage
def to_file(self, filename: str):
self.parse_data()
def bool_to_text(variable: Union[bool, str]) -> str:
@@ -1204,9 +1324,9 @@ class Spoiler():
def write_option(option_key: str, option_obj: type(Options.Option)):
res = getattr(self.world, option_key)[player]
displayname = getattr(option_obj, "displayname", option_key)
display_name = getattr(option_obj, "display_name", option_key)
try:
outfile.write(f'{displayname + ":":33}{res.get_current_option_name()}\n')
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
except:
raise Exception
@@ -1216,7 +1336,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)
AutoWorld.call_stage(self.world, "write_spoiler_header", outfile)
for player in range(1, self.world.players + 1):
if self.world.players > 1:
@@ -1228,7 +1348,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)
AutoWorld.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]))
@@ -1278,7 +1398,7 @@ class Spoiler():
for dungeon, medallion in self.medallions.items():
outfile.write(f'\n{dungeon}: {medallion}')
call_all(self.world, "write_spoiler", outfile)
AutoWorld.call_all(self.world, "write_spoiler", outfile)
outfile.write('\n\nLocations:\n\n')
outfile.write('\n'.join(
@@ -1319,14 +1439,19 @@ 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)
AutoWorld.call_all(self.world, "write_spoiler_end", outfile)
seeddigits = 20
def get_seed(seed=None):
def get_seed(seed=None) -> int:
if seed is None:
random.seed(None)
return random.randint(0, pow(10, seeddigits) - 1)
return seed
from worlds import AutoWorld
auto_world = AutoWorld.World

660
ChecksFinderClient.py Normal file
View File

@@ -0,0 +1,660 @@
from __future__ import annotations
import os
import logging
import asyncio
import urllib.parse
import sys
import typing
import time
import websockets
import Utils
if __name__ == "__main__":
Utils.init_logging("ChecksFinderClient", exception_logger="Client")
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission
from Utils import Version, stream_input
from worlds import network_data_package, AutoWorldRegister
from CommonClient import gui_enabled, console_loop, logger, server_autoreconnect, get_base_parser, \
keep_alive
from worlds.checksfinder import ChecksFinderWorld
class ClientCommandProcessor(CommandProcessor):
def __init__(self, ctx: CommonContext):
self.ctx = ctx
def output(self, text: str):
logger.info(text)
def _cmd_exit(self) -> bool:
"""Close connections and client"""
self.ctx.exit_event.set()
return True
def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server"""
self.ctx.server_address = None
asyncio.create_task(self.ctx.connect(address if address else None), name="connecting")
return True
def _cmd_disconnect(self) -> bool:
"""Disconnect from a MultiWorld Server"""
self.ctx.server_address = None
asyncio.create_task(self.ctx.disconnect(), name="disconnecting")
return True
def _cmd_received(self) -> bool:
"""List all received items"""
logger.info(f'{len(self.ctx.items_received)} received items:')
for index, item in enumerate(self.ctx.items_received, 1):
self.output(f"{self.ctx.item_name_getter(item.item)} from {self.ctx.player_names[item.player]}")
return True
def _cmd_missing(self) -> bool:
"""List all missing location checks, from your local game state"""
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():
if location_id < 0:
continue
if location_id not in self.ctx.locations_checked:
if location_id in self.ctx.missing_locations:
self.output('Missing: ' + location)
count += 1
elif location_id in self.ctx.checked_locations:
self.output('Checked: ' + location)
count += 1
checked_count += 1
if count:
self.output(
f"Found {count} missing location checks{f'. {checked_count} location checks previously visited.' if checked_count else ''}")
else:
self.output("No missing location checks found.")
return True
def _cmd_items(self):
"""List all item names for the currently running game."""
self.output(f"Item Names for {self.ctx.game}")
for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id:
self.output(item_name)
def _cmd_locations(self):
"""List all location names for the currently running game."""
self.output(f"Location Names for {self.ctx.game}")
for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id:
self.output(location_name)
def _cmd_resync(self):
"""Manually trigger a resync."""
self.output(f"Syncing items.")
self.ctx.syncing = True
def _cmd_ready(self):
"""Send ready status to server."""
self.ctx.ready = not self.ctx.ready
if self.ctx.ready:
state = ClientStatus.CLIENT_READY
self.output("Readied up.")
else:
state = ClientStatus.CLIENT_CONNECTED
self.output("Unreadied.")
asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate")
def default(self, raw: str):
raw = self.ctx.on_user_say(raw)
if raw:
asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say")
class CommonContext():
tags: typing.Set[str] = {"AP"}
starting_reconnect_delay: int = 5
current_reconnect_delay: int = starting_reconnect_delay
command_processor: int = ClientCommandProcessor
game = None
ui = None
keep_alive_task = None
items_handling: typing.Optional[int] = None
current_energy_link_value = 0 # to display in UI, gets set by server
def __init__(self, server_address, password):
# server state
self.send_index: int = 0
self.server_address = server_address
self.password = password
self.syncing = False
self.awaiting_bridge = False
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
self.ready = False
self.team = None
self.slot = None
self.auth = None
self.seed_name = None
self.locations_checked: typing.Set[int] = set() # local state
self.locations_scouted: typing.Set[int] = set()
self.items_received = []
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()
self.watcher_event = asyncio.Event()
self.slow_mode = False
self.jsontotextparser = JSONtoTextParser(self)
self.set_getters(network_data_package)
# execution
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@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 = []
self.locations_info = {}
self.server_version = Version(0, 0, 0)
if self.server and self.server.socket is not None:
await self.server.socket.close()
self.server = None
self.server_task = None
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
for root, dirs, files in os.walk(path):
for file in files:
if file.find("obtain") <= -1:
os.remove(root+"/"+file)
# 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", {})
if local_package and local_package["version"] > network_data_package["version"]:
data_package: dict = local_package
elif network: # check if data from server is newer
if data_package["version"] > network_data_package["version"]:
Utils.persistent_store("datapackage", "latest", network_data_package)
item_lookup: dict = {}
locations_lookup: dict = {}
for game, gamedata in data_package["games"].items():
for item_name, item_id in gamedata["item_name_to_id"].items():
item_lookup[item_id] = item_name
for location_name, location_id in gamedata["location_name_to_id"].items():
locations_lookup[location_id] = location_name
def get_item_name_from_id(code: int):
return item_lookup.get(code, f'Unknown item (ID:{code})')
self.item_name_getter = get_item_name_from_id
def get_location_name_from_address(address: int):
return locations_lookup.get(address, f'Unknown location (ID:{address})')
self.location_name_getter = get_location_name_from_address
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
async def disconnect(self):
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task is not None:
await self.server_task
async def send_msgs(self, msgs):
if not self.server or not self.server.socket.open or self.server.socket.closed:
return
await self.server.socket.send(encode(msgs))
def consume_players_package(self, package: typing.List[tuple]):
self.player_names = {slot: name for team, slot, name, orig_name in package if self.team == team}
self.player_names[0] = "Archipelago"
def event_invalid_slot(self):
raise Exception('Invalid Slot; please verify that you have connected to the correct world.')
def event_invalid_game(self):
raise Exception('Invalid Game; please verify that you connected with the right game to the correct world.')
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
logger.info('Enter the password required to join this game:')
self.password = await self.console_input()
return self.password
async def send_connect(self, **kwargs):
payload = {
'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': self.tags, 'items_handling': self.items_handling,
'uuid': Utils.get_unique_identifier(), 'game': self.game
}
if kwargs:
payload.update(kwargs)
await self.send_msgs([payload])
async def console_input(self):
self.input_requests += 1
return await self.input_queue.get()
async def connect(self, address=None):
await self.disconnect()
self.server_task = asyncio.create_task(server_loop(self, address), name="server loop")
def on_print(self, args: dict):
logger.info(args["text"])
def on_print_json(self, args: dict):
if self.ui:
self.ui.print_json(args["data"])
else:
text = self.jsontotextparser(args["data"])
logger.info(text)
def on_package(self, cmd: str, args: dict):
pass
def on_user_say(self, text: str) -> typing.Optional[str]:
"""Gets called before sending a Say to the server from the user.
Returned text is sent, or sending is aborted if None is returned."""
return text
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)
async def shutdown(self):
self.server_address = None
if self.server and not self.server.socket.closed:
await self.server.socket.close()
if self.server_task:
await self.server_task
while self.input_requests > 0:
self.input_queue.put_nowait(None)
self.input_requests -= 1
self.keep_alive_task.cancel()
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
for root, dirs, files in os.walk(path):
for file in files:
if file.find("obtain") <= -1:
os.remove(root+"/"+file)
# DeathLink hooks
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 = ""):
if self.server and self.server.socket:
logger.info("DeathLink: 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 update_death_link(self, death_link):
old_tags = self.tags.copy()
if death_link:
self.tags.add("DeathLink")
else:
self.tags -= {"DeathLink"}
if old_tags != self.tags and self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])
async def server_loop(ctx: CommonContext, address=None):
cached_address = None
if ctx.server and ctx.server.socket:
logger.error('Already connected')
return
if address is None: # set through CLI or APBP
address = ctx.server_address
# Wait for the user to provide a multiworld server address
if not address:
logger.info('Please connect to an Archipelago server.')
return
address = f"ws://{address}" if "://" not in address else address
port = urllib.parse.urlparse(address).port or 38281
logger.info(f'Connecting to Archipelago server at {address}')
try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
ctx.server = Endpoint(socket)
logger.info('Connected')
ctx.server_address = address
ctx.current_reconnect_delay = ctx.starting_reconnect_delay
async for data in ctx.server.socket:
for msg in decode(data):
await process_server_cmd(ctx, msg)
logger.warning('Disconnected from multiworld server, type /connect to reconnect')
except ConnectionRefusedError:
if cached_address:
logger.error('Unable to connect to multiworld server at cached address. '
'Please use the connect button above.')
else:
logger.exception('Connection refused by the multiworld server')
except websockets.InvalidURI:
logger.exception('Failed to connect to the multiworld server (invalid URI)')
except (OSError, websockets.InvalidURI):
logger.exception('Failed to connect to the multiworld server')
except Exception as e:
logger.exception('Lost connection to the multiworld server, type /connect to reconnect')
finally:
await ctx.connection_closed()
if ctx.server_address:
logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s")
asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect")
ctx.current_reconnect_delay *= 2
async def process_server_cmd(ctx: CommonContext, args: dict):
try:
cmd = args["cmd"]
except:
logger.exception(f"Could not get command from {args}")
raise
if cmd == 'RoomInfo':
if ctx.seed_name and ctx.seed_name != args["seed_name"]:
logger.info("The server is running a different multiworld than your client is. (invalid seed_name)")
else:
logger.info('--------------------------------')
logger.info('Room Information:')
logger.info('--------------------------------')
version = args["version"]
ctx.server_version = tuple(version)
version = ".".join(str(item) for item in version)
logger.info(f'Server protocol version: {version}')
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
if args['password']:
logger.info('Password required')
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']}"
f" for each location checked. Use !hint for more information.")
ctx.hint_cost = int(args['hint_cost'])
ctx.check_points = int(args['location_check_points'])
if len(args['players']) < 1:
logger.info('No player connected')
else:
args['players'].sort()
current_team = -1
logger.info('Connected Players:')
for network_player in args['players']:
if network_player.team != current_team:
logger.info(f' Team #{network_player.team + 1}')
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
if args["datapackage_version"] > network_data_package["version"] or args["datapackage_version"] == 0:
await ctx.send_msgs([{"cmd": "GetDataPackage"}])
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
logger.info("Got new ID/Name Datapackage")
ctx.set_getters(args['data'], network=True)
elif cmd == 'ConnectionRefused':
errors = args["errors"]
if 'InvalidSlot' in errors:
ctx.event_invalid_slot()
elif 'InvalidGame' in errors:
ctx.event_invalid_game()
elif 'SlotAlreadyTaken' in errors:
raise Exception('Player slot already in use for that team')
elif 'IncompatibleVersion' in errors:
raise Exception('Server reported your client version as incompatible')
elif 'InvalidItemsHandling' in errors:
raise Exception('The item handling flags requested by the client are not supported')
# last to check, recoverable problem
elif 'InvalidPassword' in errors:
logger.error('Invalid password')
ctx.password = None
await ctx.server_auth(True)
elif errors:
raise Exception("Unknown connection errors: " + str(errors))
else:
raise Exception('Connection refused by the multiworld host, no reason provided')
elif cmd == 'Connected':
if not os.path.exists(os.path.expandvars(r"%localappdata%/ChecksFinder")):
os.mkdir(os.path.expandvars(r"%localappdata%/ChecksFinder"))
ctx.team = args["team"]
ctx.slot = args["slot"]
ctx.consume_players_package(args["players"])
msgs = []
if ctx.locations_checked:
msgs.append({"cmd": "LocationChecks",
"locations": list(ctx.locations_checked)})
if ctx.locations_scouted:
msgs.append({"cmd": "LocationScouts",
"locations": list(ctx.locations_scouted)})
if msgs:
await ctx.send_msgs(msgs)
if ctx.finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
# Get the server side view of missing as of time of connecting.
# This list is used to only send to the server what is reported as ACTUALLY Missing.
# 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 = set(args["missing_locations"])
ctx.checked_locations = set(args["checked_locations"])
for ss in ctx.checked_locations:
filename = f"send{ss}"
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
f.close()
elif cmd == 'ReceivedItems':
start_index = args["index"]
if start_index == 0:
ctx.items_received = []
elif start_index != len(ctx.items_received):
sync_msg = [{'cmd': 'Sync'}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks",
"locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
if start_index == len(ctx.items_received):
for item in args['items']:
filename = f"AP_{str(NetworkItem(*item).location)}PLR{str(NetworkItem(*item).player)}.item"
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
f.write(str(NetworkItem(*item).item))
f.close()
ctx.items_received.append(NetworkItem(*item))
ctx.watcher_event.set()
elif cmd == 'LocationInfo':
for item, location, player in args['locations']:
if location not in ctx.locations_info:
ctx.locations_info[location] = (item, player)
ctx.watcher_event.set()
elif cmd == "RoomUpdate":
if "players" in args:
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
for ss in ctx.checked_locations:
filename = f"send{ss}"
with open(os.path.expandvars(r"%localappdata%/ChecksFinder/"+filename), 'w') as f:
f.close()
if "permissions" in args:
ctx.update_permissions(args["permissions"])
elif cmd == 'Print':
ctx.on_print(args)
elif cmd == 'PrintJSON':
ctx.on_print_json(args)
elif cmd == 'InvalidPacket':
logger.warning(f"Invalid Packet of {args['type']}: {args['text']}")
elif cmd == "Bounced":
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"])
elif cmd == "SetReply":
if args["key"] == "EnergyLink":
ctx.current_energy_link_value = args["value"]
if ctx.ui:
ctx.ui.set_new_energy_link_value()
else:
logger.debug(f"unknown command {cmd}")
ctx.on_package(cmd, args)
async def game_watcher(ctx: CommonContext):
from worlds.checksfinder.Locations import lookup_id_to_name
while not ctx.exit_event.is_set():
if ctx.syncing == True:
sync_msg = [{'cmd': 'Sync'}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
ctx.syncing = False
path = os.path.expandvars(r"%localappdata%/ChecksFinder")
sending = []
victory = False
for root, dirs, files in os.walk(path):
for file in files:
if file.find("send") > -1:
st = file.split("send", -1)[1]
sending = sending+[(int(st))]
if file.find("victory") > -1:
victory = True
ctx.locations_checked = sending
message = [{"cmd": 'LocationChecks', "locations": sending}]
await ctx.send_msgs(message)
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
await asyncio.sleep(0.1)
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
class TextContext(CommonContext):
game = "ChecksFinder"
items_handling = 0b111 # full remote
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(TextContext, self).server_auth(password_requested)
if not self.auth:
logger.info('Enter slot name:')
self.auth = await self.console_input()
await self.send_connect()
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="server loop")
input_task = None
if gui_enabled:
from kvui import ChecksFinderManager
ctx.ui = ChecksFinderManager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
ui_task = None
if sys.stdin:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
progression_watcher = asyncio.create_task(
game_watcher(ctx), name="ChecksFinderProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
await progression_watcher
await ctx.shutdown()
if ui_task:
await ui_task
if input_task:
input_task.cancel()
import colorama
parser = get_base_parser(description="ChecksFinder Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.init()
loop = asyncio.get_event_loop()
loop.run_until_complete(main(args))
loop.close()
colorama.deinit()

View File

@@ -119,6 +119,7 @@ class CommonContext():
ui = None
keep_alive_task = None
items_handling: typing.Optional[int] = None
current_energy_link_value = 0 # to display in UI, gets set by server
def __init__(self, server_address, password):
# server state
@@ -148,7 +149,7 @@ class CommonContext():
self.items_received = []
self.missing_locations: typing.Set[int] = set()
self.checked_locations: typing.Set[int] = set() # server state
self.locations_info = {}
self.locations_info: typing.Dict[int, NetworkItem] = {}
self.input_queue = asyncio.Queue()
self.input_requests = 0
@@ -317,16 +318,17 @@ class CommonContext():
logger.info(f"DeathLink: Received from {data['source']}")
async def send_death(self, death_text: str = ""):
logger.info("DeathLink: 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
}
}])
if self.server and self.server.socket:
logger.info("DeathLink: 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 update_death_link(self, death_link):
old_tags = self.tags.copy()
@@ -441,7 +443,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
else:
args['players'].sort()
current_team = -1
logger.info('Players:')
logger.info('Connected Players:')
for network_player in args['players']:
if network_player.team != current_team:
logger.info(f' Team #{network_player.team + 1}')
@@ -517,9 +519,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.watcher_event.set()
elif cmd == 'LocationInfo':
for item, location, player in args['locations']:
if location not in ctx.locations_info:
ctx.locations_info[location] = (item, player)
for item in [NetworkItem(*item) for item in args['locations']]:
ctx.locations_info[item.location] = item
ctx.watcher_event.set()
elif cmd == "RoomUpdate":
@@ -548,7 +549,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
# 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"])
elif cmd == "SetReply":
if args["key"] == "EnergyLink":
ctx.current_energy_link_value = args["value"]
if ctx.ui:
ctx.ui.set_new_energy_link_value()
else:
logger.debug(f"unknown command {cmd}")

View File

@@ -18,6 +18,8 @@ CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
class FF1CommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
@@ -28,6 +30,12 @@ class FF1CommandProcessor(ClientCommandProcessor):
if isinstance(self.ctx, FF1Context):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in bizhawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
class FF1Context(CommonContext):
command_processor = FF1CommandProcessor
@@ -42,6 +50,7 @@ class FF1Context(CommonContext):
self.nes_status = CONNECTION_INITIAL_STATUS
self.game = 'Final Fantasy'
self.awaiting_rom = False
self.display_msgs = True
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -54,7 +63,8 @@ class FF1Context(CommonContext):
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
self.messages[(time.time(), msg_id)] = msg
if DISPLAY_MSGS:
self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
@@ -215,6 +225,9 @@ if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("FF1Client")
options = Utils.get_options()
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
async def main(args):
ctx = FF1Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")

View File

@@ -19,7 +19,7 @@ if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client")
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \
get_base_parser
get_base_parser
from MultiServer import mark_raw
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
@@ -62,6 +62,8 @@ class FactorioContext(CommonContext):
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)
self.energy_link_increment = 0
self.last_deplete = 0
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -105,6 +107,20 @@ class FactorioContext(CommonContext):
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"]})
if cmd == "Connected" and self.energy_link_increment:
asyncio.create_task(self.send_msgs([{
"cmd": "SetNotify", "keys": ["EnergyLink"]
}]))
elif cmd == "SetReply":
if args["key"] == "EnergyLink":
if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete:
# it's our deplete request
gained = int(args["original_value"] - args["value"])
gained_text = Utils.format_SI_prefix(gained) + "J"
if gained:
logger.info(f"EnergyLink: Received {gained_text}. "
f"{Utils.format_SI_prefix(args['value'])}J remaining.")
self.rcon_client.send_command(f"/ap-energylink {gained}")
async def game_watcher(ctx: FactorioContext):
@@ -113,7 +129,8 @@ async def game_watcher(ctx: FactorioContext):
next_bridge = time.perf_counter() + 1
try:
while not ctx.exit_event.is_set():
if ctx.awaiting_bridge and ctx.rcon_client and time.perf_counter() > next_bridge:
# TODO: restore on-demand refresh
if 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"))
@@ -127,8 +144,7 @@ async def game_watcher(ctx: FactorioContext):
research_data = data["research_done"]
research_data = {int(tech_name.split("-")[1]) for tech_name in research_data}
victory = data["victory"]
if "death_link" in data: # TODO: Remove this if statement around version 0.2.4 or so
await ctx.update_death_link(data["death_link"])
await ctx.update_death_link(data["death_link"])
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
@@ -144,7 +160,31 @@ async def game_watcher(ctx: FactorioContext):
if death_link_tick != ctx.death_link_tick:
ctx.death_link_tick = death_link_tick
if "DeathLink" in ctx.tags:
await ctx.send_death()
asyncio.create_task(ctx.send_death())
if ctx.energy_link_increment:
in_world_bridges = data["energy_bridges"]
if in_world_bridges:
in_world_energy = data["energy"]
if in_world_energy < (ctx.energy_link_increment * in_world_bridges):
# attempt to refill
ctx.last_deplete = time.time()
asyncio.create_task(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges},
{"operation": "max", "value": 0}],
"last_deplete": ctx.last_deplete
}]))
# Above Capacity - (len(Bridges) * ENERGY_INCREMENT)
elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \
ctx.energy_link_increment*in_world_bridges:
value = ctx.energy_link_increment * in_world_bridges
asyncio.create_task(ctx.send_msgs([{
"cmd": "Set", "key": "EnergyLink", "operations":
[{"operation": "add", "value": value}]
}]))
ctx.rcon_client.send_command(
f"/ap-energylink -{value}")
logger.info(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J")
await asyncio.sleep(0.1)
@@ -233,12 +273,16 @@ async def factorio_server_watcher(ctx: FactorioContext):
factorio_process.wait(5)
async def get_info(ctx, rcon_client):
async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient):
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))
ctx.energy_link_increment = info.get("energy_link", 0)
logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}")
if ctx.energy_link_increment and ctx.ui:
ctx.ui.enable_energy_link()
await ctx.update_death_link(death_link)

203
Fill.py
View File

@@ -4,7 +4,6 @@ import collections
import itertools
from collections import Counter, deque
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item
from worlds.AutoWorld import call_all
@@ -14,7 +13,7 @@ class FillError(RuntimeError):
pass
def sweep_from_pool(base_state: CollectionState, itempool=[]):
def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] = tuple()) -> CollectionState:
new_state = base_state.copy()
for item in itempool:
new_state.collect(item, True)
@@ -22,13 +21,13 @@ def sweep_from_pool(base_state: CollectionState, itempool=[]):
return new_state
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations, itempool: typing.List[Item],
single_player_placement=False, lock=False):
unplaced_items = []
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False) -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items = Counter()
reachable_items: typing.Dict[int, deque] = {}
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
for item in itempool:
reachable_items.setdefault(item.player, deque()).append(item)
@@ -38,7 +37,8 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
for items in reachable_items.values() if items]
for item in items_to_place:
itempool.remove(item)
maximum_exploration_state = sweep_from_pool(base_state, itempool)
maximum_exploration_state = sweep_from_pool(
base_state, itempool + unplaced_items)
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
@@ -46,7 +46,8 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
spot_to_fill: typing.Optional[Location] = None
if world.accessibility[item_to_place.player] == 'minimal':
perform_access_check = not world.has_beaten_game(maximum_exploration_state,
item_to_place.player) if single_player_placement else not has_beaten_game
item_to_place.player) \
if single_player_placement else not has_beaten_game
else:
perform_access_check = True
@@ -61,7 +62,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
else:
# we filled all reachable spots.
# try swapping this item with previously placed items
for(i, location) in enumerate(placements):
for (i, location) in enumerate(placements):
placed_item = location.item
# Unplaceable items can sometimes be swapped infinitely. Limit the
# number of times we will swap an individual item to prevent this
@@ -106,35 +107,39 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations,
placed_item.location = location
if spot_to_fill is None:
# Maybe the game can be beaten anyway?
# Can't place this item, move on to the next
unplaced_items.append(item_to_place)
if world.accessibility[item_to_place.player] != 'minimal' and world.can_beat_game():
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)}')
continue
world.push_item(spot_to_fill, item_to_place, False)
spot_to_fill.locked = lock
placements.append(spot_to_fill)
spot_to_fill.event = True
spot_to_fill.event = item_to_place.advancement
if len(unplaced_items) > 0 and len(locations) > 0:
# There are leftover unplaceable items and locations that won't accept them
if world.can_beat_game():
logging.warning(
f'Not all items placed. Game beatable anyway. (Could not place {unplaced_items})')
else:
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
itempool.extend(unplaced_items)
def distribute_items_restrictive(world: MultiWorld):
def distribute_items_restrictive(world: MultiWorld) -> None:
fill_locations = sorted(world.get_unfilled_locations())
world.random.shuffle(fill_locations)
# get items to distribute
itempool = sorted(world.itempool)
world.random.shuffle(itempool)
progitempool = []
nonexcludeditempool = []
localrestitempool = {player: [] for player in range(1, world.players + 1)}
nonlocalrestitempool = []
restitempool = []
progitempool: typing.List[Item] = []
nonexcludeditempool: typing.List[Item] = []
localrestitempool: typing.Dict[int, typing.List[Item]] = {player: [] for player in range(1, world.players + 1)}
nonlocalrestitempool: typing.List[Item] = []
restitempool: typing.List[Item] = []
for item in itempool:
if item.advancement:
@@ -152,7 +157,7 @@ def distribute_items_restrictive(world: MultiWorld):
localrestitempool, nonlocalrestitempool, restitempool, fill_locations)
locations: typing.Dict[LocationProgressType, typing.List[Location]] = {
type: [] for type in LocationProgressType}
loc_type: [] for loc_type in LocationProgressType}
for loc in fill_locations:
locations[loc.progress_type].append(loc)
@@ -174,8 +179,8 @@ def distribute_items_restrictive(world: MultiWorld):
if nonexcludeditempool:
world.random.shuffle(defaultlocations)
# needs logical fill to not conflict with local items
nonexcludeditempool, defaultlocations = fast_fill(
world, nonexcludeditempool, defaultlocations)
fill_restrictive(
world, world.state, defaultlocations, nonexcludeditempool)
if nonexcludeditempool:
raise FillError(
f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations')
@@ -184,7 +189,7 @@ def distribute_items_restrictive(world: MultiWorld):
world.random.shuffle(defaultlocations)
if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds
local_locations = {player: [] for player in world.player_ids}
local_locations: typing.Dict[int, typing.List[Location]] = {player: [] for player in world.player_ids}
for location in defaultlocations:
local_locations[location.player].append(location)
for player_locations in local_locations.values():
@@ -220,7 +225,7 @@ def distribute_items_restrictive(world: MultiWorld):
if unplaced or unfilled:
logging.warning(
f'Unplaced items({len(unplaced)}): {unplaced} - Unfilled Locations({len(unfilled)}): {unfilled}')
items_counter = Counter([location.item.player for location in world.get_locations()])
items_counter = Counter([location.item.player for location in world.get_locations() if location.item])
locations_counter = Counter([location.player for location in world.get_locations()])
items_counter.update([item.player for item in unplaced])
locations_counter.update([location.player for location in unfilled])
@@ -228,14 +233,16 @@ def distribute_items_restrictive(world: MultiWorld):
logging.info(f'Per-Player counts: {print_data})')
def fast_fill(world: MultiWorld, item_pool: typing.List, fill_locations: typing.List) -> typing.Tuple[typing.List, typing.List]:
def fast_fill(world: MultiWorld,
item_pool: typing.List[Item],
fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]:
placing = min(len(item_pool), len(fill_locations))
for item, location in zip(item_pool, fill_locations):
world.push_item(location, item, False)
return item_pool[placing:], fill_locations[placing:]
def flood_items(world: MultiWorld):
def flood_items(world: MultiWorld) -> None:
# get items to distribute
world.random.shuffle(world.itempool)
itempool = world.itempool
@@ -274,7 +281,8 @@ def flood_items(world: MultiWorld):
item_to_place = item
break
# we might be in a situation where all new locations require multiple items to reach. If that is the case, just place any advancement item we've found and continue trying
# we might be in a situation where all new locations require multiple items to reach.
# If that is the case, just place any advancement item we've found and continue trying
if item_to_place is None:
if candidate_item_to_place is not None:
item_to_place = candidate_item_to_place
@@ -295,71 +303,113 @@ def flood_items(world: MultiWorld):
break
def balance_multiworld_progression(world: MultiWorld):
balanceable_players = {player for player in range(1, world.players + 1) if world.progression_balancing[player]}
def balance_multiworld_progression(world: MultiWorld) -> None:
# A system to reduce situations where players have no checks remaining, popularly known as "BK mode."
# Overall progression balancing algorithm:
# Gather up all locations in a sphere.
# Define a threshold value based on the player with the most available locations.
# If other players are below the threshold value, swap progression in this sphere into earlier spheres,
# which gives more locations available by this sphere.
balanceable_players = {player for player in world.player_ids if world.progression_balancing[player]}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
else:
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
state = CollectionState(world)
checked_locations = set()
checked_locations: typing.Set[Location] = set()
unchecked_locations = set(world.get_locations())
reachable_locations_count = {player: 0 for player in world.player_ids}
reachable_locations_count = {
player: 0
for player in world.player_ids
if len(world.get_filled_locations(player)) != 0
}
total_locations_count = Counter(location.player for location in world.get_locations() if not location.locked)
balanceable_players = {player for player in balanceable_players if total_locations_count[player]}
sphere_num = 1
moved_item_count = 0
def get_sphere_locations(sphere_state, locations):
def get_sphere_locations(sphere_state: CollectionState,
locations: typing.Set[Location]) -> typing.Set[Location]:
sphere_state.sweep_for_events(key_only=True, locations=locations)
return {loc for loc in locations if sphere_state.can_reach(loc)}
def item_percentage(player: int, num: int) -> float:
return num / total_locations_count[player]
while True:
# Gather non-locked locations.
# This ensures that only shuffled locations get counted for progression balancing,
# i.e. the items the players will be checking.
sphere_locations = get_sphere_locations(state, unchecked_locations)
for location in sphere_locations:
unchecked_locations.remove(location)
reachable_locations_count[location.player] += 1
if not location.locked:
reachable_locations_count[location.player] += 1
logging.debug(f"Sphere {sphere_num}")
logging.debug(f"Reachable locations: {reachable_locations_count}")
debug_percentages = {
player: round(item_percentage(player, num), 2)
for player, num in reachable_locations_count.items()
}
logging.debug(f"Reachable percentages: {debug_percentages}\n")
sphere_num += 1
if checked_locations:
threshold = max(reachable_locations_count.values()) - 20
# The 10% threshold can be modified for "progression balancing strength"
# right now it approximates the old 20/216 bound.
threshold_percentage = max(map(lambda p: item_percentage(p, reachable_locations_count[p]),
reachable_locations_count)) - 0.10
logging.debug(f"Threshold: {threshold_percentage}")
balancing_players = {player for player, reachables in reachable_locations_count.items() if
reachables < threshold and player in balanceable_players}
item_percentage(player, reachables) < threshold_percentage and player in balanceable_players}
if balancing_players:
balancing_state = state.copy()
balancing_unchecked_locations = unchecked_locations.copy()
balancing_reachables = reachable_locations_count.copy()
balancing_sphere = sphere_locations.copy()
candidate_items = collections.defaultdict(set)
candidate_items: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
while True:
# Check locations in the current sphere and gather progression items to swap earlier
for location in balancing_sphere:
if location.event:
balancing_state.collect(location.item, True, location)
player = location.item.player
# only replace items that end up in another player's world
if(not location.locked and
if (not location.locked and not location.item.skip_in_prog_balancing and
player in balancing_players and
location.player != player and
location.progress_type != LocationProgressType.PRIORITY):
candidate_items[player].add(location)
logging.debug(f"Candidate item: {location.name}, {location.item.name}")
balancing_sphere = get_sphere_locations(balancing_state, balancing_unchecked_locations)
for location in balancing_sphere:
balancing_unchecked_locations.remove(location)
balancing_reachables[location.player] += 1
if not location.locked:
balancing_reachables[location.player] += 1
if world.has_beaten_game(balancing_state) or all(
reachables >= threshold for reachables in balancing_reachables.values()):
item_percentage(player, reachables) >= threshold_percentage
for player, reachables in balancing_reachables.items()):
break
elif not balancing_sphere:
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
unlocked_locations = collections.defaultdict(set)
# Gather a set of locations which we can swap items into
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
for l in unchecked_locations:
if l not in balancing_unchecked_locations:
unlocked_locations[l.player].add(l)
items_to_replace = []
items_to_replace: typing.List[Location] = []
for player in balancing_players:
locations_to_test = unlocked_locations[player]
items_to_test = candidate_items[player]
while items_to_test:
testing = items_to_test.pop()
reducing_state = state.copy()
for location in itertools.chain((l for l in items_to_replace if l.item.player == player),
items_to_test):
for location in itertools.chain((
l for l in items_to_replace
if l.item.player == player
), items_to_test):
reducing_state.collect(location.item, True, location)
reducing_state.sweep_for_events(locations=locations_to_test)
@@ -369,7 +419,8 @@ def balance_multiworld_progression(world: MultiWorld):
items_to_replace.append(testing)
else:
reduced_sphere = get_sphere_locations(reducing_state, locations_to_test)
if reachable_locations_count[player] + len(reduced_sphere) < threshold:
p = item_percentage(player, reachable_locations_count[player] + len(reduced_sphere))
if p < threshold_percentage:
items_to_replace.append(testing)
replaced_items = False
@@ -381,6 +432,7 @@ def balance_multiworld_progression(world: MultiWorld):
items_to_replace.sort()
world.random.shuffle(items_to_replace)
# Start swapping items. Since we swap into earlier spheres, no need for accessibility checks.
while replacement_locations and items_to_replace:
old_location = items_to_replace.pop()
for new_location in replacement_locations:
@@ -390,6 +442,7 @@ def balance_multiworld_progression(world: MultiWorld):
swap_location_item(old_location, new_location)
logging.debug(f"Progression balancing moved {new_location.item} to {new_location}, "
f"displacing {old_location.item} into {old_location}")
moved_item_count += 1
state.collect(new_location.item, True, new_location)
replaced_items = True
break
@@ -397,10 +450,12 @@ def balance_multiworld_progression(world: MultiWorld):
logging.warning(f"Could not Progression Balance {old_location.item}")
if replaced_items:
logging.debug(f"Moved {moved_item_count} items so far\n")
unlocked = {fresh for player in balancing_players for fresh in unlocked_locations[player]}
for location in get_sphere_locations(state, unlocked):
unchecked_locations.remove(location)
reachable_locations_count[location.player] += 1
if not location.locked:
reachable_locations_count[location.player] += 1
sphere_locations.add(location)
for location in sphere_locations:
@@ -415,7 +470,7 @@ def balance_multiworld_progression(world: MultiWorld):
break
def swap_location_item(location_1: Location, location_2: Location, check_locked=True):
def swap_location_item(location_1: Location, location_2: Location, check_locked: bool = True) -> None:
"""Swaps Items of locations. Does NOT swap flags like shop_slot or locked, but does swap event"""
if check_locked:
if location_1.locked:
@@ -426,16 +481,17 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked=
location_1.item.location = location_1
location_2.item.location = location_2
location_1.event, location_2.event = location_2.event, location_1.event
def distribute_planned(world: MultiWorld):
def warn(warning: str, force):
def distribute_planned(world: MultiWorld) -> None:
def warn(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
logging.warning(f'{warning}')
else:
logging.debug(f'{warning}')
def failed(warning: str, force):
if force in [True, 'fail', 'failure']:
def failed(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure']:
raise Exception(warning)
else:
warn(warning, force)
@@ -444,7 +500,8 @@ def distribute_planned(world: MultiWorld):
from worlds.alttp.Regions import key_drop_data
world_name_lookup = world.world_name_lookup
plando_blocks = []
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
player_ids = set(world.player_ids)
for player in player_ids:
for block in world.plando_items[player]:
@@ -455,7 +512,7 @@ def distribute_planned(world: MultiWorld):
block['from_pool'] = True
if 'world' not in block:
block['world'] = False
items = []
items: block_value = []
if "items" in block:
items = block["items"]
if 'count' not in block:
@@ -468,7 +525,7 @@ def distribute_planned(world: MultiWorld):
failed("You must specify at least one item to place items with plando.", block['force'])
continue
if isinstance(items, dict):
item_list = []
item_list: typing.List[str] = []
for key, value in items.items():
if value is True:
value = world.itempool.count(world.worlds[player].create_item(key))
@@ -478,7 +535,7 @@ def distribute_planned(world: MultiWorld):
items = [items]
block['items'] = items
locations = []
locations: block_value = []
if 'location' in block:
locations = block['location'] # just allow 'location' to keep old yamls compatible
elif 'locations' in block:
@@ -491,20 +548,18 @@ def distribute_planned(world: MultiWorld):
for key, value in locations.items():
location_list += [key] * value
locations = location_list
if isinstance(locations, str):
locations = [locations]
block['locations'] = locations
if not block['count']:
block['count'] = (min(len(block['items']), len(block['locations'])) if len(block['locations'])
> 0 else len(block['items']))
block['count'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if isinstance(block['count'], int):
block['count'] = {'min': block['count'], 'max': block['count']}
if 'min' not in block['count']:
block['count']['min'] = 0
if 'max' not in block['count']:
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if len(block['locations'])
> 0 else len(block['items']))
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if block['count']['max'] > len(block['items']):
count = block['count']
failed(f"Plando count {count} greater than items specified", block['force'])
@@ -534,20 +589,19 @@ def distribute_planned(world: MultiWorld):
maxcount = placement['count']['target']
from_pool = placement['from_pool']
if target_world is False or world.players == 1: # target own world
worlds = {player}
worlds: typing.Set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(world.player_ids) - {player}
elif target_world is None: # target all worlds
worlds = set(world.player_ids)
elif type(target_world) == list: # list of target worlds
worlds = []
worlds = set()
for listed_world in target_world:
if listed_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
placement['force'])
continue
worlds.append(world_name_lookup[listed_world])
worlds = set(worlds)
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, world.players + 1):
failed(
@@ -567,8 +621,8 @@ def distribute_planned(world: MultiWorld):
world.random.shuffle(candidates)
world.random.shuffle(items)
count = 0
err = []
successful_pairs = []
err: typing.List[str] = []
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
for item_name in items:
item = world.worlds[player].create_item(item_name)
for location in reversed(candidates):
@@ -579,7 +633,7 @@ def distribute_planned(world: MultiWorld):
if not location.item:
if location.item_rule(item):
if location.can_fill(world.state, item, False):
successful_pairs.append([item, location])
successful_pairs.append((item, location))
candidates.remove(location)
count = count + 1
break
@@ -592,10 +646,9 @@ def distribute_planned(world: MultiWorld):
if count == maxcount:
break
if count < placement['count']['min']:
err = " ".join(err)
m = placement['count']['min']
failed(
f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {err}",
f"Plando block failed to place {m - count} of {m} item(s) for {world.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
for (item, location) in successful_pairs:
world.push_item(location, item, collect=False)

View File

@@ -14,8 +14,8 @@ 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
from worlds.generic import PlandoConnection
from Utils import parse_yaml, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed
@@ -27,24 +27,31 @@ import copy
categories = set(AutoWorldRegister.world_types)
def mystery_argparse():
options = get_options()
defaults = options["generator"]
def resolve_path(path: str, resolver: typing.Callable[[str], str]) -> str:
return path if os.path.isabs(path) else resolver(path)
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
parser.add_argument('--weights_file_path', default = defaults["weights_file_path"],
parser.add_argument('--weights_file_path', default=defaults["weights_file_path"],
help='Path to the weights file to use for rolling game settings, urls are also valid')
parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player',
action='store_true')
parser.add_argument('--player_files_path', default=defaults["player_files_path"],
parser.add_argument('--player_files_path', default=resolve_path(defaults["player_files_path"], user_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: max(int(value), 1))
parser.add_argument('--spoiler', type=int, default=defaults["spoiler"])
parser.add_argument('--lttp_rom', default=options["lttp_options"]["rom_file"], help="Path to the 1.0 JP LttP Baserom.")
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('--lttp_rom', default=options["lttp_options"]["rom_file"],
help="Path to the 1.0 JP LttP Baserom.") # absolute, relative to cwd or relative to app path
parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"],
help="Path to the 1.0 JP SM Baserom.")
parser.add_argument('--enemizercli', default=resolve_path(defaults["enemizer_path"], local_path))
parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path),
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
parser.add_argument('--race', action='store_true', default=defaults["race"])
parser.add_argument('--meta_file_path', default=defaults["meta_file_path"])
parser.add_argument('--log_level', default='info', help='Sets log level')
@@ -180,7 +187,7 @@ def main(args=None, callback=ERmain):
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {erargs.name}")
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
if args.yaml_output:
import yaml
@@ -209,11 +216,11 @@ def main(args=None, callback=ERmain):
def read_weights_yaml(path):
try:
if urllib.parse.urlparse(path).scheme:
yaml = str(urllib.request.urlopen(path).read(), "utf-8")
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
else:
with open(path, 'rb') as f:
yaml = str(f.read(), "utf-8")
yaml = str(f.read(), "utf-8-sig")
except Exception as e:
raise Exception(f"Failed to read weights ({path})") from e
@@ -426,17 +433,8 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
except Exception as e:
raise Exception(f"Error generating option {option_key} in {ret.game}") from e
else:
# verify item names existing
if getattr(player_option, "verify_item_name", False):
for item_name in player_option.value:
if item_name not in AutoWorldRegister.world_types[ret.game].item_names:
raise Exception(f"Item {item_name} from option {player_option} "
f"is not a valid item name from {ret.game}")
elif getattr(player_option, "verify_location_name", False):
for location_name in player_option.value:
if location_name not in AutoWorldRegister.world_types[ret.game].location_names:
raise Exception(f"Location {location_name} from option {player_option} "
f"is not a valid location name from {ret.game}")
if hasattr(player_option, "verify"):
player_option.verify(AutoWorldRegister.world_types[ret.game])
else:
setattr(ret, option_key, option(option.default))
@@ -511,8 +509,10 @@ def roll_settings(weights: dict, plando_options: typing.Set[str] = frozenset(("b
roll_alttp_settings(ret, game_weights, plando_options)
else:
raise Exception(f"Unsupported game {ret.game}")
return ret
def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
if "dungeon_items" in weights and get_choice_legacy('dungeon_items', weights, "none") != "none":
raise Exception(f"dungeon_items key in A Link to the Past was removed, but is present in these weights as {get_choice_legacy('dungeon_items', weights, False)}.")

View File

@@ -1,8 +1,8 @@
MIT License
Copyright (c) 2017 LLCoolDave
Copyright (c) 2021 Berserker66
Copyright (c) 2021 CaitSith2
Copyright (c) 2022 Berserker66
Copyright (c) 2022 CaitSith2
Copyright (c) 2021 LegendaryLinux
Permission is hereby granted, free of charge, to any person obtaining a copy

307
Launcher.py Normal file
View File

@@ -0,0 +1,307 @@
"""
Archipelago launcher for bundled app.
* if run with APBP as argument, launch corresponding client.
* if run with executable as argument, run it passing argv[2:] as arguments
* if run without arguments, open launcher GUI
Scroll down to components= to add components to the launcher as well as setup.py
"""
import argparse
from os.path import isfile
import sys
from typing import Iterable, Sequence, Callable, Union, Optional
import subprocess
import itertools
from Utils import is_frozen, user_path, local_path, init_logging
from shutil import which
import shlex
from enum import Enum, auto
import logging
is_linux = sys.platform.startswith('linux')
is_macos = sys.platform == 'darwin'
is_windows = sys.platform in ("win32", "cygwin", "msys")
def open_host_yaml():
file = user_path('host.yaml')
if is_linux:
exe = which('sensible-editor') or which('gedit') or \
which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, file])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, file])
else:
import webbrowser
webbrowser.open(file)
def open_patch():
try:
import tkinter
import tkinter.filedialog
except Exception as e:
logging.error("Could not load tkinter, which is likely not installed. "
"This attempt was made because Launcher.open_patch was used.")
raise e
else:
root = tkinter.Tk()
root.withdraw()
suffixes = []
for c in components:
if isfile(get_exe(c)[-1]):
suffixes += c.file_identifier.suffixes if c.type == Type.CLIENT and \
isinstance(c.file_identifier, SuffixIdentifier) else []
filename = tkinter.filedialog.askopenfilename(filetypes=(('Patches', ' '.join(suffixes)),))
file, _, component = identify(filename)
if file and component:
launch([*get_exe(component), file], component.cli)
def browse_files():
file = user_path()
if is_linux:
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, file])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, file])
else:
import webbrowser
webbrowser.open(file)
class Type(Enum):
TOOL = auto()
FUNC = auto() # not a real component
CLIENT = auto()
ADJUSTER = auto()
class SuffixIdentifier:
suffixes: Iterable[str]
def __init__(self, *args: str):
self.suffixes = args
def __call__(self, path: str):
if isinstance(path, str):
for suffix in self.suffixes:
if path.endswith(suffix):
return True
return False
class Component:
display_name: str
type: Optional[Type]
script_name: Optional[str]
frozen_name: Optional[str]
icon: str # just the name, no suffix
cli: bool
func: Optional[Callable]
file_identifier: Optional[Callable[[str], bool]]
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None,
file_identifier: Optional[Callable[[str], bool]] = None):
self.display_name = display_name
self.script_name = script_name
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
self.icon = icon
self.cli = cli
self.type = component_type or \
None if not display_name else \
Type.FUNC if func else \
Type.CLIENT if 'Client' in display_name else \
Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL
self.func = func
self.file_identifier = file_identifier
def handles_file(self, path: str):
return self.file_identifier(path) if self.file_identifier else False
components: Iterable[Component] = (
# Launcher
Component('', 'Launcher'),
# Core
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
Component('Generate', 'Generate', cli=True),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
# SNI
Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3')),
Component('LttP Adjuster', 'LttPAdjuster'),
# Factorio
Component('Factorio Client', 'FactorioClient'),
# Minecraft
Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True,
file_identifier=SuffixIdentifier('.apmc')),
# Ocarina of Time
Component('OoT Client', 'OoTClient',
file_identifier=SuffixIdentifier('.apz5')),
Component('OoT Adjuster', 'OoTAdjuster'),
# FF1
Component('FF1 Client', 'FF1Client'),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Functions
Component('Open host.yaml', func=open_host_yaml),
Component('Open Patch', func=open_patch),
Component('Browse Files', func=browse_files),
)
icon_paths = {
'icon': local_path('data', 'icon.ico' if is_windows else 'icon.png'),
'mcicon': local_path('data', 'mcicon.ico')
}
def identify(path: Union[None, str]):
if path is None:
return None, None, None
for component in components:
if component.handles_file(path):
return path, component.script_name, component
return (None, None, None) if '/' in path or '\\' in path else (None, path, None)
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
if isinstance(component, str):
name = component
component = None
if name.startswith('Archipelago'):
name = name[11:]
if name.endswith('.exe'):
name = name[:-4]
if name.endswith('.py'):
name = name[:-3]
if not name:
return None
for c in components:
if c.script_name == name or c.frozen_name == f'Archipelago{name}':
component = c
break
if not component:
return None
if is_frozen():
suffix = '.exe' if is_windows else ''
return [local_path(f'{component.frozen_name}{suffix}')]
else:
return [sys.executable, local_path(f'{component.script_name}.py')]
def launch(exe, in_terminal=False):
if in_terminal:
if is_windows:
subprocess.Popen(['start', *exe], shell=True)
return
elif is_linux:
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
if terminal:
subprocess.Popen([terminal, '-e', shlex.join(exe)])
return
elif is_macos:
terminal = [which('open'), '-W', '-a', 'Terminal.app']
subprocess.Popen([*terminal, *exe])
return
subprocess.Popen(exe)
def run_gui():
if not sys.stdout:
from kvui import App, ContainerLayout, GridLayout, Button, Label # this kills stdout
else:
from kivy.app import App
from kivy.uix.button import Button
from kivy.uix.floatlayout import FloatLayout as ContainerLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.label import Label
class Launcher(App):
base_title: str = "Archipelago Launcher"
container: ContainerLayout
grid: GridLayout
_tools = {c.display_name: c for c in components if c.type == Type.TOOL and isfile(get_exe(c)[-1])}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT and isfile(get_exe(c)[-1])}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER and isfile(get_exe(c)[-1])}
_funcs = {c.display_name: c for c in components if c.type == Type.FUNC}
def __init__(self, ctx=None):
self.title = self.base_title
self.ctx = ctx
self.icon = r"data/icon.png"
super().__init__()
def build(self):
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
button_layout = self.grid # make buttons fill the window
for (tool, client) in itertools.zip_longest(itertools.chain(
self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()):
# column 1
if tool:
button = Button(text=tool[0])
button.component = tool[1]
button.bind(on_release=self.component_action)
button_layout.add_widget(button)
else:
button_layout.add_widget(Label())
# column 2
if client:
button = Button(text=client[0])
button.component = client[1]
button.bind(on_press=self.component_action)
button_layout.add_widget(button)
else:
button_layout.add_widget(Label())
return self.container
@staticmethod
def component_action(button):
if button.component.type == Type.FUNC:
button.component.func()
else:
launch(get_exe(button.component), button.component.cli)
Launcher().run()
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
if isinstance(args, argparse.Namespace):
args = {k: v for k, v in args._get_kwargs()}
elif not args:
args = {}
if "Patch|Game|Component" in args:
file, component, _ = identify(args["Patch|Game|Component"])
if file:
args['file'] = file
if component:
args['component'] = component
if 'file' in args:
subprocess.run([*get_exe(args['component']), args['file'], *args['args']])
elif 'component' in args:
subprocess.run([*get_exe(args['component']), *args['args']])
else:
run_gui()
if __name__ == '__main__':
init_logging('Launcher')
parser = argparse.ArgumentParser(description='Archipelago Launcher')
parser.add_argument('Patch|Game|Component', type=str, nargs='?',
help="Pass either a patch file, a generated game or the name of a component to run.")
parser.add_argument('args', nargs="*", help="Arguments to pass to component.")
main(parser.parse_args())

View File

@@ -21,7 +21,7 @@ from urllib.parse import urlparse
from urllib.request import urlopen
from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes
from Utils import output_path, local_path, open_file, get_cert_none_ssl_context, persistent_store, get_adjuster_settings, tkinter_center_window
from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, get_adjuster_settings, tkinter_center_window
from Patch import GAME_ALTTP
class AdjusterWorld(object):
@@ -130,7 +130,7 @@ def adjust(args):
logger = logging.getLogger('Adjuster')
logger.info('Patching ROM.')
vanillaRom = args.baserom
if os.path.splitext(args.rom)[-1].lower() == '.apbp':
if os.path.splitext(args.rom)[-1].lower() in {'.apbp', '.aplttp'}:
import Patch
meta, args.rom = Patch.create_rom_file(args.rom)
@@ -286,7 +286,7 @@ def run_sprite_update():
def update_sprites(task, on_finish=None):
resultmessage = ""
successful = True
sprite_dir = local_path("data", "sprites", "alttpr")
sprite_dir = user_path("data", "sprites", "alttpr")
os.makedirs(sprite_dir, exist_ok=True)
ctx = get_cert_none_ssl_context()
def finished():
@@ -1013,11 +1013,11 @@ class SpriteSelector():
@property
def alttpr_sprite_dir(self):
return local_path("data", "sprites", "alttpr")
return user_path("data", "sprites", "alttpr")
@property
def custom_sprite_dir(self):
return local_path("data", "sprites", "custom")
return user_path("data", "sprites", "custom")
def get_image_for_sprite(sprite, gif_only: bool = False):

143
Main.py
View File

@@ -1,3 +1,5 @@
import copy
import collections
from itertools import zip_longest, chain
import logging
import os
@@ -7,9 +9,9 @@ import concurrent.futures
import pickle
import tempfile
import zipfile
from typing import Dict, Tuple, Optional
from typing import Dict, Tuple, Optional, Set
from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType
from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
from worlds.alttp.Items import item_name_groups
from worlds.alttp.Regions import lookup_vanilla_location_to_entrance
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
@@ -18,7 +20,6 @@ from Utils import output_path, get_options, __version__, version_tuple
from worlds.generic.Rules import locality_rules, exclusion_rules
from worlds import AutoWorld
ordered_areas = (
'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace',
@@ -47,13 +48,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.item_functionality = args.item_functionality.copy()
world.timer = args.timer.copy()
world.goal = args.goal.copy()
if hasattr(args, "algorithm"): # current GUI options
world.algorithm = args.algorithm
world.shuffleganon = args.shuffleganon
world.custom = args.custom
world.customitemarray = args.customitemarray
world.open_pyramid = args.open_pyramid.copy()
world.boss_shuffle = args.shufflebosses.copy()
world.enemy_health = args.enemy_health.copy()
@@ -77,12 +71,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.plando_connections = args.plando_connections.copy()
world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy()
world.set_options(args)
world.player_name = args.name.copy()
world.enemizer = args.enemizercli
world.sprite = args.sprite.copy()
world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option.
world.set_options(args)
world.set_item_links()
world.state = CollectionState(world)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
logger.info("Found World Types:")
@@ -137,9 +133,88 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in world.player_ids:
exclusion_rules(world, player, world.exclude_locations[player].value)
world.priority_locations[player].value -= world.exclude_locations[player].value
for location_name in world.priority_locations[player].value:
world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY
AutoWorld.call_all(world, "generate_basic")
# temporary home for item links, should be moved out of Main
for group_id, group in world.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]):
advancement = set()
counters = {player: {name: 0 for name in shared_pool} for player in players}
for item in world.itempool:
if item.player in counters and item.name in shared_pool:
counters[item.player][item.name] += 1
if item.advancement:
advancement.add(item.name)
for player in players.copy():
if all([counters[player][item] == 0 for item in shared_pool]):
players.remove(player)
del(counters[player])
if not players:
return None, None
for item in shared_pool:
count = min(counters[player][item] for player in players)
if count:
for player in players:
counters[player][item] = count
else:
for player in players:
del(counters[player][item])
return counters, advancement
common_item_count, common_advancement_items = find_common_pool(group["players"], group["item_pool"])
if not common_item_count:
continue
new_itempool = []
for item_name, item_count in next(iter(common_item_count.values())).items():
advancement = item_name in common_advancement_items
for _ in range(item_count):
new_item = group["world"].create_item(item_name)
new_item.advancement = advancement
new_itempool.append(new_item)
region = Region("Menu", RegionType.Generic, "ItemLink", group_id, world)
world.regions.append(region)
locations = region.locations = []
for item in world.itempool:
count = common_item_count.get(item.player, {}).get(item.name, 0)
if count:
loc = Location(group_id, f"Item Link: {item.name} -> {world.player_name[item.player]} {count}",
None, region)
loc.access_rule = lambda state, item_name = item.name, group_id_ = group_id, count_ = count: \
state.has(item_name, group_id_, count_)
locations.append(loc)
loc.place_locked_item(item)
common_item_count[item.player][item.name] -= 1
else:
new_itempool.append(item)
itemcount = len(world.itempool)
world.itempool = new_itempool
while itemcount > len(world.itempool):
items_to_add = []
for player in group["players"]:
if group["replacement_items"][player]:
items_to_add.append(AutoWorld.call_single(world, "create_item", player,
group["replacement_items"][player]))
else:
items_to_add.append(AutoWorld.call_single(world, "create_filler", player))
world.random.shuffle(items_to_add)
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
if any(world.item_links.values()):
world._recache()
world._all_state = None
logger.info("Running Item Plando")
for item in world.itempool:
@@ -250,46 +325,58 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
import NetUtils
slot_data = {}
client_versions = {}
minimum_versions = {"server": (0, 1, 8), "clients": client_versions}
games = {}
minimum_versions = {"server": (0, 2, 4), "clients": client_versions}
slot_info = {}
names = [[name for player, name in sorted(world.player_name.items())]]
for slot in world.player_ids:
client_versions[slot] = world.worlds[slot].get_required_client_version()
games[slot] = world.game[slot]
precollected_items = {player: [item.code for item in world_precollected]
slot_info[slot] = NetUtils.NetworkSlot(names[0][slot - 1], world.game[slot],
world.player_types[slot])
for slot, group in world.groups.items():
games[slot] = world.game[slot]
slot_info[slot] = NetUtils.NetworkSlot(group["name"], world.game[slot], world.player_types[slot],
group_members=sorted(group["players"]))
precollected_items = {player: [item.code for item in world_precollected if type(item.code) == int]
for player, world_precollected in world.precollected_items.items()}
precollected_hints = {player: set() for player in range(1, world.players + 1)}
precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))}
sending_visible_players = set()
for slot in world.player_ids:
slot_data[slot] = world.worlds[slot].fill_slot_data()
if world.worlds[slot].sending_visible:
sending_visible_players.add(slot)
def precollect_hint(location):
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False, "", location.item.flags)
location.item.code, False, entrance, location.item.flags)
precollected_hints[location.player].add(hint)
precollected_hints[location.item.player].add(hint)
if location.item.player not in world.groups:
precollected_hints[location.item.player].add(hint)
else:
for player in world.groups[location.item.player]["players"]:
precollected_hints[player].add(hint)
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in world.player_ids}
for location in world.get_filled_locations():
if type(location.address) == int:
# item code None should be event, location.address should then also be None
assert location.item.code is not None
assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None"
locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags
if location.player in sending_visible_players:
precollect_hint(location)
elif location.name in world.start_location_hints[location.player]:
if location.name in world.start_location_hints[location.player]:
precollect_hint(location)
elif location.item.name in world.start_hints[location.item.player]:
precollect_hint(location)
elif any([location.item.name in world.start_hints[player]
for player in world.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location)
multidata = {
"slot_data": slot_data,
"games": games,
"names": [[name for player, name in sorted(world.player_name.items())]],
"slot_info": slot_info,
"names": names, # TODO: remove around 0.2.5 in favor of slot_info
"games": games, # TODO: remove around 0.2.5 in favor of slot_info
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
"remote_items": {player for player in world.player_ids if
world.worlds[player].remote_items},
@@ -311,7 +398,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
multidata = zlib.compress(pickle.dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:
f.write(bytes([2])) # version of format
f.write(bytes([3])) # version of format
f.write(multidata)
multidata_task = pool.submit(write_multidata)
@@ -321,7 +408,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
else:
logger.warning("Location Accessibility requirements not fulfilled.")
# retrieve exceptions via .result() if they occured.
# retrieve exceptions via .result() if they occurred.
multidata_task.result()
for i, future in enumerate(concurrent.futures.as_completed(output_file_futures), start=1):
if i % 10 == 0 or i == len(output_file_futures):

View File

@@ -2,6 +2,7 @@ import argparse
import os, sys
import re
import atexit
import shutil
from subprocess import Popen
from shutil import copyfile
from time import strftime
@@ -16,6 +17,7 @@ atexit.register(input, "Press enter to exit.")
# 1 or more digits followed by m or g, then optional b
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
forge_version = "1.17.1-37.1.1"
is_windows = sys.platform in ("win32", "cygwin", "msys")
def prompt_yes_no(prompt):
@@ -158,9 +160,15 @@ def find_jdk_dir() -> str:
# get the java exe location
def find_jdk() -> str:
jdk = find_jdk_dir()
jdk_exe = os.path.join(jdk, "bin", "java.exe")
if os.path.isfile(jdk_exe):
if is_windows:
jdk = find_jdk_dir()
jdk_exe = os.path.join(jdk, "bin", "java.exe")
if os.path.isfile(jdk_exe):
return jdk_exe
else:
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
if not jdk_exe:
raise Exception("Could not find Java. Is Java installed on the system?")
return jdk_exe
@@ -203,7 +211,7 @@ def install_forge(directory: str):
f.write(resp.content)
print(f"Installing Forge...")
argstring = ' '.join([jdk, "-jar", "\"" + forge_install_jar+ "\"", "--installServer", "\"" + directory + "\""])
install_process = Popen(argstring)
install_process = Popen(argstring, shell=not is_windows)
install_process.wait()
os.remove(forge_install_jar)
@@ -220,7 +228,8 @@ def run_forge_server(forge_dir: str, heap_arg):
heap_arg = heap_arg[:-1]
heap_arg = "-Xmx" + heap_arg
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, "win_args.txt")
os_args = "win_args.txt" if is_windows else "unix_args.txt"
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
win_args = []
with open(args_file) as argfile:
for line in argfile:
@@ -229,7 +238,7 @@ def run_forge_server(forge_dir: str, heap_arg):
argstring = ' '.join([java_exe, heap_arg] + win_args + ["-nogui"])
logging.info(f"Running Forge server: {argstring}")
os.chdir(forge_dir)
return Popen(argstring)
return Popen(argstring, shell=not is_windows)
if __name__ == '__main__':
@@ -252,15 +261,21 @@ if __name__ == '__main__':
max_heap = options["minecraft_options"]["max_heap_size"]
if args.install:
print("Installing Java and Minecraft Forge")
download_java()
if is_windows:
print("Installing Java and Minecraft Forge")
download_java()
else:
print("Installing Minecraft Forge")
install_forge(forge_dir)
sys.exit(0)
if apmc_file is not None and not os.path.isfile(apmc_file):
raise FileNotFoundError(f"Path {apmc_file} does not exist or could not be accessed.")
if not os.path.isdir(forge_dir):
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
if prompt_yes_no("Did not find forge directory. Download and install forge now?"):
install_forge(forge_dir)
if not os.path.isdir(forge_dir):
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
if not max_heap_re.match(max_heap):
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")

View File

@@ -3,7 +3,8 @@ import sys
import subprocess
import pkg_resources
requirements_files = {'requirements.txt'}
local_dir = os.path.dirname(__file__)
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
if sys.version_info < (3, 8, 6):
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
@@ -11,7 +12,7 @@ if sys.version_info < (3, 8, 6):
update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled
if not update_ran:
for entry in os.scandir("worlds"):
for entry in os.scandir(os.path.join(local_dir, "worlds")):
if entry.is_dir():
req_file = os.path.join(entry.path, "requirements.txt")
if os.path.exists(req_file):

View File

@@ -15,6 +15,7 @@ import random
import pickle
import itertools
import time
import operator
import ModuleUpdate
@@ -33,10 +34,29 @@ from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_
import Utils
from Utils import get_item_name_from_id, get_location_name_from_id, \
version_tuple, restricted_loads, Version
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType
colorama.init()
# functions callable on storable data on the server by clients
modify_functions = {
"add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append)
"mul": operator.mul,
"mod": operator.mod,
"max": max,
"min": min,
"replace": lambda old, new: new,
"default": lambda old, new: old,
"pow": operator.pow,
# bitwise:
"xor": operator.xor,
"or": operator.or_,
"and": operator.and_,
"left_shift": operator.lshift,
"right_shift": operator.rshift,
}
class Client(Endpoint):
version = Version(0, 0, 0)
@@ -97,13 +117,17 @@ class Context:
# 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, int]]]
groups: typing.Dict[int, typing.Set[int]]
save_version = 2
stored_data: typing.Dict[str, object]
stored_data_notification_clients: typing.Dict[str, typing.Set[Client]]
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
log_network: bool = False):
super(Context, self).__init__()
self.slot_info: typing.Dict[int, NetworkSlot] = {}
self.log_network = log_network
self.endpoints = []
self.clients = {}
@@ -156,7 +180,10 @@ class Context:
self.games: typing.Dict[int, str] = {}
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
self.seed_name = ""
self.groups = {}
self.random = random.Random()
self.stored_data = {}
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
# General networking
@@ -261,7 +288,7 @@ class Context:
@staticmethod
def decompress(data: bytes) -> dict:
format_version = data[0]
if format_version > 2:
if format_version > 3:
raise Utils.VersionException("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:]))
@@ -292,18 +319,40 @@ class Context:
self.slot_data = decoded_obj['slot_data']
self.er_hint_data = {int(player): {int(address): name for address, name in loc_data.items()}
for player, loc_data in decoded_obj["er_hint_data"].items()}
self.games = decoded_obj["games"]
# load start inventory:
for slot, item_codes in decoded_obj["precollected_items"].items():
self.start_inventory[slot] = [NetworkItem(item_code, -2, 0) for item_code in item_codes]
for team in range(len(decoded_obj['names'])):
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:
if "slot_info" in decoded_obj:
self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
if slot_info.type == SlotType.group}
else:
self.games = decoded_obj["games"]
self.groups = {}
self.slot_info = {
slot: NetworkSlot(
self.player_names[0, slot],
self.games[slot],
SlotType(int(bool(locations))))
for slot, locations in self.locations.items()
}
# locations may need converting
for slot, locations in self.locations.items():
for location, item_data in locations.items():
if len(item_data) < 3:
locations[location] = (*item_data, 0)
# declare slots that aren't players as done
for slot, slot_info in self.slot_info.items():
if slot_info.type.always_goal:
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)
@@ -382,7 +431,13 @@ class Context:
(key, value.timestamp()) for key, value in self.client_activity_timers.items()),
"client_connection_timers": tuple(
(key, value.timestamp()) for key, value in self.client_connection_timers.items()),
"random_state": self.random.getstate()
"random_state": self.random.getstate(),
"stored_data": self.stored_data,
"game_options": {"hint_cost": self.hint_cost, "location_check_points": self.location_check_points,
"server_password": self.server_password, "password": self.password, "forfeit_mode":
self.forfeit_mode, "remaining_mode": self.remaining_mode, "collect_mode":
self.collect_mode, "item_cheat": self.item_cheat, "compatibility": self.compatibility}
}
return d
@@ -398,7 +453,7 @@ class Context:
self.received_items[(*old, False)] = items.copy()
for (team, slot, remote) in self.received_items:
# remove start inventory from items, since this is separate now
start_inventory = get_start_inventory(self, team, slot, slot in self.remote_start_inventory)
start_inventory = get_start_inventory(self, slot, slot in self.remote_start_inventory)
if start_inventory:
del self.received_items[team, slot, remote][:len(start_inventory)]
logging.info("Upgraded save data")
@@ -418,10 +473,25 @@ class Context:
{tuple(key): datetime.datetime.fromtimestamp(value, datetime.timezone.utc) for key, value
in savedata["client_activity_timers"]})
self.location_checks.update(savedata["location_checks"])
if "random_state" in savedata:
self.random.setstate(savedata["random_state"])
logging.info(f'Loaded save file with {sum([len(p) for p in self.received_items.values()])} received items '
f'for {len(self.received_items)} players')
self.random.setstate(savedata["random_state"])
if "game_options" in savedata:
self.hint_cost = savedata["game_options"]["hint_cost"]
self.location_check_points = savedata["game_options"]["location_check_points"]
self.server_password = savedata["game_options"]["server_password"]
self.password = savedata["game_options"]["password"]
self.forfeit_mode = savedata["game_options"]["forfeit_mode"]
self.remaining_mode = savedata["game_options"]["remaining_mode"]
self.collect_mode = savedata["game_options"]["collect_mode"]
self.item_cheat = savedata["game_options"]["item_cheat"]
self.compatibility = savedata["game_options"]["compatibility"]
if "stored_data" in savedata:
self.stored_data = savedata["stored_data"]
# count items and slots from lists for item_handling = remote
logging.info(
f'Loaded save file with {sum([len(v) for k, v in self.received_items.items() if k[2]])} received items '
f'for {sum(k[2] for k in self.received_items)} players')
# rest
@@ -478,20 +548,34 @@ class Context:
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint]):
"""Send and remember hints"""
concerns = collections.defaultdict(list)
for hint in hints:
net_msg = hint.as_network_message()
concerns[hint.receiving_player].append(net_msg)
if not hint.local:
if hint.receiving_player in ctx.groups:
for player in ctx.groups[hint.receiving_player]:
concerns[player].append(net_msg)
else:
concerns[hint.receiving_player].append(net_msg)
if not hint.local and net_msg not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(net_msg)
# remember hints in all cases
if not hint.found:
ctx.hints[team, hint.finding_player].add(hint)
if hint.receiving_player in ctx.groups:
for player in ctx.groups[hint.receiving_player]:
ctx.hints[team, player].add(hint)
else:
ctx.hints[team, hint.receiving_player].add(hint)
for text in (format_hint(ctx, team, hint) for hint in hints):
logging.info("Notice (Team #%d): %s" % (team + 1, text))
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))
if 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):
@@ -541,6 +625,8 @@ async def on_client_connected(ctx: Context, client: Client):
'cmd': 'RoomInfo',
'password': bool(ctx.password),
'players': players,
# TODO remove around 0.2.5 in favor of slot_info ?
# Maybe convert into a list of games that are present to fetch relevant datapackage entries before Connect?
'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.
@@ -579,7 +665,7 @@ async def on_client_joined(ctx: Context, client: Client):
f"{verb} {ctx.games[client.slot]} has joined. "
f"Client({version_str}), {client.tags}).")
ctx.notify_client(client, "Now that you are connected, "
"you can use !help to list commands to run via the server."
"you can use !help to list commands to run via the server. "
"If your client supports it, "
"you may have additional local commands you can list with /help.")
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
@@ -612,16 +698,19 @@ def get_players_string(ctx: Context):
player_names = sorted(ctx.player_names.keys())
current_team = -1
text = ''
total = 0
for team, slot in player_names:
player_name = ctx.player_names[team, slot]
if team != current_team:
text += f':: Team #{team + 1}: '
current_team = team
if (team, slot) in auth_clients:
text += f'{player_name} '
else:
text += f'({player_name}) '
return f'{len(auth_clients)} players of {len(ctx.player_names)} connected ' + text[:-1]
if ctx.slot_info[slot].type == SlotType.player:
total += 1
player_name = ctx.player_names[team, slot]
if team != current_team:
text += f':: Team #{team + 1}: '
current_team = team
if (team, slot) in auth_clients:
text += f'{player_name} '
else:
text += f'({player_name}) '
return f'{len(auth_clients)} players of {total} connected ' + text[:-1]
def get_status_string(ctx: Context, team: int):
@@ -641,7 +730,7 @@ def get_received_items(ctx: Context, team: int, player: int, remote_items: bool)
return ctx.received_items.setdefault((team, player, remote_items), [])
def get_start_inventory(ctx: Context, team: int, player: int, remote_start_inventory: bool) -> typing.List[NetworkItem]:
def get_start_inventory(ctx: Context, player: int, remote_start_inventory: bool) -> typing.List[NetworkItem]:
return ctx.start_inventory.setdefault(player, []) if remote_start_inventory else []
@@ -651,7 +740,7 @@ def send_new_items(ctx: Context):
for client in clients:
if client.no_items:
continue
start_inventory = get_start_inventory(ctx, team, slot, client.remote_start_inventory)
start_inventory = get_start_inventory(ctx, slot, client.remote_start_inventory)
items = get_received_items(ctx, team, slot, client.remote_items)
if len(start_inventory) + len(items) > client.send_index:
first_new_item = max(0, client.send_index - len(start_inventory))
@@ -697,6 +786,15 @@ def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
return sorted(items)
def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem):
targets = ctx.groups.get(target_slot, [target_slot])
for target in targets:
for item in items:
if item.player != target_slot:
get_received_items(ctx, team, target, False).append(item)
get_received_items(ctx, team, target, True).append(item)
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
count_activity: bool = True):
new_locations = set(locations) - ctx.location_checks[team, slot]
@@ -705,17 +803,9 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
if count_activity:
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
for location in new_locations:
if len(ctx.locations[slot][location]) == 3:
item_id, target_player, flags = ctx.locations[slot][location]
else:
# TODO: remove around version 0.2.5
item_id, target_player = ctx.locations[slot][location]
flags = 0
item_id, target_player, flags = ctx.locations[slot][location]
new_item = NetworkItem(item_id, location, slot, flags)
if target_player != slot:
get_received_items(ctx, team, target_player, False).append(new_item)
get_received_items(ctx, team, target_player, True).append(new_item)
send_items_to(ctx, team, target_player, new_item)
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id),
@@ -736,17 +826,16 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
hints = []
slots = []
for group_id, group in ctx.groups.items():
if slot in group:
slots.append(group_id)
seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item]
for finding_player, check_data in ctx.locations.items():
for location_id, result in check_data.items():
if len(result) == 3:
item_id, receiving_player, item_flags = result
else:
# TODO: remove around version 0.2.5
item_id, receiving_player = result
item_flags = 0
item_id, receiving_player, item_flags = result
if receiving_player == slot and item_id == seeked_item_id:
if (receiving_player == slot or receiving_player in slots) and item_id == seeked_item_id:
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
@@ -755,16 +844,15 @@ def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[
return hints
def collect_hints_location(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
seeked_location: int = proxy_worlds[ctx.games[slot]].location_name_to_id[location]
return collect_hint_location_id(ctx, team, slot, seeked_location)
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int) -> typing.List[NetUtils.Hint]:
result = ctx.locations[slot].get(seeked_location, (None, None, None))
if result:
if len(result) == 3:
item_id, receiving_player, item_flags = result
else:
# TODO: remove around version 0.2.5
item_id, receiving_player = result
item_flags = 0
if any(result):
item_id, receiving_player, item_flags = result
found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
@@ -1170,14 +1258,14 @@ class ClientMessageProcessor(CommonCommandProcessor):
if hint_name in world.hint_blacklist:
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = []
elif not for_location and hint_name in world.item_name_groups: # item group name
elif not for_location and hint_name in world.item_name_groups: # item group name
hints = []
for item in world.item_name_groups[hint_name]:
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item))
elif not for_location and hint_name in world.item_names: # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
else: # location name
hints = collect_hints_location(self.ctx, self.client.team, self.client.slot, hint_name)
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
cost = self.ctx.get_hint_cost(self.client.slot)
if hints:
new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot]
@@ -1209,10 +1297,6 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.ctx.hints_used[self.client.team, self.client.slot] += 1
points_available = get_client_points(self.ctx, self.client)
if not hint.found:
self.ctx.hints[self.client.team, hint.finding_player].add(hint)
self.ctx.hints[self.client.team, hint.receiving_player].add(hint)
if not_found_hints:
if hints and cost and int((points_available // cost) == 0):
self.output(
@@ -1300,7 +1384,6 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
errors.add('InvalidPassword')
if args['name'] not in ctx.connect_names:
logging.info((args["name"], ctx.connect_names))
errors.add('InvalidSlot')
else:
team, slot = ctx.connect_names[args['name']]
@@ -1315,6 +1398,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
client.no_items = False
client.remote_items = slot in ctx.remote_items
client.remote_start_inventory = slot in ctx.remote_start_inventory
await ctx.send_msgs(client, [{
"cmd": "Print", "text":
"Warning: Client is not sending items_handling flags, "
"which will not be supported in the future."}])
else:
try:
client.items_handling = args['items_handling']
@@ -1347,9 +1434,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
"players": ctx.get_players_package(),
"missing_locations": get_missing_checks(ctx, team, slot),
"checked_locations": get_checked_checks(ctx, team, slot),
"slot_data": ctx.slot_data[client.slot]
"slot_data": ctx.slot_data[client.slot],
"slot_info": ctx.slot_info
}]
start_inventory = get_start_inventory(ctx, team, slot, client.remote_start_inventory)
start_inventory = get_start_inventory(ctx, slot, client.remote_start_inventory)
items = get_received_items(ctx, client.team, client.slot, client.remote_items)
if (start_inventory or items) and not client.no_items:
reply.append({"cmd": 'ReceivedItems', "index": 0, "items": start_inventory + items})
@@ -1384,7 +1472,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
if args.get('items_handling', None) is not None and client.items_handling != args['items_handling']:
try:
client.items_handling = args['items_handling']
start_inventory = get_start_inventory(ctx, client.team, client.slot, client.remote_start_inventory)
start_inventory = get_start_inventory(ctx, client.slot, client.remote_start_inventory)
items = get_received_items(ctx, client.team, client.slot, client.remote_items)
if (items or start_inventory) and not client.no_items:
client.send_index = len(start_inventory) + len(items)
@@ -1408,7 +1496,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
f"from {old_tags} to {client.tags}.")
elif cmd == 'Sync':
start_inventory = get_start_inventory(ctx, client.team, client.slot, client.remote_start_inventory)
start_inventory = get_start_inventory(ctx, client.slot, client.remote_start_inventory)
items = get_received_items(ctx, client.team, client.slot, client.remote_items)
if (start_inventory or items) and not client.no_items:
client.send_index = len(start_inventory) + len(items)
@@ -1425,21 +1513,20 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
elif cmd == 'LocationScouts':
locs = []
create_as_hint = args.get("create_as_hint", False)
hints = []
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',
"original_cmd": cmd}])
return
if len(ctx.locations[client.slot][location]) == 3:
target_item, target_player, flags = ctx.locations[client.slot][location]
else:
# TODO: remove around version 0.2.5
target_item, target_player = ctx.locations[client.slot][location]
flags = 0
target_item, target_player, flags = ctx.locations[client.slot][location]
if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
locs.append(NetworkItem(target_item, location, target_player, flags))
notify_hints(ctx, client.team, hints)
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'StatusUpdate':
@@ -1466,6 +1553,43 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
bounceclient.slot in slots):
await ctx.send_encoded_msgs(bounceclient, msg)
elif cmd == "Get":
if "keys" not in args or type(args["keys"]) != list:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'Retrieve', "original_cmd": cmd}])
return
args["cmd"] = "Retrieved"
keys = args["keys"]
args["keys"] = {key: ctx.stored_data.get(key, None) for key in keys}
await ctx.send_msgs(client, [args])
elif cmd == "Set":
if "key" not in args or \
"operations" not in args or not type(args["operations"]) == list:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'Set', "original_cmd": cmd}])
return
args["cmd"] = "SetReply"
value = ctx.stored_data.get(args["key"], args.get("default", 0))
args["original_value"] = value
for operation in args["operations"]:
func = modify_functions[operation["operation"]]
value = func(value, operation["value"])
ctx.stored_data[args["key"]] = args["value"] = value
targets = set(ctx.stored_data_notification_clients[args["key"]])
if args.get("want_reply", True):
targets.add(client)
if targets:
ctx.broadcast(targets, [args])
elif cmd == "SetNotify":
if "keys" not in args or type(args["keys"]) != list:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'SetNotify', "original_cmd": cmd}])
return
for key in args["keys"]:
ctx.stored_data_notification_clients[key].add(client)
def update_client_status(ctx: Context, client: Client, new_status: ClientStatus):
current = ctx.client_game_state[client.team, client.slot]
@@ -1588,8 +1712,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(f"Could not find player {player_name} to forbid the !forfeit command for.")
return False
def _cmd_send(self, player_name: str, *item_name: str) -> bool:
"""Sends an item to the specified player"""
def _cmd_send_multiple(self, amount: typing.Union[int, str], player_name: str, *item_name: str) -> bool:
"""Sends multiples of an item to the specified player"""
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
team, slot = self.ctx.player_name_lookup[seeked_player]
@@ -1597,12 +1721,14 @@ class ServerCommandProcessor(CommonCommandProcessor):
world = proxy_worlds[self.ctx.games[slot]]
item, usable, response = get_intended_text(item, world.item_names)
if usable:
new_item = NetworkItem(world.item_name_to_id[item], -1, 0)
get_received_items(self.ctx, team, slot, True).append(new_item)
get_received_items(self.ctx, team, slot, False).append(new_item)
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' +
self.ctx.get_aliased_name(team, slot))
amount: int = int(amount)
new_items = [NetworkItem(world.item_name_to_id[item], -1, 0) for i in range(int(amount))]
send_items_to(self.ctx, team, slot, *new_items)
send_new_items(self.ctx)
self.ctx.notify_all(
'Cheat console: sending ' + ('' if amount == 1 else f'{amount} of ') +
f'"{item}" to {self.ctx.get_aliased_name(team, slot)}')
return True
else:
self.output(response)
@@ -1611,6 +1737,10 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(response)
return False
def _cmd_send(self, player_name: str, *item_name: str) -> bool:
"""Sends an item to the specified player"""
return self._cmd_send_multiple(1, player_name, *item_name)
def _cmd_hint(self, player_name: str, *item: str) -> bool:
"""Send out a hint for a player's item to their team"""
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
@@ -1626,8 +1756,10 @@ class ServerCommandProcessor(CommonCommandProcessor):
hints.extend(collect_hints(self.ctx, team, slot, item))
else: # item name
hints = collect_hints(self.ctx, team, slot, item)
if hints:
notify_hints(self.ctx, team, hints)
else:
self.output("No hints found.")
return True
@@ -1648,7 +1780,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
world = proxy_worlds[self.ctx.games[slot]]
item, usable, response = get_intended_text(item, world.location_names)
if usable:
hints = collect_hints_location(self.ctx, team, slot, item)
hints = collect_hint_location_name(self.ctx, team, slot, item)
if hints:
notify_hints(self.ctx, team, hints)
else:
@@ -1679,6 +1811,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
if option_name in {"forfeit_mode", "remaining_mode", "collect_mode"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
elif option_name in {"hint_cost", "location_check_points"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
return True
else:
known = (f"{option}:{otype}" for option, otype in self.ctx.simple_options.items())
@@ -1798,11 +1932,18 @@ async def main(args: argparse.Namespace):
try:
if not data_filename:
import tkinter
import tkinter.filedialog
root = tkinter.Tk()
root.withdraw()
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago *.zip"),))
try:
import tkinter
import tkinter.filedialog
except Exception as e:
logging.error("Could not load tkinter, which is likely not installed. "
"This attempt was made because no .archipelago file was provided as argument. "
"Either provide a file or ensure the tkinter package is installed.")
raise e
else:
root = tkinter.Tk()
root.withdraw()
data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data", "*.archipelago *.zip"),))
ctx.load(data_filename, args.use_embedded_options)

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import logging
import typing
import enum
from json import JSONEncoder, JSONDecoder
@@ -29,7 +28,18 @@ class ClientStatus(enum.IntEnum):
CLIENT_GOAL = 30
class Permission(enum.IntEnum):
class SlotType(enum.IntFlag):
spectator = 0b00
player = 0b01
group = 0b10
@property
def always_goal(self) -> bool:
"""Mark this slot has having reached its goal instantly."""
return self.value != 0b01
class Permission(enum.IntFlag):
disabled = 0b000 # 0, completely disables access
enabled = 0b001 # 1, allows manual use
goal = 0b010 # 2, allows manual use after goal completion
@@ -49,12 +59,21 @@ class Permission(enum.IntEnum):
class NetworkPlayer(typing.NamedTuple):
"""Represents a particular player on a particular team."""
team: int
slot: int
alias: str
name: str
class NetworkSlot(typing.NamedTuple):
"""Represents a particular slot across teams."""
name: str
game: str
type: SlotType
group_members: typing.Union[typing.List[int], typing.Tuple] = () # only populated if type == group
class NetworkItem(typing.NamedTuple):
item: int
location: int
@@ -89,9 +108,10 @@ def get_any_version(data: dict) -> Version:
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
whitelist = {"NetworkPlayer": NetworkPlayer,
"NetworkItem": NetworkItem,
}
whitelist = {
"NetworkPlayer": NetworkPlayer,
"NetworkItem": NetworkItem,
}
custom_hooks = {
"Version": get_any_version
@@ -122,9 +142,6 @@ class Endpoint:
def __init__(self, socket):
self.socket = socket
async def disconnect(self):
raise NotImplementedError
class HandlerMeta(type):
def __new__(mcs, name, bases, attrs):

View File

@@ -87,7 +87,7 @@ def adjustGUI():
option = sfx_options[option_name]
optionFrame = Frame(romSettingsFrame)
optionFrame.grid(row=row, column=column, sticky=E)
optionLabel = Label(optionFrame, text=option.displayname)
optionLabel = Label(optionFrame, text=option.display_name)
optionLabel.pack(side=LEFT)
setattr(opts, option_name, StringVar())
getattr(opts, option_name).set(option.name_lookup[option.default])
@@ -143,7 +143,7 @@ def adjustGUI():
option = cosmetic_options['sword_trail_duration']
optionFrame = Frame(romSettingsFrame)
optionFrame.grid(row=8, column=2, sticky=E)
optionLabel = Label(optionFrame, text=option.displayname)
optionLabel = Label(optionFrame, text=option.display_name)
optionLabel.pack(side=LEFT)
setattr(opts, 'sword_trail_duration', StringVar())
getattr(opts, 'sword_trail_duration').set(option.default)

287
OoTClient.py Normal file
View File

@@ -0,0 +1,287 @@
import asyncio
import json
import os
import multiprocessing
import subprocess
from asyncio import StreamReader, StreamWriter
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, \
ClientCommandProcessor, logger, get_base_parser
import Utils
from worlds import network_data_package
from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file
from worlds.oot.Utils import data_path
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart oot_connector.lua"
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure oot_connector.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart oot_connector.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
"""
Payload: lua -> client
{
playerName: string,
locations: dict,
deathlinkActive: bool,
isDead: bool,
gameComplete: bool
}
Payload: client -> lua
{
items: list,
playerNames: list,
triggerDeath: bool
}
Deathlink logic:
"Dead" is true <-> Link is at 0 hp.
deathlink_pending: we need to kill the player
deathlink_sent_this_death: we interacted with the multiworld on this death, waiting to reset with living link
"""
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
def get_item_value(ap_id):
return ap_id - 66000
class OoTCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx):
super().__init__(ctx)
def _cmd_n64(self):
"""Check N64 Connection State"""
if isinstance(self.ctx, OoTContext):
logger.info(f"N64 Status: {self.ctx.n64_status}")
class OoTContext(CommonContext):
command_processor = OoTCommandProcessor
items_handling = 0b001 # full local
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.game = 'Ocarina of Time'
self.n64_streams: (StreamReader, StreamWriter) = None
self.n64_sync_task = None
self.n64_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.location_table = {}
self.deathlink_enabled = False
self.deathlink_pending = False
self.deathlink_sent_this_death = False
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(OoTContext, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to Bizhawk to get player information')
return
await self.send_connect()
def on_deathlink(self, data: dict):
self.deathlink_pending = True
super().on_deathlink(data)
def get_payload(ctx: OoTContext):
if ctx.deathlink_enabled and ctx.deathlink_pending:
trigger_death = True
ctx.deathlink_sent_this_death = True
else:
trigger_death = False
return json.dumps({
"items": [get_item_value(item.item) for item in ctx.items_received],
"playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
"triggerDeath": trigger_death
})
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
# Turn on deathlink if it is on
if payload['deathlinkActive'] and not ctx.deathlink_enabled:
await ctx.update_death_link(True)
ctx.deathlink_enabled = True
# Game completion handling
if payload['gameComplete'] and not ctx.finished_game:
await ctx.send_msgs([{
"cmd": "StatusUpdate",
"status": 30
}])
ctx.finished_game = True
# Locations handling
if ctx.location_table != payload['locations']:
ctx.location_table = payload['locations']
await ctx.send_msgs([{
"cmd": "LocationChecks",
"locations": [oot_loc_name_to_id[loc] for loc in ctx.location_table if ctx.location_table[loc]]
}])
# Deathlink handling
if ctx.deathlink_enabled:
if payload['isDead']: # link is dead
ctx.deathlink_pending = False
if not ctx.deathlink_sent_this_death:
ctx.deathlink_sent_this_death = True
await ctx.send_death()
else: # link is alive
ctx.deathlink_sent_this_death = False
async def n64_sync_task(ctx: OoTContext):
logger.info("Starting n64 connector. Use /n64 for status information.")
while not ctx.exit_event.is_set():
error_status = None
if ctx.n64_streams:
(reader, writer) = ctx.n64_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to five fields:
# 1. str: player name (always)
# 2. bool: deathlink active (always)
# 3. dict[str, bool]: checked locations
# 4. bool: whether Link is currently at 0 HP
# 5. bool: whether the game currently registers as complete
data = await asyncio.wait_for(reader.readline(), timeout=10)
data_decoded = json.loads(data.decode())
if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse
asyncio.create_task(parse_payload(data_decoded, ctx, False))
if not ctx.auth:
ctx.auth = data_decoded['playerName']
if ctx.awaiting_rom:
await ctx.server_auth(False)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.n64_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.n64_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.n64_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.n64_streams = None
if ctx.n64_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to N64")
ctx.n64_status = CONNECTION_CONNECTED_STATUS
else:
ctx.n64_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.n64_status = error_status
logger.info("Lost connection to N64 and attempting to reconnect. Use /n64 for status updates")
else:
try:
logger.debug("Attempting to connect to N64")
ctx.n64_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28921), timeout=10)
ctx.n64_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.n64_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.n64_status = CONNECTION_REFUSED_STATUS
continue
async def run_game(romfile):
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def patch_and_run_game(apz5_file):
base_name = os.path.splitext(apz5_file)[0]
decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
apply_patch_file(rom, apz5_file)
rom.write_to_file(decomp_path)
os.chdir(data_path("Compress"))
compress_rom_file(decomp_path, comp_path)
os.remove(decomp_path)
asyncio.create_task(run_game(comp_path))
if __name__ == '__main__':
Utils.init_logging("OoTClient")
async def main():
multiprocessing.freeze_support()
parser = get_base_parser()
parser.add_argument('apz5_file', default="", type=str, nargs="?",
help='Path to an APZ5 file')
args = parser.parse_args()
if args.apz5_file:
logger.info("APZ5 file supplied, beginning patching process...")
asyncio.create_task(patch_and_run_game(args.apz5_file))
ctx = OoTContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop")
if gui_enabled:
input_task = None
from kvui import OoTManager
ctx.ui = OoTManager(ctx)
ui_task = asyncio.create_task(ctx.ui.async_run(), name="UI")
else:
input_task = asyncio.create_task(console_loop(ctx), name="Input")
ui_task = None
ctx.n64_sync_task = asyncio.create_task(n64_sync_task(ctx), name="N64 Sync")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.n64_sync_task:
await ctx.n64_sync_task
if ui_task:
await ui_task
if input_task:
input_task.cancel()
import colorama
colorama.init()
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
loop.close()
colorama.deinit()

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
import typing
import random
from schema import Schema, And, Or
class AssembleOptions(type):
def __new__(mcs, name, bases, attrs):
@@ -14,8 +16,10 @@ class AssembleOptions(type):
name_lookup.update(base.name_lookup)
new_options = {name[7:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("option_")}
if "random" in new_options:
raise Exception("Choice option 'random' cannot be manually assigned.")
assert "random" not in new_options, "Choice option 'random' cannot be manually assigned."
assert len(new_options) == len(set(new_options.values())), "same ID cannot be used twice. Try alias?"
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
options.update(new_options)
@@ -25,29 +29,50 @@ class AssembleOptions(type):
# auto-validate schema on __init__
if "schema" in attrs.keys():
def validate_decorator(func):
def validate(self, *args, **kwargs):
func(self, *args, **kwargs)
if "__init__" in attrs:
def validate_decorator(func):
def validate(self, *args, **kwargs):
ret = func(self, *args, **kwargs)
self.value = self.schema.validate(self.value)
return ret
return validate
attrs["__init__"] = validate_decorator(attrs["__init__"])
else:
# construct an __init__ that calls parent __init__
cls = super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
def meta__init__(self, *args, **kwargs):
super(cls, self).__init__(*args, **kwargs)
self.value = self.schema.validate(self.value)
return validate
cls.__init__ = meta__init__
return cls
attrs["__init__"] = validate_decorator(attrs["__init__"])
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
class Option(metaclass=AssembleOptions):
value: int
name_lookup: typing.Dict[int, str]
T = typing.TypeVar('T')
class Option(typing.Generic[T], metaclass=AssembleOptions):
value: T
default = 0
# convert option_name_long into Name Long as displayname, otherwise name_long is the result.
# convert option_name_long into Name Long as display_name, otherwise name_long is the result.
# Handled in get_option_name()
autodisplayname = False
auto_display_name = False
# can be weighted between selections
supports_weighting = True
# filled by AssembleOptions:
name_lookup: typing.Dict[int, str]
options: typing.Dict[str, int]
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.get_current_option_name()})"
@@ -63,13 +88,13 @@ class Option(metaclass=AssembleOptions):
return self.get_option_name(self.value)
@classmethod
def get_option_name(cls, value: typing.Any) -> str:
if cls.autodisplayname:
def get_option_name(cls, value: T) -> str:
if cls.auto_display_name:
return cls.name_lookup[value].replace("_", " ").title()
else:
return cls.name_lookup[value]
def __int__(self) -> int:
def __int__(self) -> T:
return self.value
def __bool__(self) -> bool:
@@ -80,18 +105,20 @@ class Option(metaclass=AssembleOptions):
raise NotImplementedError
class Toggle(Option):
class Toggle(Option[int]):
option_false = 0
option_true = 1
default = 0
def __init__(self, value: int):
assert value == 0 or value == 1
assert value == 0 or value == 1, "value of Toggle can only be 0 or 1"
self.value = value
@classmethod
def from_text(cls, text: str) -> Toggle:
if text.lower() in {"off", "0", "false", "none", "null", "no"}:
if text == "random":
return cls(random.choice(list(cls.name_lookup)))
elif text.lower() in {"off", "0", "false", "none", "null", "no"}:
return cls(0)
else:
return cls(1)
@@ -132,8 +159,8 @@ class DefaultOnToggle(Toggle):
default = 1
class Choice(Option):
autodisplayname = True
class Choice(Option[int]):
auto_display_name = True
def __init__(self, value: int):
self.value: int = value
@@ -143,8 +170,8 @@ class Choice(Option):
text = text.lower()
if text == "random":
return cls(random.choice(list(cls.name_lookup)))
for optionname, value in cls.options.items():
if optionname == text:
for option_name, value in cls.options.items():
if option_name == text:
return cls(value)
raise KeyError(
f'Could not find option "{text}" for "{cls.__name__}", '
@@ -160,10 +187,10 @@ class Choice(Option):
if isinstance(other, self.__class__):
return other.value == self.value
elif isinstance(other, str):
assert other in self.options
assert other in self.options, f"compared against a str that could never be equal. {self} == {other}"
return other == self.current_key
elif isinstance(other, int):
assert other in self.name_lookup
assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}"
return other == self.value
elif isinstance(other, bool):
return other == bool(self.value)
@@ -174,10 +201,10 @@ class Choice(Option):
if isinstance(other, self.__class__):
return other.value != self.value
elif isinstance(other, str):
assert other in self.options
assert other in self.options, f"compared against a str that could never be equal. {self} != {other}"
return other != self.current_key
elif isinstance(other, int):
assert other in self.name_lookup
assert other in self.name_lookup, f"compared against am int that could never be equal. {self} != {other}"
return other != self.value
elif isinstance(other, bool):
return other != bool(self.value)
@@ -189,7 +216,7 @@ class Choice(Option):
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
class Range(Option, int):
class Range(Option[int], int):
range_start = 0
range_end = 1
@@ -213,22 +240,26 @@ class Range(Option, int):
elif text.startswith("random-range-"):
textsplit = text.split("-")
try:
randomrange = [int(textsplit[len(textsplit)-2]), int(textsplit[len(textsplit)-1])]
random_range = [int(textsplit[len(textsplit) - 2]), int(textsplit[len(textsplit) - 1])]
except ValueError:
raise ValueError(f"Invalid random range {text} for option {cls.__name__}")
randomrange.sort()
if randomrange[0] < cls.range_start or randomrange[1] > cls.range_end:
raise Exception(f"{randomrange[0]}-{randomrange[1]} is outside allowed range {cls.range_start}-{cls.range_end} for option {cls.__name__}")
random_range.sort()
if random_range[0] < cls.range_start or random_range[1] > cls.range_end:
raise Exception(
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"):
return cls(int(round(random.triangular(randomrange[0], randomrange[1], randomrange[0]))))
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[0]))))
elif text.startswith("random-range-middle"):
return cls(int(round(random.triangular(randomrange[0], randomrange[1]))))
return cls(int(round(random.triangular(random_range[0], random_range[1]))))
elif text.startswith("random-range-high"):
return cls(int(round(random.triangular(randomrange[0], randomrange[1], randomrange[1]))))
return cls(int(round(random.triangular(random_range[0], random_range[1], random_range[1]))))
else:
return cls(int(round(random.randint(randomrange[0], randomrange[1]))))
else:
return cls(int(round(random.randint(random_range[0], random_range[1]))))
elif text == "random":
return cls(random.randint(cls.range_start, cls.range_end))
else:
raise Exception(f"random text \"{text}\" did not resolve to a recognized pattern. Acceptable values are: random, random-high, random-middle, random-low, random-range-low-<min>-<max>, random-range-middle-<min>-<max>, random-range-high-<min>-<max>, or random-range-<min>-<max>.")
return cls(int(text))
@classmethod
@@ -244,26 +275,13 @@ class Range(Option, int):
return str(self.value)
class OptionNameSet(Option):
default = frozenset()
def __init__(self, value: typing.Set[str]):
self.value: typing.Set[str] = value
@classmethod
def from_text(cls, text: str) -> OptionNameSet:
return cls({option.strip() for option in text.split(",")})
@classmethod
def from_any(cls, data: typing.Any) -> OptionNameSet:
if type(data) == set:
return cls(data)
return cls.from_text(str(data))
class VerifyKeys:
valid_keys = frozenset()
valid_keys_casefold: bool = False
convert_name_groups: bool = False
verify_item_name: bool = False
verify_location_name: bool = False
value: typing.Any
@classmethod
def verify_keys(cls, data):
@@ -275,11 +293,27 @@ class VerifyKeys:
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls.valid_keys}.")
def verify(self, world):
if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value:
new_value |= world.item_name_groups.get(item_name, {item_name})
self.value = new_value
if self.verify_item_name:
for item_name in self.value:
if item_name not in world.item_names:
raise Exception(f"Item {item_name} from option {self} "
f"is not a valid item name from {world.game}")
elif self.verify_location_name:
for location_name in self.value:
if location_name not in world.location_names:
raise Exception(f"Location {location_name} from option {self} "
f"is not a valid location name from {world.game}")
class OptionDict(Option, VerifyKeys):
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys):
default = {}
supports_weighting = False
value: typing.Dict[str, typing.Any]
def __init__(self, value: typing.Dict[str, typing.Any]):
self.value = value
@@ -300,7 +334,6 @@ class OptionDict(Option, VerifyKeys):
class ItemDict(OptionDict):
# implemented by Generate
verify_item_name = True
def __init__(self, value: typing.Dict[str, int]):
@@ -309,10 +342,9 @@ class ItemDict(OptionDict):
super(ItemDict, self).__init__(value)
class OptionList(Option, VerifyKeys):
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
default = []
supports_weighting = False
value: list
def __init__(self, value: typing.List[typing.Any]):
self.value = value or []
@@ -336,10 +368,9 @@ class OptionList(Option, VerifyKeys):
return item in self.value
class OptionSet(Option):
class OptionSet(Option[typing.Set[str]], VerifyKeys):
default = frozenset()
supports_weighting = False
value: set
def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]):
self.value = set(value)
@@ -352,13 +383,15 @@ class OptionSet(Option):
@classmethod
def from_any(cls, data: typing.Any):
if type(data) == list:
cls.verify_keys(data)
return cls(data)
elif type(data) == set:
cls.verify_keys(data)
return cls(data)
return cls.from_text(str(data))
def get_option_name(self, value):
return ", ".join(value)
return ", ".join(sorted(value))
def __contains__(self, item):
return item in self.value
@@ -372,7 +405,7 @@ class Accessibility(Choice):
Locations: ensure everything can be reached and acquired.
Items: ensure all logically relevant items can be acquired.
Minimal: ensure what is needed to reach your goal can be acquired."""
displayname = "Accessibility"
display_name = "Accessibility"
option_locations = 0
option_items = 1
option_minimal = 2
@@ -382,7 +415,7 @@ class Accessibility(Choice):
class ProgressionBalancing(DefaultOnToggle):
"""A system that moves progression earlier, to try and prevent the player from getting stuck and bored early."""
displayname = "Progression Balancing"
display_name = "Progression Balancing"
common_options = {
@@ -392,45 +425,78 @@ common_options = {
class ItemSet(OptionSet):
# implemented by Generate
verify_item_name = True
convert_name_groups = True
class LocalItems(ItemSet):
"""Forces these items to be in their native world."""
displayname = "Local Items"
display_name = "Local Items"
class NonLocalItems(ItemSet):
"""Forces these items to be outside their native world."""
displayname = "Not Local Items"
display_name = "Not Local Items"
class StartInventory(ItemDict):
"""Start with these items."""
verify_item_name = True
displayname = "Start Inventory"
display_name = "Start Inventory"
class StartHints(ItemSet):
"""Start with these item's locations prefilled into the !hint command."""
displayname = "Start Hints"
display_name = "Start Hints"
class StartLocationHints(OptionSet):
"""Start with these locations and their item prefilled into the !hint command"""
displayname = "Start Location Hints"
display_name = "Start Location Hints"
class ExcludeLocations(OptionSet):
"""Prevent these locations from having an important item"""
displayname = "Excluded Locations"
display_name = "Excluded Locations"
verify_location_name = True
class PriorityLocations(OptionSet):
"""Prevent these locations from having an unimportant item"""
display_name = "Priority Locations"
verify_location_name = True
class DeathLink(Toggle):
"""When you die, everyone dies. Of course the reverse is true too."""
displayname = "Death Link"
display_name = "Death Link"
class ItemLinks(OptionList):
"""Share part of your item pool with other players."""
default = []
schema = Schema([
{
"name": And(str, len),
"item_pool": [And(str, len)],
"replacement_item": Or(And(str, len), None)
}
])
def verify(self, world):
super(ItemLinks, self).verify(world)
existing_links = set()
for link in self.value:
if link["name"] in existing_links:
raise Exception(f"You cannot have more than one link named {link['name']}.")
existing_links.add(link["name"])
for item_name in link["item_pool"]:
if item_name not in world.item_names and item_name not in world.item_name_groups:
raise Exception(f"Item {item_name} from item link {link} "
f"is not a valid item name from {world.game}")
if link["replacement_item"] and link["replacement_item"] not in world.item_names:
raise Exception(f"Item {link['replacement_item']} from item link {link} "
f"is not a valid item name from {world.game}")
per_game_common_options = {
@@ -440,7 +506,9 @@ per_game_common_options = {
"start_inventory": StartInventory,
"start_hints": StartHints,
"start_location_hints": StartLocationHints,
"exclude_locations": ExcludeLocations
"exclude_locations": ExcludeLocations,
"priority_locations": PriorityLocations,
"item_links": ItemLinks
}
if __name__ == "__main__":
@@ -450,8 +518,8 @@ if __name__ == "__main__":
map_shuffle = Toggle
compass_shuffle = Toggle
keyshuffle = Toggle
bigkey_shuffle = Toggle
key_shuffle = Toggle
big_key_shuffle = Toggle
hints = Toggle
test = argparse.Namespace()
test.logic = Logic.from_text("no_logic")

212
Patch.py
View File

@@ -1,6 +1,7 @@
# TODO: convert this into a system like AutoWorld
from __future__ import annotations
import shutil
import json
import bsdiff4
import yaml
import os
@@ -9,21 +10,166 @@ import threading
import concurrent.futures
import zipfile
import sys
from typing import Tuple, Optional
from typing import Tuple, Optional, Dict, Any, Union, BinaryIO
import Utils
current_patch_version = 3
current_patch_version = 4
class AutoPatchRegister(type):
patch_types: Dict[str, APDeltaPatch] = {}
file_endings: Dict[str, APDeltaPatch] = {}
def __new__(cls, name: str, bases, dct: Dict[str, Any]):
# construct class
new_class = super().__new__(cls, name, bases, dct)
if "game" in dct:
AutoPatchRegister.patch_types[dct["game"]] = new_class
if not dct["patch_file_ending"]:
raise Exception(f"Need an expected file ending for {name}")
AutoPatchRegister.file_endings[dct["patch_file_ending"]] = new_class
return new_class
@staticmethod
def get_handler(file: str) -> Optional[type(APDeltaPatch)]:
for file_ending, handler in AutoPatchRegister.file_endings.items():
if file.endswith(file_ending):
return handler
class APContainer:
"""A zipfile containing at least archipelago.json"""
version: int = current_patch_version
compression_level: int = 9
compression_method: int = zipfile.ZIP_DEFLATED
game: Optional[str] = None
# instance attributes:
path: Optional[str]
player: Optional[int]
player_name: str
server: str
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
player_name: str = "", server: str = ""):
self.path = path
self.player = player
self.player_name = player_name
self.server = server
def write(self, file: Optional[Union[str, BinaryIO]] = None):
if not self.path and not file:
raise FileNotFoundError(f"Cannot write {self.__class__.__name__} due to no path provided.")
with zipfile.ZipFile(file if file else self.path, "w", self.compression_method, True, self.compression_level) \
as zf:
if file:
self.path = zf.filename
self.write_contents(zf)
def write_contents(self, opened_zipfile: zipfile.ZipFile):
manifest = self.get_manifest()
try:
manifest = json.dumps(manifest)
except Exception as e:
raise Exception(f"Manifest {manifest} did not convert to json.") from e
else:
opened_zipfile.writestr("archipelago.json", manifest)
def read(self, file: Optional[Union[str, BinaryIO]] = None):
"""Read data into patch object. file can be file-like, such as an outer zip file's stream."""
if not self.path and not file:
raise FileNotFoundError(f"Cannot read {self.__class__.__name__} due to no path provided.")
with zipfile.ZipFile(file if file else self.path, "r") as zf:
if file:
self.path = zf.filename
self.read_contents(zf)
def read_contents(self, opened_zipfile: zipfile.ZipFile):
with opened_zipfile.open("archipelago.json", "r") as f:
manifest = json.load(f)
if manifest["compatible_version"] > self.version:
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
f"for this handler (version: {self.version})")
self.player = manifest["player"]
self.server = manifest["server"]
self.player_name = manifest["player_name"]
def get_manifest(self) -> dict:
return {
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
"player": self.player,
"player_name": self.player_name,
"game": self.game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 4,
"version": current_patch_version,
}
class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
"""An APContainer that additionally has delta.bsdiff4
containing a delta patch to get the desired file, often a rom."""
hash = Optional[str] # base checksum of source file
patch_file_ending: str = ""
delta: Optional[bytes] = None
result_file_ending: str = ".sfc"
source_data: bytes
def __init__(self, *args, patched_path: str = "", **kwargs):
self.patched_path = patched_path
super(APDeltaPatch, self).__init__(*args, **kwargs)
def get_manifest(self) -> dict:
manifest = super(APDeltaPatch, self).get_manifest()
manifest["base_checksum"] = self.hash
manifest["result_file_ending"] = self.result_file_ending
return manifest
@classmethod
def get_source_data(cls) -> bytes:
"""Get Base data"""
raise NotImplementedError()
@classmethod
def get_source_data_with_cache(cls) -> bytes:
if not hasattr(cls, "source_data"):
cls.source_data = cls.get_source_data()
return cls.source_data
def write_contents(self, opened_zipfile: zipfile.ZipFile):
super(APDeltaPatch, self).write_contents(opened_zipfile)
# write Delta
opened_zipfile.writestr("delta.bsdiff4",
bsdiff4.diff(self.get_source_data_with_cache(), open(self.patched_path, "rb").read()),
compress_type=zipfile.ZIP_STORED) # bsdiff4 is a format with integrated compression
def read_contents(self, opened_zipfile: zipfile.ZipFile):
super(APDeltaPatch, self).read_contents(opened_zipfile)
self.delta = opened_zipfile.read("delta.bsdiff4")
def patch(self, target: str):
"""Base + Delta -> Patched"""
if not self.delta:
self.read()
result = bsdiff4.patch(self.get_source_data_with_cache(), self.delta)
with open(target, "wb") as f:
f.write(result)
# legacy patch handling follows:
GAME_ALTTP = "A Link to the Past"
GAME_SM = "Super Metroid"
GAME_SOE = "Secret of Evermore"
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore"}
GAME_SMZ3 = "SMZ3"
supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore", "SMZ3"}
preferred_endings = {
GAME_ALTTP: "apbp",
GAME_SM: "apm3",
GAME_SOE: "apsoe"
GAME_SOE: "apsoe",
GAME_SMZ3: "apsmz"
}
@@ -34,6 +180,10 @@ def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAM
from worlds.sm.Rom import JAP10HASH as HASH
elif game == GAME_SOE:
from worlds.soe.Patch import USHASH as HASH
elif game == GAME_SMZ3:
from worlds.alttp.Rom import JAP10HASH as ALTTPHASH
from worlds.sm.Rom import JAP10HASH as SMHASH
HASH = ALTTPHASH + SMHASH
else:
raise RuntimeError(f"Selected game {game} for base rom not found.")
@@ -63,7 +213,7 @@ def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str
meta,
game)
target = destination if destination else os.path.splitext(rom_file_to_patch)[0] + (
".apbp" if game == GAME_ALTTP else ".apm3")
".apbp" if game == GAME_ALTTP else ".apsmz" if game == GAME_SMZ3 else ".apm3")
write_lzma(bytes, target)
return target
@@ -88,18 +238,29 @@ def get_base_rom_data(game: str):
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_file"]
get_base_rom_bytes = lambda: bytes(read_rom(open(file_name, "rb")))
from worlds.soe.Patch import get_base_rom_path
get_base_rom_bytes = lambda: bytes(read_rom(open(get_base_rom_path(), "rb")))
elif game == GAME_SMZ3:
from worlds.smz3.Rom import get_base_rom_bytes
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:
f.write(patched_data)
return data, target
auto_handler = AutoPatchRegister.get_handler(patch_file)
if auto_handler:
handler: APDeltaPatch = auto_handler(patch_file)
target = os.path.splitext(patch_file)[0]+handler.result_file_ending
handler.patch(target)
return {"server": handler.server,
"player": handler.player,
"player_name": handler.player_name}, target
else:
data, target, patched_data = create_rom_bytes(patch_file)
with open(target, "wb") as f:
f.write(patched_data)
return data, target
def update_patch_data(patch_data: bytes, server: str = "") -> bytes:
@@ -218,24 +379,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(".archipelago"):
import json
import zlib
with open(rom, 'rb') as fr:
multidata = zlib.decompress(fr.read()).decode("utf-8")
with open(rom + '.txt', 'w') as fw:
fw.write(multidata)
multidata = json.loads(multidata)
for romname in multidata['roms']:
Utils.persistent_store("servers", "".join(chr(byte) for byte in romname[2]), address)
from Utils import get_options
multidata["server_options"] = get_options()["server_options"]
multidata = zlib.compress(json.dumps(multidata).encode("utf-8"), 9)
with open(rom + "_updated.archipelago", 'wb') as f:
f.write(multidata)
elif rom.endswith(".apsmz"):
print(f"Applying patch {rom}")
data, target = create_rom_file(rom)
print(f"Created rom {target}.")
if 'server' in data:
Utils.persistent_store("servers", data['hash'], data['server'])
print(f"Host is {data['server']}")
elif rom.endswith(".zip"):
print(f"Updating host in patch files contained in {rom}")
@@ -264,4 +414,4 @@ if __name__ == "__main__":
import traceback
traceback.print_exc()
input("Press enter to close.")
input("Press enter to close.")

View File

@@ -18,6 +18,11 @@ Currently, the following games are supported:
* VVVVVV
* Raft
* Super Mario 64
* Meritous
* Super Metroid/Link to the Past combo randomizer (SMZ3)
* ChecksFinder
* ArchipIDLE
* Hollow Knight
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
@@ -58,6 +63,8 @@ Contributions are welcome. We have a few asks of any new contributors.
Otherwise, we tend to judge code on a case to case basis. It is a generally good idea to stick to PEP-8 guidelines to ensure consistency with existing code. (And to make the linter happy.)
For adding a new game to Archipelago please see the docs folder for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
## Code of Conduct
We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to:

View File

@@ -11,9 +11,8 @@ import shutil
import logging
import asyncio
from json import loads, dumps
from tkinter import font
from Utils import get_item_name_from_id, init_logging
from Utils import init_logging
if __name__ == "__main__":
init_logging("SNIClient", exception_logger="Client")
@@ -22,13 +21,12 @@ 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
from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT
import Utils
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, gui_enabled, get_base_parser
from Patch import GAME_ALTTP, GAME_SM
from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3
snes_logger = logging.getLogger("SNES")
@@ -41,7 +39,7 @@ class DeathState(enum.IntEnum):
dead = 3
class LttPCommandProcessor(ClientCommandProcessor):
class SNIClientCommandProcessor(ClientCommandProcessor):
ctx: Context
def _cmd_slow_mode(self, toggle: str = ""):
@@ -56,7 +54,8 @@ 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"""
otherwise show available devices; and a SNES device number if more than one SNES is detected.
Examples: "/snes", "/snes 1", "/snes localhost:8080 1" """
snes_address = self.ctx.snes_address
snes_device_number = -1
@@ -65,13 +64,11 @@ class LttPCommandProcessor(ClientCommandProcessor):
num_options = len(options)
if num_options > 0:
snes_address = options[0]
snes_device_number = int(options[0])
if num_options > 1:
try:
snes_device_number = int(options[1])
except:
pass
snes_address = options[0]
snes_device_number = int(options[1])
self.ctx.snes_reconnect_address = None
asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), name="SNES Connect")
@@ -92,15 +89,23 @@ class LttPCommandProcessor(ClientCommandProcessor):
# 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
# def _cmd_snes_read(self, address, size=1):
# """Read the SNES' memory address (base16)."""
# if self.ctx.snes_state != SNESState.SNES_ATTACHED:
# self.output("No attached SNES Device.")
# return False
# data = await snes_read(self.ctx, int(address, 16), size)
# self.output(f"Data Read: {data}")
# return True
class Context(CommonContext):
command_processor = LttPCommandProcessor
command_processor = SNIClientCommandProcessor
game = "A Link to the Past"
items_handling = None # set in game_watcher
@@ -167,6 +172,16 @@ class Context(CommonContext):
if not currently_dead:
self.death_state = DeathState.alive
def on_package(self, cmd: str, args: dict):
if cmd in {"Connected", "RoomUpdate"}:
if "checked_locations" in args and args["checked_locations"]:
new_locations = set(args["checked_locations"])
self.checked_locations |= new_locations
self.locations_scouted |= new_locations
# Items belonging to the player should not be marked as checked in game, since the player will likely need that item.
# Once the games handled by SNIClient gets made to be remote items, this will no longer be needed.
asyncio.create_task(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
async def deathlink_kill_player(ctx: Context):
ctx.death_state = DeathState.killing_player
@@ -183,9 +198,11 @@ async def deathlink_kill_player(ctx: Context):
continue
if not invincible[0] and last_health[0] == health[0]:
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
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
snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([1, 0])) # set current health to 1 (to prevent saving with 0 energy)
snes_buffered_write(ctx, WRAM_START + 0x0A50, bytes([255])) # deal 255 of damage at next opportunity
if not ctx.death_link_allow_survive:
snes_buffered_write(ctx, WRAM_START + 0x09D6, bytes([0, 0])) # set current reserve to 0
await snes_flush_writes(ctx)
@@ -200,7 +217,8 @@ async def deathlink_kill_player(ctx: Context):
health = await snes_read(ctx, WRAM_START + 0x09C2, 2)
if health is not None:
health = health[0] | (health[1] << 8)
if not gamemode or gamemode[0] in SM_DEATH_MODES or (ctx.death_link_allow_survive and health is not None and health > 0):
if not gamemode or gamemode[0] in SM_DEATH_MODES or (
ctx.death_link_allow_survive and health is not None and health > 0):
ctx.death_state = DeathState.dead
ctx.last_death_link = time.time()
@@ -233,11 +251,12 @@ SCOUTREPLY_LOCATION_ADDR = SAVEDATA_START + 0x4D8 # 1 byte
SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte
SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte
SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes
SHOP_LEN = (len(Shops.shop_table) * 3) + 5
DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte
# SM
SM_ROMNAME_START = 0x1C4F00
SM_ROMNAME_START = 0x007FC0
SM_INGAME_MODES = {0x07, 0x09, 0x0b}
SM_ENDGAME_MODES = {0x26, 0x27}
@@ -248,6 +267,19 @@ 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
SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte
# SMZ3
SMZ3_ROMNAME_START = 0x00FFC0
SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b}
SMZ3_ENDGAME_MODES = {0x26, 0x27}
SMZ3_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A}
SMZ3_RECV_PROGRESS_ADDR = SRAM_START + 0x4000 # 2 bytes
SMZ3_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
SMZ3_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()])
@@ -472,6 +504,17 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10),
'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40),
'Ganons Tower - Validation Chest': (0x4d, 0x10)}
boss_locations = {Regions.lookup_name_to_id[name] for name in {'Eastern Palace - Boss',
'Desert Palace - Boss',
'Tower of Hera - Boss',
'Palace of Darkness - Boss',
'Swamp Palace - Boss',
'Skull Woods - Boss',
"Thieves' Town - Boss",
'Ice Palace - Boss',
'Misery Mire - Boss',
'Turtle Rock - Boss'}}
location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()}
location_table_npc = {'Mushroom': 0x1000,
@@ -540,8 +583,14 @@ def launch_sni(ctx: Context):
if not sys.stdout: # if it spawns a visible console, may as well populate it
subprocess.Popen(os.path.abspath(sni_path), cwd=os.path.dirname(sni_path))
else:
subprocess.Popen(os.path.abspath(sni_path), cwd=os.path.dirname(sni_path), stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
proc = subprocess.Popen(os.path.abspath(sni_path), cwd=os.path.dirname(sni_path),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
proc.wait(.1) # wait a bit to see if startup fails (missing dependencies)
snes_logger.info('Failed to start SNI. Try running it externally for error output.')
except subprocess.TimeoutExpired:
pass # seems to be running
else:
snes_logger.info(
f"Attempt to start SNI was aborted as path {sni_path} was not found, "
@@ -625,24 +674,24 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1):
try:
devices = await get_snes_devices(ctx)
numDevices = len(devices)
device_count = len(devices)
if numDevices == 1:
if device_count == 1:
device = devices[0]
elif ctx.snes_reconnect_address:
if ctx.snes_attached_device[1] in devices:
device = ctx.snes_attached_device[1]
else:
device = devices[ctx.snes_attached_device[0]]
elif numDevices > 1:
elif device_count > 1:
if deviceIndex == -1:
snes_logger.info(
"Found " + str(numDevices) + " SNES devices; connect to one with /snes <address> <device number>:")
snes_logger.info(f"Found {device_count} SNES devices. "
f"Connect to one with /snes <address> <device number>. For example /snes {address} 1")
for idx, availableDevice in enumerate(devices):
snes_logger.info(str(idx + 1) + ": " + availableDevice)
elif (deviceIndex < 0) or (deviceIndex - 1) > numDevices:
elif (deviceIndex < 0) or (deviceIndex - 1) > device_count:
snes_logger.warning("SNES device number out of range")
else:
@@ -664,8 +713,6 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1):
ctx.snes_attached_device = (devices.index(device), device)
ctx.snes_reconnect_address = address
recv_task = asyncio.create_task(snes_recv_loop(ctx))
SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay
snes_logger.info(f"Attached to {device}")
except Exception as e:
if recv_task is not None:
@@ -684,6 +731,10 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1):
asyncio.create_task(snes_autoreconnect(ctx))
SNES_RECONNECT_DELAY *= 2
else:
SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay
snes_logger.info(f"Attached to {device}")
async def snes_disconnect(ctx: Context):
if ctx.snes_socket:
@@ -814,19 +865,27 @@ async def track_locations(ctx: Context, roomid, roomdata):
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
try:
if roomid in location_shop_ids:
misc_data = await snes_read(ctx, SHOP_ADDR, (len(Shops.shop_table) * 3) + 5)
for cnt, b in enumerate(misc_data):
if int(b) and (Shops.SHOP_ID_START + cnt) not in ctx.locations_checked:
new_check(Shops.SHOP_ID_START + cnt)
shop_data = await snes_read(ctx, SHOP_ADDR, SHOP_LEN)
shop_data_changed = False
shop_data = list(shop_data)
for cnt, b in enumerate(shop_data):
location = Shops.SHOP_ID_START + cnt
if int(b) and location not in ctx.locations_checked:
new_check(location)
if location in ctx.checked_locations and location not in ctx.locations_checked \
and location in ctx.locations_info and ctx.locations_info[location].player != ctx.slot:
if not int(b):
shop_data[cnt] += 1
shop_data_changed = True
if shop_data_changed:
snes_buffered_write(ctx, SHOP_ADDR, bytes(shop_data))
except Exception as e:
snes_logger.info(f"Exception: {e}")
for location_id, (loc_roomid, loc_mask) in location_table_uw_id.items():
try:
if location_id not in ctx.locations_checked and loc_roomid == roomid and (
roomdata << 4) & loc_mask != 0:
if location_id not in ctx.locations_checked and loc_roomid == roomid and \
(roomdata << 4) & loc_mask != 0:
new_check(location_id)
except Exception as e:
snes_logger.exception(f"Exception: {e}")
@@ -834,12 +893,19 @@ async def track_locations(ctx: Context, roomid, roomdata):
uw_begin = 0x129
ow_end = uw_end = 0
uw_unchecked = {}
uw_checked = {}
for location, (roomid, mask) in location_table_uw.items():
location_id = Regions.lookup_name_to_id[location]
if location_id not in ctx.locations_checked:
uw_unchecked[location_id] = (roomid, mask)
uw_begin = min(uw_begin, roomid)
uw_end = max(uw_end, roomid + 1)
if location_id in ctx.checked_locations and location_id not in ctx.locations_checked and \
location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot and \
location_id not in boss_locations:
uw_begin = min(uw_begin, roomid)
uw_end = max(uw_end, roomid + 1)
uw_checked[location_id] = (roomid, mask)
if uw_begin < uw_end:
uw_data = await snes_read(ctx, SAVEDATA_START + (uw_begin * 2), (uw_end - uw_begin) * 2)
@@ -849,14 +915,27 @@ async def track_locations(ctx: Context, roomid, roomdata):
roomdata = uw_data[offset] | (uw_data[offset + 1] << 8)
if roomdata & mask != 0:
new_check(location_id)
if uw_checked:
uw_data = list(uw_data)
for location_id, (roomid, mask) in uw_checked.items():
offset = (roomid - uw_begin) * 2
roomdata = uw_data[offset] | (uw_data[offset + 1] << 8)
roomdata |= mask
uw_data[offset] = roomdata & 0xFF
uw_data[offset + 1] = roomdata >> 8
snes_buffered_write(ctx, SAVEDATA_START + (uw_begin * 2), bytes(uw_data))
ow_begin = 0x82
ow_unchecked = {}
ow_checked = {}
for location_id, screenid in location_table_ow_id.items():
if location_id not in ctx.locations_checked:
ow_unchecked[location_id] = screenid
ow_begin = min(ow_begin, screenid)
ow_end = max(ow_end, screenid + 1)
if location_id in ctx.checked_locations and location_id in ctx.locations_info \
and ctx.locations_info[location_id].player != ctx.slot:
ow_checked[location_id] = screenid
if ow_begin < ow_end:
ow_data = await snes_read(ctx, SAVEDATA_START + 0x280 + ow_begin, ow_end - ow_begin)
@@ -864,25 +943,48 @@ async def track_locations(ctx: Context, roomid, roomdata):
for location_id, screenid in ow_unchecked.items():
if ow_data[screenid - ow_begin] & 0x40 != 0:
new_check(location_id)
if ow_checked:
ow_data = list(ow_data)
for location_id, screenid in ow_checked.items():
ow_data[screenid - ow_begin] |= 0x40
snes_buffered_write(ctx, SAVEDATA_START + 0x280 + ow_begin, bytes(ow_data))
if not ctx.locations_checked.issuperset(location_table_npc_id):
npc_data = await snes_read(ctx, SAVEDATA_START + 0x410, 2)
if npc_data is not None:
npc_value_changed = False
npc_value = npc_data[0] | (npc_data[1] << 8)
for location_id, mask in location_table_npc_id.items():
if npc_value & mask != 0 and location_id not in ctx.locations_checked:
new_check(location_id)
if location_id in ctx.checked_locations and location_id not in ctx.locations_checked \
and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot:
npc_value |= mask
npc_value_changed = True
if npc_value_changed:
npc_data = bytes([npc_value & 0xFF, npc_value >> 8])
snes_buffered_write(ctx, SAVEDATA_START + 0x410, npc_data)
if not ctx.locations_checked.issuperset(location_table_misc_id):
misc_data = await snes_read(ctx, SAVEDATA_START + 0x3c6, 4)
if misc_data is not None:
misc_data = list(misc_data)
misc_data_changed = False
for location_id, (offset, mask) in location_table_misc_id.items():
assert (0x3c6 <= offset <= 0x3c9)
if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked:
new_check(location_id)
if location_id in ctx.checked_locations and location_id not in ctx.locations_checked \
and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot:
misc_data_changed = True
misc_data[offset - 0x3c6] |= mask
if misc_data_changed:
snes_buffered_write(ctx, SAVEDATA_START + 0x3c6, bytes(misc_data))
if new_locations:
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}])
await snes_flush_writes(ctx)
async def game_watcher(ctx: Context):
@@ -898,29 +1000,42 @@ async def game_watcher(ctx: Context):
if not ctx.rom:
ctx.finished_game = False
ctx.death_link_allow_survive = False
game_name = await snes_read(ctx, SM_ROMNAME_START, 2)
game_name = await snes_read(ctx, SM_ROMNAME_START, 5)
if game_name is None:
continue
elif game_name == b"SM":
elif game_name[:2] == b"SM":
ctx.game = GAME_SM
ctx.items_handling = 0b001 # full local
# versions lower than 0.3.0 dont have item handling flag nor remote item support
romVersion = int(game_name[2:5].decode('UTF-8'))
if romVersion < 30:
ctx.items_handling = 0b001 # full local
else:
item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1)
ctx.items_handling = 0b001 if item_handling is None else item_handling[0]
else:
ctx.game = GAME_ALTTP
ctx.items_handling = 0b001 # full local
game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3)
if game_name == b"ZSM":
ctx.game = GAME_SMZ3
ctx.items_handling = 0b101 # local items and remote start inventory
else:
ctx.game = GAME_ALTTP
ctx.items_handling = 0b001 # full local
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else ROMNAME_START, ROMNAME_SIZE)
rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else SMZ3_ROMNAME_START if ctx.game == GAME_SMZ3 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:
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
await ctx.update_death_link(bool(death_link[0] & 0b1))
if ctx.game != GAME_SMZ3:
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:
ctx.death_link_allow_survive = bool(death_link[0] & 0b10)
await ctx.update_death_link(bool(death_link[0] & 0b1))
if not ctx.prev_rom or ctx.prev_rom != ctx.rom:
ctx.locations_checked = set()
ctx.locations_scouted = set()
ctx.locations_info = {}
ctx.prev_rom = ctx.rom
if ctx.awaiting_rom:
@@ -976,7 +1091,8 @@ async def game_watcher(ctx: Context):
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'),
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,
@@ -989,16 +1105,16 @@ async def game_watcher(ctx: Context):
snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR,
bytes([scout_location]))
snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR,
bytes([ctx.locations_info[scout_location][0]]))
bytes([ctx.locations_info[scout_location].item]))
snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR,
bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location][1])]))
bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location].player)]))
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)
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():
@@ -1025,14 +1141,16 @@ async def game_watcher(ctx: Context):
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]))
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)})')
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)
@@ -1043,14 +1161,82 @@ async def game_watcher(ctx: Context):
itemOutPtr = data[2] | (data[3] << 8)
from worlds.sm.Items import items_start_id
from worlds.sm.Locations import locations_start_id
if itemOutPtr < len(ctx.items_received):
item = ctx.items_received[itemOutPtr]
itemId = item.item - items_start_id
locationId = (item.location - locations_start_id) if item.location >= 0 and bool(ctx.items_handling & 0b010) else 0x00
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, locationId & 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)
elif ctx.game == GAME_SMZ3:
currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2)
if (currentGame is not None):
if (currentGame[0] != 0):
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
endGameModes = SM_ENDGAME_MODES
else:
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
endGameModes = ENDGAME_MODES
if gamemode is not None and (gamemode[0] in endGameModes):
if not ctx.finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
continue
data = await snes_read(ctx, SMZ3_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, SMZ3_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8)
# worldId = message[0] | (message[1] << 8) # unused
# itemId = message[2] | (message[3] << 8) # unused
isZ3Item = ((message[5] & 0x80) != 0)
maskedPart = (message[5] & 0x7F) if isZ3Item else message[5]
itemIndex = ((message[4] | (maskedPart << 8)) >> 3) + (256 if isZ3Item else 0)
recv_index += 1
snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
from worlds.smz3.TotalSMZ3.Location 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, SMZ3_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.smz3.TotalSMZ3.Item 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]))
playerID = item.player if item.player <= SMZ3_ROM_PLAYER_LIMIT else 0
snes_buffered_write(ctx, SMZ3_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]))
snes_buffered_write(ctx, SMZ3_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)))
@@ -1130,6 +1316,7 @@ async def main():
if input_task:
input_task.cancel()
def get_alttp_settings(romfile: str):
lastSettings = Utils.get_adjuster_settings(GAME_ALTTP)
adjusted = False
@@ -1139,8 +1326,8 @@ def get_alttp_settings(romfile: str):
if not hasattr(lastSettings, 'auto_apply') or 'ask' in lastSettings.auto_apply:
whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap",
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
"reduceflashing", "deathlink"}
"uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes",
"reduceflashing", "deathlink"}
printed_options = {name: value for name, value in vars(lastSettings).items() if name in whitelist}
if hasattr(lastSettings, "sprite_pool"):
sprite_pool = {}
@@ -1154,40 +1341,41 @@ def get_alttp_settings(romfile: str):
import pprint
if gui_enabled:
from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button
applyPromptWindow = Tk()
applyPromptWindow.resizable(False, False)
applyPromptWindow.protocol('WM_DELETE_WINDOW',lambda: onButtonClick())
applyPromptWindow.protocol('WM_DELETE_WINDOW', lambda: onButtonClick())
logo = PhotoImage(file=Utils.local_path('data', 'icon.png'))
applyPromptWindow.tk.call('wm', 'iconphoto', applyPromptWindow._w, logo)
applyPromptWindow.wm_title("Last adjuster settings LttP")
label = LabelFrame(applyPromptWindow,
text='Last used adjuster settings were found. Would you like to apply these?')
label.grid(column=0,row=0, padx=5, pady=5, ipadx=5, ipady=5)
label.grid_columnconfigure (0, weight=1)
label.grid_columnconfigure (1, weight=1)
label.grid_columnconfigure (2, weight=1)
label.grid_columnconfigure (3, weight=1)
def onButtonClick(answer: str='no'):
text='Last used adjuster settings were found. Would you like to apply these?')
label.grid(column=0, row=0, padx=5, pady=5, ipadx=5, ipady=5)
label.grid_columnconfigure(0, weight=1)
label.grid_columnconfigure(1, weight=1)
label.grid_columnconfigure(2, weight=1)
label.grid_columnconfigure(3, weight=1)
def onButtonClick(answer: str = 'no'):
setattr(onButtonClick, 'choice', answer)
applyPromptWindow.destroy()
framedOptions = Frame(label)
framedOptions.grid(column=0, columnspan=4,row=0)
framedOptions.grid(column=0, columnspan=4, row=0)
framedOptions.grid_columnconfigure(0, weight=1)
framedOptions.grid_columnconfigure(1, weight=1)
framedOptions.grid_columnconfigure(2, weight=1)
curRow = 0
curCol = 0
for name, value in printed_options.items():
Label(framedOptions, text=name+": "+str(value)).grid(column=curCol, row=curRow, padx=5)
if(curCol==2):
curRow+=1
curCol=0
Label(framedOptions, text=name + ": " + str(value)).grid(column=curCol, row=curRow, padx=5)
if (curCol == 2):
curRow += 1
curCol = 0
else:
curCol+=1
curCol += 1
yesButton = Button(label, text='Yes', command=lambda: onButtonClick('yes'), width=10)
yesButton.grid(column=0, row=1)
@@ -1203,8 +1391,8 @@ def get_alttp_settings(romfile: str):
choice = getattr(onButtonClick, 'choice')
else:
choice = input(f"Last used adjuster settings were found. Would you like to apply these? \n"
f"{pprint.pformat(printed_options)}\n"
f"Enter yes, no, always or never: ")
f"{pprint.pformat(printed_options)}\n"
f"Enter yes, no, always or never: ")
if choice and choice.startswith("y"):
choice = 'yes'
elif choice and "never" in choice:
@@ -1221,7 +1409,7 @@ def get_alttp_settings(romfile: str):
choice = 'no'
elif 'always' in lastSettings.auto_apply:
choice = 'yes'
if 'yes' in choice:
from worlds.alttp.Rom import get_base_rom_path
lastSettings.rom = romfile
@@ -1247,10 +1435,10 @@ def get_alttp_settings(romfile: str):
except Exception as e:
logging.exception(e)
else:
adjusted = False
return adjustedromfile, adjusted
if __name__ == '__main__':
colorama.init()
loop = asyncio.get_event_loop()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import shutil
import typing
import builtins
import os
@@ -24,7 +25,7 @@ class Version(typing.NamedTuple):
build: int
__version__ = "0.2.4"
__version__ = "0.3.1"
version_tuple = tuplize_version(__version__)
from yaml import load, dump, SafeLoader
@@ -72,10 +73,10 @@ def is_frozen() -> bool:
return getattr(sys, 'frozen', False)
def local_path(*path):
if local_path.cached_path:
return os.path.join(local_path.cached_path, *path)
def local_path(*path: str) -> str:
"""Returns path to a file in the local Archipelago installation or source."""
if hasattr(local_path, 'cached_path'):
pass
elif is_frozen():
if hasattr(sys, "_MEIPASS"):
# we are running in a PyInstaller bundle
@@ -95,21 +96,47 @@ def local_path(*path):
return os.path.join(local_path.cached_path, *path)
local_path.cached_path = None
def home_path(*path: str) -> str:
"""Returns path to a file in the user home's Archipelago directory."""
if hasattr(home_path, 'cached_path'):
pass
elif sys.platform.startswith('linux'):
home_path.cached_path = os.path.expanduser('~/Archipelago')
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
else:
# not implemented
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
return os.path.join(home_path.cached_path, *path)
def output_path(*path):
if output_path.cached_path:
def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, 'cached_path'):
pass
elif os.access(local_path(), os.W_OK):
user_path.cached_path = local_path()
else:
user_path.cached_path = home_path()
# populate home from local - TODO: upgrade feature
if user_path.cached_path != local_path() and not os.path.exists(user_path('host.yaml')):
for dn in ('Players', 'data/sprites'):
shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True)
for fn in ('manifest.json', 'host.yaml'):
shutil.copy2(local_path(fn), user_path(fn))
return os.path.join(user_path.cached_path, *path)
def output_path(*path: str):
if hasattr(output_path, 'cached_path'):
return os.path.join(output_path.cached_path, *path)
output_path.cached_path = local_path(get_options()["general_options"]["output_path"])
output_path.cached_path = user_path(get_options()["general_options"]["output_path"])
path = os.path.join(output_path.cached_path, *path)
os.makedirs(os.path.dirname(path), exist_ok=True)
return path
output_path.cached_path = None
def open_file(filename):
if sys.platform == 'win32':
os.startfile(filename)
@@ -182,7 +209,7 @@ def get_default_options() -> dict:
"output_path": "output",
},
"factorio_options": {
"executable": "factorio\\bin\\x64\\factorio",
"executable": os.path.join("factorio", "bin", "x64", "factorio"),
},
"sm_options": {
"rom_file": "Super Metroid (JU).sfc",
@@ -219,7 +246,7 @@ def get_default_options() -> dict:
},
"generator": {
"teams": 1,
"enemizer_path": "EnemizerCLI/EnemizerCLI.Core.exe",
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core.exe"),
"player_files_path": "Players",
"players": 0,
"weights_file_path": "weights.yaml",
@@ -263,8 +290,11 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
@cache_argsless
def get_options() -> dict:
if not hasattr(get_options, "options"):
locations = ("options.yaml", "host.yaml",
local_path("options.yaml"), local_path("host.yaml"))
filenames = ("options.yaml", "host.yaml")
locations = []
if os.path.join(os.getcwd()) != local_path():
locations += filenames # use files from cwd only if it's not the local_path
locations += [user_path(filename) for filename in filenames]
for location in locations:
if os.path.exists(location):
@@ -274,7 +304,7 @@ def get_options() -> dict:
get_options.options = update_options(get_default_options(), options, location, list())
break
else:
raise FileNotFoundError(f"Could not find {locations[1]} to load options.")
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
return get_options.options
@@ -289,7 +319,7 @@ def get_location_name_from_id(code: int) -> str:
def persistent_store(category: str, key: typing.Any, value: typing.Any):
path = local_path("_persistent_storage.yaml")
path = user_path("_persistent_storage.yaml")
storage: dict = persistent_load()
category = storage.setdefault(category, {})
category[key] = value
@@ -301,7 +331,7 @@ def persistent_load() -> typing.Dict[dict]:
storage = getattr(persistent_load, "storage", None)
if storage:
return storage
path = local_path("_persistent_storage.yaml")
path = user_path("_persistent_storage.yaml")
storage: dict = {}
if os.path.exists(path):
try:
@@ -349,7 +379,7 @@ class RestrictedUnpickler(pickle.Unpickler):
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# used by MultiServer -> savegame/multidata
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint"}:
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name in {"PlandoItem", "PlandoConnection"}:
@@ -388,7 +418,7 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
log_format: str = "[%(name)s]: %(message)s", exception_logger: str = ""):
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = local_path("logs")
log_folder = user_path("logs")
os.makedirs(log_folder, exist_ok=True)
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
@@ -438,11 +468,22 @@ def stream_input(stream, queue):
def tkinter_center_window(window: Tk):
window.update()
xPos = int(window.winfo_screenwidth()/2 - window.winfo_reqwidth()/2)
yPos = int(window.winfo_screenheight()/2 - window.winfo_reqheight()/2)
xPos = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2)
yPos = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2)
window.geometry("+{}+{}".format(xPos, yPos))
class VersionException(Exception):
pass
def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")):
n = 0
while value > power:
value /= power
n += 1
if type(value) == int:
return f"{value} {power_labels[n]}"
else:
return f"{value:0.3f} {power_labels[n]}"

View File

@@ -3,11 +3,13 @@ import multiprocessing
import logging
import ModuleUpdate
ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt"))
ModuleUpdate.update()
# in case app gets imported by something like gunicorn
import Utils
Utils.local_path.cached_path = os.path.dirname(__file__)
from WebHostLib import app as raw_app
@@ -19,6 +21,8 @@ from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files
configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml'))
def get_app():
@@ -32,6 +36,15 @@ def get_app():
return app
def create_ordered_tutorials_file():
import json
with open(os.path.join("WebHostLib", "static", "assets", "tutorial", "tutorials.json")) as source:
data = json.load(source)
data = sorted(data, key=lambda entry: entry["gameTitle"].lower())
with open(os.path.join("WebHostLib", "static", "generated", "tutorials.json"), "w") as target:
json.dump(data, target)
if __name__ == "__main__":
multiprocessing.freeze_support()
multiprocessing.set_start_method('spawn')
@@ -43,6 +56,7 @@ if __name__ == "__main__":
logging.warning("Could not update LttP sprites.")
app = get_app()
create_options_files()
create_ordered_tutorials_file()
if app.config["SELFLAUNCH"]:
autohost(app.config)
if app.config["SELFGEN"]:

View File

@@ -70,6 +70,12 @@ app.url_map.converters["suuid"] = B64UUIDConverter
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
def get_world_theme(game_name: str):
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
@app.before_request
def register_session():
session.permanent = True # technically 31 days after the last visit
@@ -97,13 +103,13 @@ def weighted_settings():
# Player settings pages
@app.route('/games/<string:game>/player-settings')
def player_settings(game):
return render_template(f"player-settings.html", game=game)
return render_template(f"player-settings.html", game=game, theme=get_world_theme(game))
# Game Info Pages
@app.route('/games/<string:game>/info/<string:lang>')
def game_info(game, lang):
return render_template('gameInfo.html', game=game, lang=lang)
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
# List of supported games
@@ -112,13 +118,13 @@ def games():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world.__doc__ if world.__doc__ else "No description provided."
worlds[game] = world
return render_template("supportedGames.html", worlds=worlds)
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
def tutorial(game, file, lang):
return render_template("tutorial.html", game=game, file=file, lang=lang)
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
@app.route('/tutorial/')

View File

@@ -1,9 +1,12 @@
import zipfile
import json
from io import BytesIO
from flask import send_file, Response, render_template
from pony.orm import select
from Patch import update_patch_data, preferred_endings
from Patch import update_patch_data, preferred_endings, AutoPatchRegister
from WebHostLib import app, Slot, Room, Seed, cache
import zipfile
@app.route("/dl_patch/<suuid:room_id>/<int:patch_id>")
@@ -12,16 +15,34 @@ def download_patch(room_id, patch_id):
if not patch:
return "Patch not found"
else:
import io
room = Room.get(id=room_id)
last_port = room.last_port
filelike = BytesIO(patch.data)
greater_than_version_3 = zipfile.is_zipfile(filelike)
if greater_than_version_3:
# Python's zipfile module cannot overwrite/delete files in a zip, so we recreate the whole thing in ram
new_file = BytesIO()
with zipfile.ZipFile(filelike, "a") as zf:
with zf.open("archipelago.json", "r") as f:
manifest = json.load(f)
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}"
with zipfile.ZipFile(new_file, "w") as new_zip:
for file in zf.infolist():
if file.filename == "archipelago.json":
new_zip.writestr("archipelago.json", json.dumps(manifest))
else:
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
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)}" \
f"{AutoPatchRegister.patch_types[patch.game].patch_file_ending}"
new_file.seek(0)
return send_file(new_file, as_attachment=True, attachment_filename=fname)
else:
patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}")
patch_data = BytesIO(patch_data)
fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \
f"{preferred_endings[patch.game]}"
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)
@@ -53,6 +74,10 @@ def download_slot_file(room_id, player_id: int):
fname = name.rsplit("/", 1)[0]+".zip"
elif slot_data.game == "Ocarina of Time":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
elif slot_data.game == "VVVVVV":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
elif slot_data.game == "Super Mario 64":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
else:
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)

View File

@@ -5,6 +5,7 @@ import json
import zipfile
from collections import Counter
from typing import Dict, Optional as TypeOptional
from Utils import __version__
from flask import request, flash, redirect, url_for, session, render_template
@@ -78,7 +79,7 @@ def generate(race=False):
return redirect(url_for("view_seed", seed=seed_id))
return render_template("generate.html", race=race)
return render_template("generate.html", race=race, version=__version__)
def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=None, sid=None):
@@ -120,7 +121,8 @@ def gen_game(gen_options, meta: TypeOptional[Dict[str, object]] = None, owner=No
if not erargs.name[player]:
erargs.name[player] = os.path.splitext(os.path.split(playerfile)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
if len(set(erargs.name.values())) != len(erargs.name):
raise Exception(f"Names have to be unique. Names: {Counter(erargs.name.values())}")
ERmain(erargs, seed, baked_server_options=meta)
return upload_to_db(target.name, sid, owner, race)

View File

@@ -2,7 +2,7 @@ import os
import threading
import json
from Utils import local_path
from Utils import local_path, user_path
from worlds.alttp.Rom import Sprite
@@ -14,8 +14,8 @@ def update_sprites_lttp():
from LttPAdjuster import update_sprites
# Target directories
input_dir = local_path("data", "sprites", "alttpr")
output_dir = local_path("WebHostLib", "static", "generated")
input_dir = user_path("data", "sprites", "alttpr")
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
# update sprites through gui.py's functions

View File

@@ -12,7 +12,7 @@ STATE_ERROR = -1
class Slot(db.Entity):
id = PrimaryKey(int, auto=True)
player_id = Required(int)
player_name = Required(str, 16)
player_name = Required(str)
data = Optional(bytes, lazy=True)
seed = Optional('Seed')
game = Required(str)

View File

@@ -69,7 +69,7 @@ def create():
elif option.options:
game_options[option_name] = this_option = {
"type": "select",
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
"defaultValue": None,
"options": []
@@ -92,7 +92,7 @@ def create():
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
game_options[option_name] = {
"type": "range",
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
"defaultValue": option.default if hasattr(option, "default") else option.range_start,
"min": option.range_start,
@@ -102,14 +102,14 @@ def create():
elif getattr(option, "verify_item_name", False):
game_options[option_name] = {
"type": "items-list",
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
}
elif getattr(option, "verify_location_name", False):
game_options[option_name] = {
"type": "locations-list",
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
}
@@ -117,7 +117,7 @@ def create():
if option.valid_keys:
game_options[option_name] = {
"type": "custom-list",
"displayName": option.displayname if hasattr(option, "displayname") else option_name,
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
"options": list(option.valid_keys),
}
@@ -132,7 +132,7 @@ def create():
with open(os.path.join(target_folder, 'player-settings', game_name + ".json"), "w") as f:
json.dump(player_settings, f, indent=2, separators=(',', ': '))
if not world.hidden:
if not world.hidden and world.web.settings_page is True:
weighted_settings["baseOptions"]["game"][game_name] = 0
weighted_settings["games"][game_name] = {}
weighted_settings["games"][game_name]["gameSettings"] = game_options

View File

@@ -1,6 +1,6 @@
flask>=2.0.2
pony>=0.7.14
waitress>=2.0.0
flask>=2.0.3
pony>=0.7.16
waitress>=2.1.0
flask-caching>=1.10.1
Flask-Compress>=1.10.1
Flask-Limiter>=2.1
Flask-Compress>=1.11
Flask-Limiter>=2.2.0

View File

@@ -0,0 +1,12 @@
# ArchipIDLE
## What is this game?
ArchipIDLE is the 2022 Archipelago April Fools' Day joke. It is an idle game that sends a location check every
thirty seconds, up to one hundred checks.
## Where is the settings page?
The [player settings page for this game](../player-settings) contains all the options you need to configure
and export a config file.

View File

@@ -0,0 +1,24 @@
# ChecksFinder
## Where is the settings page?
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
config file.
## What is considered a location check in ChecksFinder?
Location checks in are completed when the player finds a spot on a board that has the archipelago logo. The bottom of
the screen has a number next to the archipelago logo, that number is how many you can find so far. You can only get as
many checks as you have gained items, plus five to start with being available.
## When the player receives an item, what happens?
When the player receives an item in ChecksFinder, it either can make the future boards they play be bigger in width or
height, or add a new bomb to the future boards, with a limit to having up to one fifth of the _current_ board being
bombs. The items you have gained _before_ the current board was made will be said at the bottom of the screen as a number
next to an icon, the number is how many you have gotten and the icon represents which item it is.
## What is the victory condition?
Victory is achieved when the player wins a board they were given after they have received all of their Map Width, Map
Height, and Map Bomb items. The game will say at the bottom of the screen how many of each you have received.

View File

@@ -0,0 +1,22 @@
# Hollow Knight
## Where is the settings page?
The [player settings page for this game](../player-settings) contains all the options you need to configure and export a
config file.
## What does randomization do to this game?
Randomization swaps around the locations of items. The items being swapped around are chosen within your YAML.
Shop costs are presently always randomized.
## What Hollow Knight items can appear in other players' worlds?
This is dependent entirely upon your YAML settings. Some examples include: charms, grubs, lifeblood cocoons, geo, etc.
## What does another world's item look like in Hollow Knight?
When the Hollow Knight player picks up an item from a location and it is an item for another game it will appear in that
player's recent items display as an item being sent to another player. If the item is for another Hollow Knight player
then the sprite will be that of the item's original sprite. If the item belongs to a player that is not playing Hollow
Knight then the sprite will be the Archipelago logo.

View File

@@ -0,0 +1,25 @@
# Meritous
## Where is the settings page?
The [player settings page for Meritous](../player-settings) contains all the options you need to configure and export a config file.
## What does randomization do to this game?
The PSI Enhancement Tiles have become general-purpose Item Caches, and all upgrades and artifacts are added to the multiworld item pool. Optionally, the progression-critical PSI Keys can also be added to the pool, as well as monster evolution traps which (in vanilla) trigger when bosses are defeated.
## What is the goal of Meritous when randomized?
At minimum, you will need to get the PSI Keys, defeat the three bosses, retrieve the Cursed Seal, and return it to the entrance. Depending on your selected goal, you may also have to defeat the final boss, or you may also need to explore every last room of the Atlas Dome and retrieve the Agate Knife before getting the Cursed Seal and defeating the final boss' true form.
## Which items can be in another player's world?
Every item added to the multiworld pool (as outlined above) can be distributed to other players' worlds.
## What is considered a location check in Meritous?
The Alpha, Beta, and Gamma item caches each have 24 checks to buy, increasing in cost each time. Reward chests obtained from clearing ambush rooms will contain up to 24 location checks, thereafter always awarding a cache of PSI Crystals. If enabled, PSI Key Pedestals will contain checks, which must be unlocked by eliminating a certain percentage of monsters. If enabled, defeating bosses will result in an automatic check.
## Which notable items are not randomized?
The Cursed Seal and Agate Knife will always be in the farthest-away room from the Entrance and the final room explored, respectively.
## What does another world's item look like in Meritous?
There is no visual representation of other players' items in Meritous. You will be buying checks from item caches and opening chests in ambush rooms blindly.
## When the player receives an item, what happens?
A sound will play, and a notification will briefly appear on the lower half of the screen informing you of what you have received.

View File

@@ -0,0 +1,35 @@
# SMZ3
## Where is the settings page?
The [player settings page for this game](../player-settings) 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 main inventory items, collectables, 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.
## What does another world's item look like in LttP?
Items belonging to other worlds are represented by a Power Star from Super Mario 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

@@ -10,7 +10,8 @@
- Hardware or software capable of loading and playing SNES ROM files
- An emulator capable of connecting to SNI
([snes9x rr](https://github.com/gocha/snes9x-rr/releases),
[BizHawk](http://tasvideos.org/BizHawk.html))
[BizHawk](http://tasvideos.org/BizHawk.html), or
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 or newer). Or,
- An SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), or other compatible hardware
- Your Japanese v1.0 ROM file, probably named `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
@@ -102,6 +103,21 @@ first time launching, you may be prompted to allow it to communicate through the
- SNIClient users should look in their Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
emulator is 64-bit or 32-bit.
##### RetroArch 1.10.1 or newer
You only have to do these steps once.
1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355.
![Screenshot of Network Commands setting](/static/assets/tutorial/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)".
When loading a ROM, be sure to select a **bsnes-mercury** core. These are the only cores that allow external tools to
read ROM data.
#### With hardware
This guide assumes you have downloaded the correct firmware for your device. If you have not done so already, please do

View File

@@ -13,7 +13,8 @@
- Hardware o software capaz de cargar y ejecutar archivos de ROM de SNES
- Un emulador capaz de ejecutar scripts Lua
([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
[BizHawk](http://tasvideos.org/BizHawk.html))
[BizHawk](http://tasvideos.org/BizHawk.html), o
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 o más nuevo). O,
- Un flashcart SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), o otro hardware compatible
- Tu archivo ROM japones v1.0, probablemente se llame `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
@@ -135,6 +136,21 @@ ejecutas, puedes ser que el firewall de Windows te pregunte si le permites la co
7. Observa que se ha asignado un nombre al dispositivo, y el cliente muestra "SNES Device: Connected", con el mismo
nombre en la esquina superior izquierda.
##### RetroArch 1.10.1 o más nuevo
Sólo hay que segiur estos pasos una vez.
1. Comienza en la pantalla del menú principal de RetroArch.
2. Ve a Ajustes --> Interfaz de usario. Configura "Mostrar ajustes avanzados" en ON.
3. Ve a Ajustes --> Red. Configura "Comandos de red" en ON. (Se encuentra bajo Request Device 16.) Deja en 55355 (el
default) el Puerto de comandos de red.
![Captura de pantalla del ajuste Comandos de red](/static/assets/tutorial/retroarch-network-commands-en.png)
4. Ve a Menú principal --> Actualizador en línea --> Descargador de núcleos. Desplázate y selecciona "Nintendo - SNES /
SFC (bsnes-mercury Performance)".
Cuando cargas un ROM, asegúrate de seleccionar un núcleo **bsnes-mercury**. Estos son los sólos núcleos que permiten
que herramientas externas lean datos del ROM.
#### Con Hardware
Esta guía asume que ya has descargado el firmware correcto para tu dispositivo. Si no lo has hecho ya, hazlo ahora. Los
@@ -203,4 +219,4 @@ El programa recomentdado actualmente es:
4. Si quieres que las llaves y los objetos de mazmorra tambien sean marcados, activa la caja con nombre **Race Illegal
Tracking**
5. Haz click en el boton **Start Autotracking**
6. Cierra la ventana AutoTracker, ya que deja de ser necesaria
6. Cierra la ventana AutoTracker, ya que deja de ser necesaria

View File

@@ -0,0 +1,12 @@
# ArchipIdle Setup Guide
## Joining a MultiWorld Game
1. Generate a `.yaml` file from the [ArchipIDLE Player Settings Page](/games/ArchipIDLE/player-settings)
2. Open the ArchipIDLE Client in your web browser by either:
- Navigate to the [ArchipIDLE Client](http://idle.multiworld.link)
- Download the client and run it locally from the
[ArchipIDLE GitHub Releases Page](https://github.com/ArchipelagoMW/archipidle/releases)
3. Enter the server address in the `Server Address` field and press enter
4. Enter your slot name when prompted. This should be the same as the `name` you entered on the
setting page above, or the `name` field in your yaml file.
5. Click the "Begin!" button.

View File

@@ -1,17 +1,15 @@
# Advanced YAML Guide
This guide covers more the more advanced options available in YAML files. This guide is intended for the user who is
intent on editing their YAML file manually. This guide should take about 10 minutes to read.
This guide covers more the more advanced options available in YAML files. This guide is intended for the user who is intent on editing their YAML file manually. This guide should take about 10 minutes to read.
If you would like to generate a basic, fully playable, YAML without editing a file then visit the settings page for the
game you intend to play.
If you would like to generate a basic, fully playable, YAML without editing a file then visit the settings page for the game you intend to play. The weighted settings page can also handle most of the advanced settings discussed here.
The settings page can be found on the supported games page, just click the "Settings Page" link under the name of the
game you would like. Supported games page: [Archipelago Games List](https://archipelago.gg/games)
The settings page can be found on the supported games page, just click the "Settings Page" link under the name of the game you would like.
* Supported games page: [Archipelago Games List](/games)
* Weighted settings page: [Archipelago Weighted Settings](/weighted-settings)
Clicking on the "Export Settings" button at the bottom-left will provide you with a pre-filled YAML with your options.
The player settings page also has an option to download a fully filled out yaml containing every option with every
available setting for the available options.
The player settings page also has a link to download a full template file for that game which will have every option possible for the game including some that don't display correctly on the site.
## YAML Overview

View File

@@ -91,5 +91,6 @@ including the exclamation point.
- `/allow_forfeit <player name>` Allows the given player to use the `!forfeit` command.
- `/forbid_forfeit <player name>` Bars the given player from using the `!forfeit` command.
- `/send <player name> <item name>` Grants the given player the specified item.
- `/send_multiple <amount> <player name> <item name>` Grants the given player the stated amount of the specified item.
- `/hint <player name> <item or location name>` Send out a hint for the given item or location for the specified player.
- `/option <option name> <option value>` Set a server option. For a list of options, use the `/options` command.

View File

@@ -19,9 +19,7 @@ enabled (opt-in).
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`.
* If you are not the one doing the generation or even if you are you can add to the `requires` section of your yaml so
that it will throw an error if the options that you need to generate properly are not enabled to ensure you will get
the results you desire. Only enter in the plando modules that you are using here but it should look like:
* You can add the necessary plando modules for your settings to the `requires` section of your yaml. Doing so will throw an error if the options that you need to generate properly are not enabled to ensure you will get the results you desire. Only enter in the plando modules that you are using here but it should look like:
```yaml
requires:
@@ -66,38 +64,9 @@ list of specific locations both in their own game or in another player's game.
* If `min` and `max` are defined, it will try to place a number of items between these two numbers at random
### Available Items
### Available Items and Locations
* [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)
* [Final Fantasy 1](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ff1/data/items.json)
* [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)
* [Rogue Legacy](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/rogue-legacy/Names/ItemName.py)
* [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)
* [Super Metroid](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/sm/variaRandomizer/rando/Items.py#L37) Look for "Name="
* [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
* [Final Fantasy 1](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/ff1/data/locations.json)
* [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.
* [Rogue Legacy](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/rogue-legacy/Names/LocationName.py)
* [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)
* [Super Metroid](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/sm/variaRandomizer/graph/location.py#L132)
* [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).
A list of all available items and locations can be found in the [website's datapackage](/datapackage). The items and locations will be in the `"item_name_to_id"` and `"location_name_to_id"` sections of the relevant game. You do not need the quotes but the name must be entered in the same as it appears on that page and is caps-sensitive.
### Examples

View File

@@ -0,0 +1,45 @@
# ChecksFinder Randomizer Setup Guide
## Required Software
- ChecksFinder from
the [Github releases Page for the game](https://github.com/jonloveslegos/ChecksFinder/releases) (latest version)
- Archipelago from the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases)
- (select `ChecksFinder Client` during installation.)
## Configuring your YAML file
### What is a YAML file and why do I need one?
See the guide on setting up a basic YAML at the Archipelago setup
guide: [Basic Multiworld Setup Guide](/tutorial/archipelago/setup/en)
### Where do I get a YAML file?
You can customize your settings by visiting the [ChecksFinder Player Settings Page](/games/ChecksFinder/player-settings)
### Generating a ChecksFinder game
**ChecksFinder is meant to be played _alongside_ another game! You may not be playing it for long periods of time if
you play it by itself with another person!**
When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that is done,
the host will provide you with either a link to download your data file, or with a zip file containing everyone's data
files. You do not have a file inside that zip though!
You need to start ChecksFinder client yourself, it is located within the Archipelago folder.
### Connect to the MultiServer
First start ChecksFinder.
Once both ChecksFinder and the client are started. In the client at the top type in the spot labeled `Server` type the
`Ip Address` and `Port` separated with a `:` symbol.
The client will then ask for the username you chose, input that in the text box at the bottom of the client.
### Play the game
When the console tells you that you have joined the room, you're all set. Congratulations on successfully joining a
multiworld game!

View File

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

Before

Width:  |  Height:  |  Size: 627 KiB

After

Width:  |  Height:  |  Size: 627 KiB

View File

@@ -0,0 +1,30 @@
# Hollow Knight for Archipelago Setup Guide
## Required Software
* Download and unzip the Scarab Mod Manager from the [Scarab GitHub Releases page](https://github.com/fifty-six/Scarab/releases).
* A legal copy of Hollow Knight, not purchased or played through XBox Game Pass.
* Unfortunately, the Game Pass version is not currently compatible with mods.
## Installing the Archipelago Mod using Scarab
1. Launch Scarab and ensure it locates your Hollow Knight installation directory.
2. Click the "Install" checkbox near the "Archipelago" mod entry.
3. Launch the game, you're all set!
## Configuring your YAML File
### What is a YAML and why do I need one?
You can see the [basic multiworld setup guide](/tutorial/Archipelago/setup/en) here on the Archipelago website to learn
about why Archipelago uses YAML files and what they're for.
### Where do I get a YAML?
You can use the [game settings page for Hollow Knight](/games/Hollow%20Knight/player-settings) here on the Archipelago
website to generate a YAML using a graphical interface.
### Joining an Archipelago Game in Hollow Knight
1. Start the game after installing all necessary mods.
2. Create a **new save game.**
3. Select the **Archipelago** game mode from the mode selection screen.
4. Enter the correct settings for your Archipelago server.
5. Hit **Start** to begin the game. The game will stall for a few seconds while it does all item placements.
6. The game will immediately drop you into the randomized game.
* If you are waiting for a countdown then wait for it to lapse before hitting Start.
* Or hit Start then pause the game once you're in it.

View File

@@ -0,0 +1,64 @@
# Meritous Randomizer Setup Guide
## Required Software
Download the game from the [Meritous Gaiden GitHub releases page](https://github.com/FelicitusNeko/meritous-ap/releases)
## Installation Procedures
Simply download the latest version of Meritous Gaiden from the link above, and extract it wherever you like.
- ⚠️ Do not extract Meritous Gaiden to Program Files, as this will cause file access issues.
## Joining a Multiworld Game
1. Modify the `meritous-ap.json` file with your server details, as outlined in the next section.
2. Run `meritous.exe`. If the AP settings file is detected, you will see "AP Enabled" show up in the bottom left of the menu screen.
3. Start a new game. If it is able to successfully connect to the AP server, "Connected" will show up in the bottom left of the game screen for a few seconds.
## AP Settings File
The format of `meritous-ap.json` should be as follows:
```json
{
"ap-enable": true,
"server": "archipelago.gg",
"port": 38281,
"password": null,
"slotname": "YourName"
}
```
- `ap-enable`: Enables the game to connect to the Archipelago server. If this is `false` or missing, it will generate a local item randomizer.
- `server`: The server to which to connect. This can be a domain name (such as archipelago.gg) or an IP address (such as 127.0.0.1). If this is missing, the game will assume archipelago.gg.
- `port`: The port number to which to connect. By default, Archipelago will use port 38281 to host, unless the game is hosted on the Archipelago webhost. If this is missing, the game will assume 38281.
- `password`: The password to use for this game, if any. This can be omitted or set to `null` if there is no password.
- `slotname`: The slot name to use for this game. This is required, and must match the name provided on your YAML file.
Eventually, this process will be moved to in-game menus for better ease of use.
## Finishing the Game
Your initial goal is to find all three PSI Keys. Depending on your YAML settings, these may be located on pedestals in special rooms in the Atlas Dome, or they may be scattered across other players' worlds. These PSI Keys are then brought to their respective locations in the Dome, where you will be subjected to a boss battle. Once all three bosses are defeated, this unlocks the Cursed Seal, hidden in the farthest-away location from the Entrance. The Compass tiles can help you find your way to these locations.
At minimum, every seed will require you to find the Cursed Seal and bring it back to the Entrance. The goal can then vary based on your `goal` YAML setting:
- `return_the_cursed_seal`: You will fight the final boss, but win or lose, a victory will be posted.
- `any_ending`: You must defeat the final boss.
- `true_ending`: You must first explore all 3000 rooms of the Atlas Dome and find the Agate Knife, then fight the final boss' true form.
Once the goal has been completed, you may press F to send a forfeit, sending out all of your world's remaining items to their respective players, and C to send a collect, which gathers up all of your world's items from their shuffled locations in other player's worlds. You may also press S to view your statistics, if you're a fan of numbers.
More in-depth information about the game can be found in the game's help file, accessed by pressing H while playing.
## Game Troubleshooting
### An error message shows up at the bottom-left
- `Disconnected`: If the game does not reconnect automatically, you may need to save, quit, and reload the game to reconnect. Keep in mind that the game does not auto-save, and it is only possible to save the game at Save Tiles.
- `InvalidSlot`, `InvalidGame`: Make sure the `slotname` in `meritous-ap.json` matches the name provided in your Meritous YAML file.
- `SlotAlreadyTaken`: Make sure Meritous Gaiden is not already running and connected to the server.
- `IncompatibleVersion`: Make sure Meritous Gaiden has been updated to the latest version.
- `InvalidPassword`: Make sure the `password` in `meritous-ap.json` matches the password for your game. If there is no password, either set this to `null` (no quotes) or omit/remove it completely.
- `InvalidItemsHandling`: This is a bug and shouldn't happen if you downloaded a precompiled copy of the game. If you downloaded a precompiled copy, please let KewlioMZX know over GitHub or the AP Discord.

View File

@@ -1,27 +1,38 @@
# Setup Guide for Ocarina of time Archipelago
# Setup Guide for Ocarina of Time Archipelago
## Important
As we are using Z5Client and BizHawk, this guide is only applicable to Windows.
As we are using Bizhawk, this guide is only applicable to Windows and Linux systems.
## Required Software
- BizHawk and Z5Client from: [Z5Client Releases Page](https://github.com/ArchipelagoMW/Z5Client/releases)
- We recommend download Z5Client-setup as it makes some steps automatic.
- Bizhawk: [Bizhawk Releases from TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory)
- Version 2.3.1 and later are supported. Version 2.7 is recommended for stability.
- Detailed installation instructions for Bizhawk can be found at the above link.
- Windows users must run the prereq installer first, which can also be found at the above link.
- The built-in Archipelago client, which can be installed [here](https://github.com/ArchipelagoMW/Archipelago/releases)
(select `Ocarina of Time Client` during installation).
- An Ocarina of Time v1.0 ROM.
## Install Emulator and client
## Configuring Bizhawk
Download getBizhawk.ps1 from previous link. Place it on the folder where you want your emulator to be installed, right
click on it and select "Run with PowerShell". This will download all the needed dependencies used by the emulator. This
can take a while.
Once Bizhawk has been installed, open Bizhawk and change the following settings:
It is strongly recommended to associate N64 rom extension (\*.n64) to the BizHawk we've just installed. To do so, we
simply have to search any N64 rom we happened to own, right click and select "Open with...", we unfold the list that
appears and select the bottom option "Look for another application", we browse to BizHawk folder and select EmuHawk.exe
- Go to Config > Customize. Switch to the Advanced tab, then switch the Lua Core from "NLua+KopiLua" to
"Lua+LuaInterface". This is required for the Lua script to function correctly.
- Under Config > Customize > Advanced, make sure the box for AutoSaveRAM is checked, and click the 5s button.
This reduces the possibility of losing save data in emulator crashes.
- Under Config > Customize, check the "Run in background" and "Accept background input" boxes. This will allow you to
continue playing in the background, even if another window is selected.
- Under Config > Hotkeys, many hotkeys are listed, with many bound to common keys on the keyboard. You will likely want
to disable most of these, which you can do quickly using `Esc`.
- If playing with a controller, when you bind controls, disable "P1 A Up", "P1 A Down", "P1 A Left", and "P1 A Right"
as these interfere with aiming if bound. Set directional input using the Analog tab instead.
Place the ootMulti.lua file from the previous link inside the "lua" folder from the just installed emulator.
Install the Z5Client using its setup.
It is strongly recommended to associate N64 rom extensions (\*.n64, \*.z64) to the Bizhawk we've just installed.
To do so, we simply have to search any N64 rom we happened to own, right click and select "Open with...", unfold
the list that appears and select the bottom option "Look for another application", then browse to the Bizhawk folder
and select EmuHawk.exe.
## Configuring your YAML file
@@ -33,7 +44,7 @@ an experience customized for their taste, and different players in the same mult
### 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
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 from
the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases) and look for the sample file in
the "Players" folder.
@@ -382,22 +393,20 @@ Ocarina of Time:
When you join a multiworld game, you will be asked to provide your YAML file to whoever is hosting. Once that is done,
the host will provide you with either a link to download your data file, or with a zip file containing everyone's data
files. Your data file should have a `.z5ap` extension.
files. Your data file should have a `.apz5` extension.
Double-click on your `.z5ap` file to start Z5Client and start the ROM patch process. Once the process is finished (this
can take a while), the emulator will be started automatically (If we associated the extension to the emulator as
recommended)
Double-click on your `.apz5` file to start your client and start the ROM patch process. Once the process is finished
(this can take a while), the client and the emulator will be started automatically (if you associated the extension
to the emulator as recommended).
### Connect to the Multiserver
Once both the Z5Client and the emulator are started we must connect them. Within the emulator we click on the "Tools"
menu and select "Lua console". In the new window click on the folder icon and look for the ootMulti.lua file. Once the
file is loaded it will connect automatically to Z5Client.
Once both the client and the emulator are started, you must connect them. Within the emulator click on the "Tools"
menu and select "Lua Console". Click the folder button or press Ctrl+O to open a Lua script.
Note: We strongly advise you don't open any emulator menu while it and Z5client are connected, as the script will halt
and disconnects can happen. If you get disconnected just double-click on the script again.
Navigate to your Archipelago install folder and open `data/lua/OOT/oot_connector.lua`.
To connect the client to the multiserver simply put `<address>:<port>` on the textfield on top and press enter (if the
server uses password, type in the bottom textfield `/connect <address>:<port> [password]`)
Now you are ready to start your adventure in Hyrule.
Now you are ready to start your adventure in Hyrule.

View File

@@ -0,0 +1,163 @@
# SMZ3 Setup Guide
## Required Software
- One of the client programs:
- [SNIClient](https://github.com/ArchipelagoMW/Archipelago/releases), included with the main
Archipelago install. Make sure to check the box for `SNI Client - Super Metroid Patch Setup` and
`SNI Client - A Link to the Past Patch Setup`
- Hardware or software capable of loading and playing SNES ROM files
- An emulator capable of connecting to SNI such as:
- snes9x Multitroid
from: [snes9x Multitroid Download](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
- BizHawk from: [BizHawk Website](http://tasvideos.org/BizHawk.html)
- RetroArch 1.10.3 or newer from: [RetroArch BuildBot Website](https://buildbot.libretro.com/) - nightly builds
are required until 1.10.3 is released. Or,
- An SD2SNES, FXPak Pro ([FXPak Pro Store Page](https://krikzz.com/store/home/54-fxpak-pro.html)), or other
compatible hardware
- Your legally obtained Super Metroid ROM file, probably named `Super Metroid (Japan, USA).sfc` and
Your Japanese Zelda3 v1.0 ROM file, probably named `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
## Installation Procedures
### Windows Setup
1. During the installation of Archipelago, you will have been asked to install the SNI Client. If you did not do this,
or you are on an older version, you may run the installer again to install the SNI Client.
2. During setup, you will be asked to locate your base ROM files. This is your Super Metroid and Zelda3 ROM files.
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.
## Create a Config (.yaml) File
### What is a config file and why do I need one?
See the guide on setting up a basic YAML at the Archipelago setup
guide: [Basic Multiworld Setup Guide](/tutorial/archipelago/setup/en)
### Where do I get a config file?
The Player Settings page on the website allows you to configure your personal settings and export a config file from
them. Player settings page: [SMZ3 Player Settings Page](/games/SMZ3/player-settings)
### 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 page. YAML
validator page: [YAML Validation page](/mysterycheck)
## Generating a Single-Player Game
1. Navigate to the Player Settings page, configure your options, and click the "Generate Game" button.
- Player Settings page: [SMZ3 Player Settings Page](/games/SMZ3/player-settings)
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 SMZ3 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 `.apsmz` 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 connector lua file included with your client
- SuperNintendoClient users should download `sniConnector.lua` from the client download page
- SNIClient users should look in their Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
emulator is 64-bit or 32-bit.
##### 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 included with your client
- SuperNintendoClient users should download `sniConnector.lua` from the client download page
- SNIClient users should look in their Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
emulator is 64-bit or 32-bit. Please note the most recent versions of BizHawk are 64-bit only.
##### RetroArch 1.10.3 or newer
You only have to do these steps once. Note, RetroArch 1.9.x will not work as it is older than 1.10.3.
1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355.
![Screenshot of Network Commands setting](/static/assets/tutorial/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)".
When loading a ROM, be sure to select a **bsnes-mercury** core. These are the only cores that allow external tools to
read ROM data.
#### 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 on the SD2SNES releases page. SD2SNES
releases page: [SD2SNES Releases Page](https://github.com/RedGuyyyy/sd2snes/releases)
Other hardware may find helpful information on the usb2snes platforms
page: [usb2snes Supported Platforms 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. 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 Generate page above.
- Generate page: [WebHost Seed Generation Page](/generate)
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

@@ -9,6 +9,7 @@
- snes9x-rr from: [snes9x-rr Releases Page](https://github.com/gocha/snes9x-rr/releases)
- BizHawk from: [BizHawk Website](http://tasvideos.org/BizHawk.html)
- bsnes-plus-nwa from: [bsnes-plus GitHub](https://github.com/black-sliver/bsnes-plus)
- RetroArch from: [RetroArch Website](https://retroarch.com?page=platforms). Or,
- Or SD2SNES, FXPak Pro ([FXPak Pro Store Page](https://krikzz.com/store/home/54-fxpak-pro.html)), or other
compatible hardware.
- Your legally obtained Secret of Evermore US ROM file, probably named `Secret of Evermore (USA).sfc`
@@ -92,6 +93,21 @@ you may be prompted to allow it to communicate through the Windows Firewall.
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.
##### RetroArch
You only have to do these steps once.
1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355.
![Screenshot of Network Commands setting](/static/assets/tutorial/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)".
When loading a ROM, be sure to select a **bsnes-mercury** core. These are the only cores that allow external tools to
read ROM data.
#### With hardware
This guide assumes you have downloaded the correct firmware for your device. If you have not done so already, please do

View File

@@ -3,7 +3,7 @@
## Required Software
- Super Mario 64 US Rom (Japanese may work also. Europe and Shindou not supported)
- Either of [sm64pclauncher](https://github.com/N00byKing/sm64pclauncher/actions/workflows/ci.yml?query=branch%3Aarchipelago+event%3Apush) or
- Either of [sm64pclauncher](https://github.com/N00byKing/sm64pclauncher/releases) or
- Cloning and building [sm64ex](https://github.com/N00byKing/sm64ex) manually.
NOTE: The above linked sm64pclauncher is a special version designed to work with the Archipelago build of sm64ex.
@@ -48,19 +48,26 @@ The Compiled binary will be in `build/REGION_pc/`.
# Joining a MultiWorld Game
To join, set the following launch options: `--sm64ap_name YourName --sm64ap_ip ServerIP:Port`.
Optionally, add `--sm64ap_passwd "YourPassword"` if the room you are using requires a password. All parameters without quotation marks.
Optionally, add `--sm64ap_passwd "YourPassword"` if the room you are using requires a password.
The Name in this case is the one specified in your generated .yaml file.
In case you are using the Archipelago Website, the IP should be `archipelago.gg` and Port `38281`.
In case you are using the Archipelago Website, the IP should be `archipelago.gg`.
If everything worked out, you will see a textbox informing you the connection has been established after the story intro.
# Playing offline
To play offline, first generate a seed on the game's settings page.
Create a room and download the `.apsm64ex` file, and start the game with the `--sm64ap_file FileName` launch argument.
## Installation Troubleshooting
Start the game from the command line to view helpful messages regarding SM64EX.
### Game doesn't start after compiling
Most likely you forgot to set the launch options. `--sm64ap_name YourName` and `--sm64ap_ip ServerIP:Port` are required for startup.
Most likely you forgot to set the launch options. `--sm64ap_name YourName` and `--sm64ap_ip ServerIP:Port` are required for startup for Multiworlds, and
`--sm64ap_file FileName` is required for (offline) singleplayer.
If your Name or Password have spaces in them, surround them in quotes.
## Game Troubleshooting

View File

@@ -12,6 +12,7 @@
- snes9x Multitroid
from: [snes9x Multitroid Download](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz),
- BizHawk from: [BizHawk Website](http://tasvideos.org/BizHawk.html)
- RetroArch 1.10.1 or newer from: [RetroArch Website](https://retroarch.com?page=platforms). Or,
- An SD2SNES, FXPak Pro ([FXPak Pro Store Page](https://krikzz.com/store/home/54-fxpak-pro.html)), or other
compatible hardware
- Your legally obtained Super Metroid ROM file, probably named `Super Metroid (Japan, USA).sfc`
@@ -107,6 +108,21 @@ first time launching, you may be prompted to allow it to communicate through the
- SNIClient users should look in their Archipelago folder for `/SNI/lua/x64` or `/SNI/lua/x86` depending on if the
emulator is 64-bit or 32-bit. Please note the most recent versions of BizHawk are 64-bit only.
##### RetroArch 1.10.1 or newer
You only have to do these steps once. Note, RetroArch 1.9.x will not work as it is older than 1.10.1.
1. Enter the RetroArch main menu screen.
2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON.
3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default
Network Command Port at 55355.
![Screenshot of Network Commands setting](/static/assets/tutorial/retroarch-network-commands-en.png)
4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - SNES / SFC (bsnes-mercury
Performance)".
When loading a ROM, be sure to select a **bsnes-mercury** core. These are the only cores that allow external tools to
read ROM data.
#### With hardware
This guide assumes you have downloaded the correct firmware for your device. If you have not done so already, please do

View File

@@ -3,31 +3,37 @@
## Required Software
- VVVVVV (Bought from the [Steam Store](https://store.steampowered.com/app/70300/VVVVVV/) or [GOG Store](https://www.gog.com/game/vvvvvv) Page, NOT Make and Play Edition!)
- [V6AP](https://github.com/N00byKing/VVVVVV/actions/workflows/ci.yml?query=branch%3Aarchipelago)
- [V6AP](https://github.com/N00byKing/VVVVVV/releases)
## Installation and Game Start Procedures
1. Install VVVVVV through either Steam or GOG
2. Go to the page linked for V6AP, and press on the topmost entry
3. Scroll down, and download the zip file corresponding to your platform (NOTE: Linux currently does not build automatically. Linux users will have to compile manually for now. Mac is unsupported, but may work if [APCpp](https://github.com/N00byKing/APCpp) is compiled and supplied)
4. Unpack the zip file where you have VVVVVV installed.
2. Go to the page linked for V6AP, and download the latest release
3. Unpack the zip file where you have VVVVVV installed.
# Joining a MultiWorld Game
To join, set the following launch options: `-v6ap_name "YourName" -v6ap_ip "ServerIP"`.
To join, set the following launch options: `-v6ap_name YourName -v6ap_ip ServerIP:Port`.
Optionally, add `-v6ap_passwd "YourPassword"` if the room you are using requires a password. All parameters without quotation marks.
The Name in this case is the one specified in your generated .yaml file.
In case you are using the Archipelago Website, the IP should be `archipelago.gg`.
If everything worked out, you will see a textbox informing you the connection has been established after the story intro.
# Playing offline
To play offline, first generate a seed on the game's settings page.
Create a room and download the `.apv6` file, and start the game with the `-v6ap_file FileName` launch argument.
## Installation Troubleshooting
Start the game from the command line to view helpful messages regarding V6AP. These will look something like "V6AP: Message"
### Game no longer starts after copying the .exe
Most likely you forgot to set the launch options. `-v6ap_name "YourName"` and `-v6ap_ip "ServerIP"` are required for startup.
Most likely you forgot to set the launch options. `-v6ap_name YourName` and `-v6ap_ip ServerIP:Port` are required for startup for Multiworlds, and
`-v6ap_file FileName` is required for (offline) singleplayer.
If your Name or Password have spaces in them, surround them in quotes.
## Game Troubleshooting

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -8,8 +8,8 @@
"files": [
{
"language": "English",
"filename": "archipelago/setup_en.md",
"link": "archipelago/setup/en",
"filename": "Archipelago/setup_en.md",
"link": "Archipelago/setup/en",
"authors": [
"alwaysintreble"
]
@@ -22,8 +22,8 @@
"files": [
{
"language": "English",
"filename": "archipelago/using_website.md",
"link": "archipelago/using_website/en",
"filename": "Archipelago/using_website.md",
"link": "Archipelago/using_website/en",
"authors": [
"alwaysintreble"
]
@@ -36,8 +36,8 @@
"files": [
{
"language": "English",
"filename": "archipelago/commands_en.md",
"link": "archipelago/commands/en",
"filename": "Archipelago/commands_en.md",
"link": "Archipelago/commands/en",
"authors": [
"jat2980",
"Ijwu"
@@ -51,8 +51,8 @@
"files": [
{
"language": "English",
"filename": "archipelago/advanced_settings_en.md",
"link": "archipelago/advanced_settings/en",
"filename": "Archipelago/advanced_settings_en.md",
"link": "Archipelago/advanced_settings/en",
"authors": [
"alwaysintreble",
"Alchav"
@@ -66,8 +66,8 @@
"files": [
{
"language": "English",
"filename": "archipelago/triggers_en.md",
"link": "archipelago/triggers/en",
"filename": "Archipelago/triggers_en.md",
"link": "Archipelago/triggers/en",
"authors": [
"alwaysintreble"
]
@@ -80,8 +80,8 @@
"files": [
{
"language": "English",
"filename": "archipelago/plando_en.md",
"link": "archipelago/plando/en",
"filename": "Archipelago/plando_en.md",
"link": "Archipelago/plando/en",
"authors": [
"alwaysintreble",
"Alchav"
@@ -100,32 +100,32 @@
"files": [
{
"language": "English",
"filename": "zelda3/multiworld_en.md",
"link": "zelda3/multiworld/en",
"filename": "A Link to the Past/multiworld_en.md",
"link": "A Link to the Past/multiworld/en",
"authors": [
"Farrak Kilhn"
]
},
{
"language": "Deutsch",
"filename": "zelda3/multiworld_de.md",
"link": "zelda3/multiworld/de",
"filename": "A Link to the Past/multiworld_de.md",
"link": "A Link to the Past/multiworld/de",
"authors": [
"Fischfilet"
]
},
{
"language": "Español",
"filename": "zelda3/multiworld_es.md",
"link": "zelda3/multiworld/es",
"filename": "A Link to the Past/multiworld_es.md",
"link": "A Link to the Past/multiworld/es",
"authors": [
"Edos"
]
},
{
"language": "Français",
"filename": "zelda3/multiworld_fr.md",
"link": "zelda3/multiworld/fr",
"filename": "A Link to the Past/multiworld_fr.md",
"link": "A Link to the Past/multiworld/fr",
"authors": [
"Coxla"
]
@@ -138,24 +138,24 @@
"files": [
{
"language": "English",
"filename": "zelda3/msu1_en.md",
"link": "zelda3/msu1/en",
"filename": "A Link to the Past/msu1_en.md",
"link": "A Link to the Past/msu1/en",
"authors": [
"Farrak Kilhn"
]
},
{
"language": "Español",
"filename": "zelda3/msu1_es.md",
"link": "zelda3/msu1/es",
"filename": "A Link to the Past/msu1_es.md",
"link": "A Link to the Past/msu1/es",
"authors": [
"Edos"
]
},
{
"language": "Français",
"filename": "msu1_fr.md",
"link": "zelda3/msu1/fr",
"filename": "A Link to the Past/msu1_fr.md",
"link": "A Link to the Past/msu1/fr",
"authors": [
"Coxla"
]
@@ -168,8 +168,8 @@
"files": [
{
"language": "English",
"filename": "zelda3/plando_en.md",
"link": "zelda3/plando/en",
"filename": "A Link to the Past/plando_en.md",
"link": "A Link to the Past/plando/en",
"authors": [
"Berserker"
]
@@ -187,16 +187,16 @@
"files": [
{
"language": "English",
"filename": "zelda5/setup_en.md",
"link": "zelda5/setup/en",
"filename": "Ocarina of Time/setup_en.md",
"link": "Ocarina of Time/setup/en",
"authors": [
"Edos"
]
},
{
"language": "Spanish",
"filename": "zelda5/setup_es.md",
"link": "zelda5/setup/es",
"filename": "Ocarina of Time/setup_es.md",
"link": "Ocarina of Time/setup/es",
"authors": [
"Edos"
]
@@ -214,8 +214,8 @@
"files": [
{
"language": "English",
"filename": "factorio/setup_en.md",
"link": "factorio/setup/en",
"filename": "Factorio/setup_en.md",
"link": "Factorio/setup/en",
"authors": [
"Berserker",
"Farrak Kilhn"
@@ -225,6 +225,25 @@
}
]
},
{
"gameTitle": "Meritous",
"tutorials": [
{
"name": "Meritous Setup Tutorial",
"description": "A guide to setting up the Archipelago Meritous software on your computer.",
"files": [
{
"language": "English",
"filename": "Meritous/setup_en.md",
"link": "Meritous/setup/en",
"authors": [
"KewlioMZX"
]
}
]
}
]
},
{
"gameTitle": "Minecraft",
"tutorials": [
@@ -234,24 +253,24 @@
"files": [
{
"language": "English",
"filename": "minecraft/minecraft_en.md",
"link": "minecraft/minecraft/en",
"filename": "Minecraft/minecraft_en.md",
"link": "Minecraft/minecraft/en",
"authors": [
"Kono Tyran"
]
},
{
"language": "Spanish",
"filename": "minecraft/minecraft_es.md",
"link": "minecraft/minecraft/es",
"filename": "Minecraft/minecraft_es.md",
"link": "Minecraft/minecraft/es",
"authors": [
"Edos"
]
},
{
"language": "Swedish",
"filename": "minecraft/minecraft_sv.md",
"link": "minecraft/minecraft/sv",
"filename": "Minecraft/minecraft_sv.md",
"link": "Minecraft/minecraft/sv",
"authors": [
"Albinum"
]
@@ -269,8 +288,8 @@
"files": [
{
"language": "English",
"filename": "ror2/setup_en.md",
"link": "ror2/setup/en",
"filename": "Risk of Rain 2/setup_en.md",
"link": "Risk of Rain 2/setup/en",
"authors": [
"Ijwu"
]
@@ -288,8 +307,8 @@
"files": [
{
"language": "English",
"filename": "raft/setup_en.md",
"link": "raft/setup/en",
"filename": "Raft/setup_en.md",
"link": "Raft/setup/en",
"authors": [
"SunnyBat",
"Awareqwx"
@@ -308,16 +327,16 @@
"files": [
{
"language": "English",
"filename": "timespinner/setup_en.md",
"link": "timespinner/setup/en",
"filename": "Timespinner/setup_en.md",
"link": "Timespinner/setup/en",
"authors": [
"Jarno"
]
},
{
"language": "German",
"filename": "timespinner/setup_de.md",
"link": "timespinner/setup/de",
"filename": "Timespinner/setup_de.md",
"link": "Timespinner/setup/de",
"authors": [
"Grrmo",
"Fynxes",
@@ -328,6 +347,25 @@
}
]
},
{
"gameTitle": "SMZ3",
"tutorials": [
{
"name": "Multiworld Setup Guide",
"description": "A guide to setting up the Archipelago Super Metroid and A Link to the Past Crossover randomizer on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "SMZ3/multiworld_en.md",
"link": "SMZ3/multiworld/en",
"authors": [
"lordlou"
]
}
]
}
]
},
{
"gameTitle": "Subnautica",
"tutorials": [
@@ -356,8 +394,8 @@
"files": [
{
"language": "English",
"filename": "super-metroid/multiworld_en.md",
"link": "super-metroid/multiworld/en",
"filename": "Super Metroid/multiworld_en.md",
"link": "Super Metroid/multiworld/en",
"authors": [
"Farrak Kilhn"
]
@@ -375,8 +413,8 @@
"files": [
{
"language": "English",
"filename": "secret-of-evermore/multiworld_en.md",
"link": "secret-of-evermore/multiworld/en",
"filename": "Secret of Evermore/multiworld_en.md",
"link": "Secret of Evermore/multiworld/en",
"authors": [
"Black Sliver"
]
@@ -394,8 +432,8 @@
"files": [
{
"language": "English",
"filename": "ff1/multiworld_en.md",
"link": "ff1/multiworld/en",
"filename": "Final Fantasy/multiworld_en.md",
"link": "Final Fantasy/multiworld/en",
"authors": [
"jat2980"
]
@@ -413,8 +451,8 @@
"files": [
{
"language": "English",
"filename": "rogue-legacy/rogue-legacy_en.md",
"link": "rogue-legacy/rogue-legacy/en",
"filename": "Rogue Legacy/rogue-legacy_en.md",
"link": "Rogue Legacy/rogue-legacy/en",
"authors": [
"Phar"
]
@@ -432,8 +470,8 @@
"files": [
{
"language": "English",
"filename": "slay-the-spire/slay-the-spire_en.md",
"link": "slay-the-spire/slay-the-spire/en",
"filename": "Slay the Spire/slay-the-spire_en.md",
"link": "Slay the Spire/slay-the-spire/en",
"authors": [
"Phar"
]
@@ -451,8 +489,8 @@
"files": [
{
"language": "English",
"filename": "sm64ex/setup_en.md",
"link": "sm64ex/setup/en",
"filename": "Super Mario 64/setup_en.md",
"link": "Super Mario 64/setup/en",
"authors": [
"N00byKing"
]
@@ -470,8 +508,8 @@
"files": [
{
"language": "English",
"filename": "v6/setup_en.md",
"link": "v6/setup/en",
"filename": "VVVVVV/setup_en.md",
"link": "VVVVVV/setup/en",
"authors": [
"N00byKing"
]
@@ -479,5 +517,62 @@
]
}
]
},
{
"gameTitle": "ChecksFinder",
"tutorials": [
{
"name": "Multiworld Setup Tutorial",
"description": "A guide to setting up the Archipelago ChecksFinder software on your computer. This guide covers single-player, multiworld, and related software.",
"files": [
{
"language": "English",
"filename": "ChecksFinder/checksfinder_en.md",
"link": "ChecksFinder/checksfinder/en",
"authors": [
"Mewlif"
]
}
]
}
]
},
{
"gameTitle": "ArchipIDLE",
"tutorials": [
{
"name": "Setup Guide",
"description": "A guide to playing ArchipIDLE",
"files": [
{
"language": "English",
"filename": "ArchipIDLE/guide_en.md",
"link": "ArchipIDLE/guide/en",
"authors": [
"Farrak Kilhn"
]
}
]
}
]
},
{
"gameTitle": "Hollow Knight",
"tutorials": [
{
"name": "Mod Setup and Use Guide",
"description": "A guide to playing Hollow Knight with Archipelago.",
"files": [
{
"language": "English",
"filename": "Hollow Knight/setup_en.md",
"link": "Hollow Knight/setup/en",
"authors": [
"ijwu"
]
}
]
}
]
}
]

View File

@@ -66,6 +66,6 @@ window.addEventListener('load', () => {
console.error(error);
}
};
ajax.open('GET', `${window.location.origin}/static/assets/tutorial/tutorials.json`, true);
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
ajax.send();
});

View File

@@ -3,16 +3,16 @@ window.addEventListener('load', () => {
let settingHash = localStorage.getItem('weighted-settings-hash');
if (!settingHash) {
// If no hash data has been set before, set it now
localStorage.setItem('weighted-settings-hash', md5(results));
localStorage.setItem('weighted-settings-hash', md5(JSON.stringify(results)));
localStorage.removeItem('weighted-settings');
settingHash = md5(results);
settingHash = md5(JSON.stringify(results));
}
if (settingHash !== md5(results)) {
if (settingHash !== md5(JSON.stringify(results))) {
const userMessage = document.getElementById('user-message');
userMessage.innerText = "Your settings are out of date! Click here to update them! Be aware this will reset " +
"them all to default.";
userMessage.style.display = "block";
userMessage.classList.add('visible');
userMessage.addEventListener('click', resetSettings);
}
@@ -45,7 +45,7 @@ const resetSettings = () => {
const fetchSettingData = () => new Promise((resolve, reject) => {
fetch(new Request(`${window.location.origin}/static/generated/weighted-settings.json`)).then((response) => {
try{ resolve(response.json()); }
try{ response.json().then((jsonObj) => resolve(jsonObj)); }
catch(error){ reject(error); }
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

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