Compare commits

..

124 Commits
0.3.4 ... 0.3.5

Author SHA1 Message Date
TheBigSalarius
156e9e0e43 FF1: Throw exception for Noverworld 2022-09-12 03:48:07 +02:00
Fabian Dill
ef46979bd8 SC2: fix client freezing on exit while SC2 is running (#1011) 2022-09-12 02:08:33 +02:00
Fabian Dill
b2aa251c47 SC2: fix bitflag overflow when multiple instances of an Item are acquired (#1008) 2022-09-12 01:51:25 +02:00
Fabian Dill
e204a0fce6 Subnautica: fix missed item and correct other item pool counts to fit it 2022-09-12 01:44:10 +02:00
TheBigSalarius
bb386d3bd7 FF1: fix FF1Client messaging and scoped lua messaging with printjson
Corrects the issue causing the client and lua messaging not displaying properly after the printjson changes
2022-09-12 01:19:51 +02:00
Fabian Dill
88a225764a FF1: fix printjson 2022-09-12 01:19:51 +02:00
espeon65536
99d2caa57d ALttP: remove link_palettes option (#1004)
* ALttP: remove link_palettes option
It doesn't work anyway so better to have it not visible.
2022-09-07 20:16:32 +02:00
lordlou
ade82e3d60 SM: varia tracker fix (#1006) 2022-09-06 19:56:23 +02:00
Fabian Dill
7c04e7e06f MultiServer: save goal completion flag 2022-09-05 22:11:26 +02:00
Fabian Dill
baf51e5959 SC2: fix Launching Mission: text pulling the unshuffled ID. (#1001)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-09-05 21:09:03 +02:00
toasterparty
8aad75ed23 Tests: Check for Holes in the Item Pool (#992)
* test for holes in the item pool

* Update test/general/TestItems.py

Co-authored-by: alwaysintreble <mmmcheese158@gmail.com>

* Update test/general/TestItems.py

Co-authored-by: alwaysintreble <mmmcheese158@gmail.com>

* Update test/general/TestItems.py

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

* Update test/general/TestItems.py

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>

Co-authored-by: alwaysintreble <mmmcheese158@gmail.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-09-05 10:02:40 +02:00
black-sliver
1792b66b3a CI: fix automated builds, update SNI and Enemizer
* Launcher.py always running ModuleUpdate breaks setup.py build --yes
* Use env variables in github workflows
* Update SNI and Enemizer versions in github workflows
* Minor cleanup in workflows
* Silence pycharm warning in Launcher.py
2022-09-05 09:23:08 +02:00
wildham0
5e8ac74b2a FFR: fix NoOverworld mode (#999)
* Add Sigil/Mark to item list
2022-09-05 09:21:00 +02:00
PoryGone
2acc129381 SA2B: Fix typo in doc string (#997) 2022-09-04 14:45:45 +02:00
lordlou
0cbb3c2839 SMZ3: data package fix (#996) 2022-09-03 23:52:09 +02:00
espeon65536
539d2e80f1 OoT: prevent glitched + mq dungeons
this combo is not allowed on main ootr, so we won't have it here either
2022-09-03 21:26:31 +02:00
lordlou
f9e28004a0 SMZ3: item link gt fill fix (#995) 2022-09-03 21:25:55 +02:00
Sunny Bat
b7cfcc9272 New features and fixes for Raft (#984)
* Add DeathLink, small logic changes

* Fix generation, rules, use bool for slotData

* Add more island options

* Update Shovel-related logic

* Update docs
2022-09-03 21:25:04 +02:00
Fabian Dill
4b6d46fd74 Core: update modules 2022-09-03 09:55:47 +02:00
Alchav
b45d8bf221 Patch: Save patch file extension in archipelago.json (#968) 2022-09-02 23:37:37 +02:00
Fabian Dill
f7d107fc0c Subnautica: add some more missed aggressive creatures 2022-09-02 09:06:33 +02:00
alwaysintreble
b14d694e1e templates: fix bug report label 2022-09-01 22:33:22 +02:00
skrawpie
8d2333006a Minecraft: Added shuffled recipe list to en_Minecraft.md (#980)
Co-authored-by: KonoTyran <Kono.Tyran@gmail.com>
2022-09-01 21:26:04 +02:00
Fabian Dill
e413619c26 Tests: verify that a world doesn't use the same ID multiple times (#985) 2022-09-01 21:25:06 +02:00
Yussur Mustafa Oraji
03f66a922d sm64ex: Fix a Location (#979) 2022-09-01 21:21:53 +02:00
black-sliver
b115bdafe7 CI/Doc: Use pytest subtests (#986)
* CI/Doc: use pytest-subtests

* CI: clean up pip installs a bit

* make lint and unittests install the same stuff
* make sure to install wheel, which is a recommended (not required) dependency for everything pip
2022-09-01 09:30:28 +02:00
lordlou
0444fdc379 SM: wasteland ap (#983) 2022-09-01 02:20:30 +02:00
Fabian Dill
c617bba959 SC2: client revamp (#967)
SC2 client now relies almost entirely on the map file and server for the locations and just facilitates them, should make it significantly more resilient to objectives being added or removed


* SC2: fix client crash on printjson messages with more [ than ]

* SC2: move text to queue, that actually clears memory

* SC2: Announce which mission is being loaded


Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-08-31 20:55:15 +02:00
lordlou
8da1cfeeb7 SM: remove events from data package (#973) 2022-08-31 06:14:17 +02:00
black-sliver
fcfc2c2e10 WebHost: fix local_path on python 3.8 (#981)
* WebHost: fix local_path on python 3.8

`__file__` is relative in 3.8, so `os.path.dirname(__file__)` ends up being an empty string breaking calls to `local_path()` (without arguments)

* WebHost: add comment to local_path override
2022-08-31 00:10:18 +02:00
espeon65536
a753905ee4 OoT bug fixes (#955)
* OoT: fix shop patching crash due to Item changes

* OoT: more informative failure in triforce piece replacement

* OoT: in triforce hunt, remove ganon BK from pool and lock the door

* OoT: no longer store trap information on the item
2022-08-30 20:54:40 +02:00
strotlog
2a7babce68 SM+SMZ3: don't abandon checks that happen while disconnected from AP (#946) 2022-08-30 17:16:21 +02:00
Fabian Dill
60d1a27079 Subnautica: revamp aggressive creature scans (#966)
* add forgotten aggressive creatures
* fix logic requirements
* added option to opt out of aggressive creature scans
2022-08-30 17:14:34 +02:00
Fabian Dill
4a2a184db1 Core: remove game-specific arguments from Generate (#971)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-08-30 17:12:33 +02:00
alwaysintreble
45fb735320 Clients: allow games without datapackage (#978) 2022-08-30 00:16:13 +02:00
PoryGone
3eb9e7050f DKC3: Fix Wrinkly Softlock (#963) 2022-08-29 20:04:02 +02:00
CaitSith2
26aed9351e Factorio: Fix a bug with single craft free samples. (#974) 2022-08-29 05:58:26 +02:00
Fabian Dill
b1ffbc49c9 LttPAdjuster: fix GUI for invalid sprite files (#885)
* LttPAdjuster: ignore invalid sprite files

* LttPAdjuster: ignore .gitignore in sprites

* LttPAdjuster: log and show message for invalid sprites

* Alttp: set sprite.valid to False for bad zspr and apsprite ...

... when throwing exceptions

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-08-28 18:30:19 +02:00
Fabian Dill
6d6111de2a Launcher: add ModuleUpdate 2022-08-27 11:13:33 +02:00
Fabian Dill
cc8ce32c61 Options: fix corner case where Toggle.value and Toggle.__int__ would be bool
Which lead to a connect failure in Raft
2022-08-27 11:12:28 +02:00
strotlog
4c94bb0ad5 WebHost: sort game list case-insensitively again 2022-08-26 18:20:37 +02:00
strotlog
af19180ff0 SM: Fix rolling saves, add SRAM features
- fix receiving items in an old save (issue #855) by moving receive queue's read pointer to a per-saveslot value
- clear SRAM over $70:2000, and invalidate save data, when booting a new seed number for the first time
- copy important ROM data to SRAM so future clients don't have to read ROM
2022-08-26 10:32:22 +02:00
CaitSith2
a175aa93e7 Factorio: Detect if more than one AP factorio mod is loaded. (#964) 2022-08-26 10:31:30 +02:00
Zach Parks
a78863fde1 Docs: Update community supported libraries in api doc (#788)
* Docs: Update client supported libraries in api doc

* left align table column

* Update table of languages to include Haxe lib and remarks

* Reformat table

* Changed verbiage on SNI remark
2022-08-26 02:12:37 -05:00
Fabian Dill
0d6cbd9093 Core: convert item name groups to frozenset
Some worlds define them in lists, this speeds up lookup via state.has_group() or similar
2022-08-24 00:19:27 +02:00
Magnemania
1aaf89ff2c SC2: Switched mission item group to a list comprehension to fix missile shuffle errors (#959) 2022-08-23 23:20:39 +02:00
Fabian Dill
295ea97544 Subnautica: increment client version 2022-08-23 23:19:46 +02:00
Fabian Dill
33103b209d WebHost: fix error on save 2022-08-23 23:19:19 +02:00
Fabian Dill
fab12dca0b SC2: add anti air to Devil's Playground Victory
People seem to be on the mission long enough to get attacked by Mutalisks, so Victory should require anti air.
Optional Objectives are doable quite comfortably before Mutalisks show up, allowing the anti-air to be on them for later in the mission.
2022-08-23 23:06:58 +02:00
Fabian Dill
c390801c4c Test: verify file webhost file creations work to some degree (#953)
WebHost: fix some file creation paths
2022-08-23 01:07:17 +02:00
Fabian Dill
e548abd332 Subnautica: use correct option parent class (#954)
* Subnautica: use correct option parent class

* Update Options.py
2022-08-22 19:02:29 -04:00
Jarno
0a5b24be2b [Core] Phase out Print packets and added Countdown type to print json (#812)
* [Core] Added Countdown type to print json to distinct the count down message from other types

* Added backward compatibility check

* Fixed review comments

* Updated header category

* Apply suggestions from code review

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

* Completely phased out Print in favor of PrintJson

* Updated docs to warn about phasing out of Print

* Removed faulty import

Co-authored-by: Hussein Farran <hmfarran@gmail.com>
2022-08-23 01:02:10 +02:00
Chris Wilson
7f41cafffc Explaining the "Style Lockdown" (#940)
* First pass at a contribution guide for the website. Suggestions are welcome.

* Attempt to make the WebHost change guide describe the intent of the style restrictions more accurately.

* Try to improve the explanation of the intention behind the style restrictions.
2022-08-22 19:01:21 -04:00
alwaysintreble
d66f981be6 Github: templates and new user interface (#870)
* move some docs out of readme and link with the headers

* PR template

* bug report template

* task and feature request templates

* md cleanup

* forgot the template

* make expected results separate section

* move pr template to .github. remove assignment field on tasks

* add headers to pr template

* Requested changes

* suggested changes from @black-sliver and @SoldierofOrder

* Update docs/code_of_conduct.md

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* Update docs/contributing.md

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* Update docs/contributing.md

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

Co-authored-by: Hussein Farran <hmfarran@gmail.com>
Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>
2022-08-23 00:39:55 +02:00
alwaysintreble
b66a265726 Docs: Make webworld attribute descriptions docstrings instead of comments for nice IDE things (#929) 2022-08-22 23:50:16 +02:00
Fabian Dill
c695f91198 Subnautica: add Options to Creature Scans (#950) 2022-08-22 23:35:41 +02:00
CaitSith2
11cbc0b40b Factorio: Make the energy bridge a different color. (#952) 2022-08-22 23:30:42 +02:00
N00byKing
87d91aeef3 sm64ex: Option for 1Up Block Rando 2022-08-22 17:52:56 +02:00
Fabian Dill
6a6dfcbaff Core: add some types to generic.Rules 2022-08-22 17:51:06 +02:00
NewSoupVi
9553627136 Witness: More bug fixes (#937)
* Fixed disable_non_randomized and other bugs

* Slight performance & code sensibility increase

* Added River Shortcut to Garden as a disabled check in disable_non_randomized

* Changed no progression items exception to a warning

* Added a list of disabled panels to slot_data for disable_non_randomized, so the client can automatically disable the right panels in the future

* Made no progression exception conditional on playercount
2022-08-22 05:50:01 +02:00
PoryGone
a4a8894d22 Add /SNI to .gitignore (#949) 2022-08-22 01:20:35 +02:00
wordfcuk
bf217dcf85 RoR2: Fixed the link to the game settings page (#945) 2022-08-21 17:30:30 +02:00
CaitSith2
484ee9f065 OoT: More item.type bugs. (#930) 2022-08-21 01:55:41 +02:00
Zach Parks
bba82ccd6c WebHost: Remove "Wiki" link from footer (#943) 2022-08-20 19:17:23 -04:00
alwaysintreble
fb122df5f5 RoR2: code cleanup and styling consistency (#833)
* build locations dict dynamically from the TotalLocations option. Minor styling cleanup

* Minor items styling cleanup. remove unused event items

* minor options cleanup. clarify preset toggle slightly better

* make items.py more readable. add chaos weights dict to use as reference point for generation

* small rules styling and consistency cleanup

* create less regions and other init cleanup

* move region creation to less function calls and move revivals calculation

* typing

* use enum instead of hardcoded ints. fix bug i introduced

* better typing
2022-08-20 19:09:35 -04:00
KonoTyran
be8c3131d8 fix allay advancements requiring note block on the wrong one. (#896) 2022-08-20 19:02:50 -04:00
Fabian Dill
9341332379 WebHost: allow newlines in data-tooltip (#921)
* WebHost: allow newlines in data-tooltip

* WebHost: Tooltips: strip surrounding whitespace

* WebHost: unify tooltips behaviour

* WebHost: unify labels around tooltips

* WebHost: changing tooltips width to max-width to allow small tooltips to not have empty space.

* Minor modifications to tooltips

- Reduce tooltip target to (?) spans
- Set fixed width of 260px on tooltips
- Add space between : and (?) on player-settings
- Removed cursor:pointer on tooltips
- Fix labels for checkboxes on generate.html

Co-authored-by: Chris Wilson <chris@legendserver.info>
2022-08-20 18:58:46 -04:00
Fabian Dill
83bcb441bf Factorio: typo 2022-08-21 00:34:36 +02:00
PoryGone
a074d16297 DKC3 v1.1.0 (#938)
Features:

* KONGsanity option (Collect all KONG letters in each level for a check)
* Autosave option
* Difficulty option
* MERRY option
* Handle collected/co-op locations


Bugfixes:

 * Fixed Mekanos softlock
 * Prevent Brothers Bear giving extra Banana Birds
 * Fixed Banana Bird Mother check sending prematurely
 * Fix Logic bug with Krematoa level costs
2022-08-20 16:46:44 +02:00
TheCondor07
89ab4aff9c SC2: Logic changes and fixes, 6 new locations, 2 removed locations (#933) 2022-08-19 22:50:44 +02:00
lordlou
0ac67bfe76 Smz3 early sword fix (#939) 2022-08-19 15:02:39 +02:00
Fabian Dill
0d61192c67 Factorio: make apworld compatible(#935) 2022-08-18 01:33:40 +02:00
Fabian Dill
a1aa9c17ff Core: convert is_zip to zip_path 2022-08-18 01:20:30 +02:00
Henrique Gemignani Passos Lima
d0faa36eef Fix CommonClient.server_loop with nogui
When running client without a gui, ctx.ui is None
2022-08-18 01:18:01 +02:00
Fabian Dill
22c8153ba8 WebHost: fix indentation in tracker.py 2022-08-17 22:15:56 +02:00
CaitSith2
6602c580f4 Fix another item.type crash bug. (#927)
* Fix another item.type crash bug.

* Another location that can crash, in the instance of plando fixed.
2022-08-17 07:16:14 -07:00
Joethepic
431a9b7023 Docs: Mc: fix version in setup guide (#873)
Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>
2022-08-17 09:59:22 +02:00
Fabian Dill
d426226bce LttP: run optimize imports on __init__ 2022-08-16 23:57:59 +02:00
Fabian Dill
09afdc2553 Webhost: prevent tracker crashes with LttP key itemlinks (#922) 2022-08-16 23:57:26 +02:00
Fabian Dill
ca83905d9f Core: allow loading worlds from zip modules (#747)
* Core: allow loading worlds from zip modules
RoR2: make it zipimport compatible (remove relative imports beyond local top-level)

* WebHost: add support for .apworld
2022-08-15 23:52:03 +02:00
black-sliver
086295adbb AutoWorld: add preliminary .apworld specification (#903)
* AutoWorld: add preliminary .apworld specification

* Doc: apworld specification: fix typo
2022-08-15 23:47:32 +02:00
alwaysintreble
81cf1508e0 Core: Refactor Autoworld.options to Autoworld.option_definitions (#906)
* refactor `world.options` -> `world.option_definitions`

* rename world api reference

* missed some self.options
2022-08-15 23:46:59 +02:00
Fabian Dill
8484193151 Core: crash if non_local pool is too big 2022-08-15 23:36:07 +02:00
Fabian Dill
d10fbf8263 Minecraft: update requests 2022-08-15 23:35:51 +02:00
Fabian Dill
f73b3d71bf Factorio: fix typo 2022-08-15 23:03:03 +02:00
Fabian Dill
d48d775a59 Subnautica: fix 2 logic/locations bugs and add a bit of docs (#917) 2022-08-15 22:53:59 +02:00
Yussur Mustafa Oraji
f716bfc58f sm64ex: Fix Second Floor Door Cost (#909) 2022-08-15 17:29:35 +02:00
Jarno Westhof
97b388747a Docs: Added DS3 & DK3 to network graph 2022-08-15 16:56:55 +02:00
lordlou
898fa203ad Smz3 updated to version 11.3 (#886) 2022-08-15 16:48:13 +02:00
CaitSith2
c02c6ee58c Fix generation failure for Final Fantasy 1 and Dark Souls 3. (#907)
* Fix generation failure for Final Fantasy 1.

* Fix spoiler log giving "Location (Player x): Item (Player y)" for FF1.

* Dark Soul 3 Items/Locations now get player names in spoiler log.
2022-08-14 12:34:46 -07:00
black-sliver
23b04b5069 SM: correctly check if items are SM items 2022-08-14 13:38:52 +02:00
Ludovic Marechal
0ed0d17f38 DS3: Update the setup guide (#878)
* Merge pull request #1 from eudaimonistic/patch-2

Update setup_en.md

(cherry picked from commit 41567697fb89e74301afe651fbde0bafca5946e0)

* DS3: Update english documentation

* DS3: Add French setup guide

* DS3: Fix space formatting in doc

* DS3: Resolve comment
2022-08-14 00:07:36 +02:00
espeon65536
645ede869f OoT: Fix blind item.type reference (#905)
* oot: remove blind reference to item.type

* oot: logical reasoning is hard

* oot: fix blind item.type reference
2022-08-13 04:36:06 +02:00
black-sliver
f5e48c850d Utils: lazy decimal import
decimal is kinda big, there is no noticable difference in performance and the import is unused by webhost's customserver
2022-08-13 00:20:08 +02:00
Fabian Dill
9bd035a19d WebHost: make a fresh Room reload page once if port is not assigned yet 2022-08-12 16:01:02 +02:00
Fabian Dill
2e428f906c Core: document KeyedDefaultDict 2022-08-12 08:34:33 +02:00
black-sliver
b702ae482b Core: clean up Utils.py
* fix import order
* lazy import shutil
* lazy import jellyfish (also speed-up by 0.8%, probably because of inlining)
* yaml:
  * explicitely call Loader UnsafeLoader
  * use CDumper, twice as fast
  * stop leaking leak imported names load and load_all
* open_file: use absolute path
* replace quotes in touched code
* add some typing in touched code
* stringify type hinting for non-imports
* %s/.format -> f
* freeze safe_builtins
* remove double-caching in get_options()
* get rid of some warnings
2022-08-12 08:07:45 +02:00
black-sliver
b8ca41b45f Utils: SI: fix rounding problems (#895)
* Utils: SI: fix rounding problems

999.999 would give 1000.00 instead of 1.00k

* Tests: add Utils: SI tests
2022-08-12 00:46:11 +02:00
CaitSith2
adc16fdd3d Factorio: Don't send researches completed by editor extensions testing forces. (#894) 2022-08-11 18:11:34 +02:00
NewSoupVi
b32d0efe6d Witness: Logic fix for Treehouse in Doors (#892) 2022-08-11 15:57:33 +02:00
black-sliver
c96acbfa23 TextClient: receive all items
By popular demand, this makes /received work again.

Closes #887
2022-08-11 01:06:58 +02:00
black-sliver
ffe528467e Generate: remove period for easy copy&paste
Double-clicking in terminal may select the period, resulting in a bad filename in clipboard.
Also fixing quotes.
2022-08-11 01:06:43 +02:00
Fabian Dill
b989698740 WebHost: fix datapackage typo 2022-08-11 01:04:53 +02:00
Fabian Dill
29e0975832 Clients: prepare for removal of players key in RoomInfo 2022-08-11 00:48:38 +02:00
CaitSith2
e1e2526322 LttP: Do a check for enemizer much earlier in generation. (#875) 2022-08-10 22:21:52 +02:00
Fabian Dill
f2e83c37e9 WebHost: use title-typical sorting for game titles (#883) 2022-08-09 22:21:45 +02:00
Fabian Dill
debda5d111 MultiServer: swap auto-forfeit with auto-collect order
That way the forfeit for items for players that are still playing appear last in the log, which is the visible text in at least the py clients
2022-08-09 16:58:02 +02:00
alwaysintreble
2c4e819010 docs: plando update (#861)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-08-09 10:47:01 +02:00
alwaysintreble
b3700dabf2 Core: Fix meta.yaml and allow the None game category for common options (#845) 2022-08-09 02:29:00 +02:00
TheCondor07
fb2979d9ef SC2: Added Difficulty Override to Client (#863) 2022-08-09 00:20:51 +02:00
Fabian Dill
a378d62dfd SC2: fix Moebius Factor rescue condition (#882) 2022-08-08 23:20:18 +02:00
lordlou
eb5ba72cfc Smz3 min accessibility fix (#880) 2022-08-08 22:23:22 +02:00
Fabian Dill
c1e9d0ab4f WebHost: allow customserver to skip importing worlds subsystem for hosting a Room (#877)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-08-07 18:28:50 +02:00
black-sliver
181cc47079 Core: cleanup BaseClasses.Location
This is just cleanup and has virtually no performance impact.
2022-08-07 13:11:12 +02:00
Zach Parks
04eef669f9 StS: Add a description for the game. (#876) 2022-08-06 21:36:32 -05:00
PoryGone
9167e5363d DKC3: Correct File Extension in Setup Guide (#872) 2022-08-06 13:26:02 +02:00
Zach Parks
f1c5c9a148 WebHost: Fix OptionDicts that define valid_keys from outputting as [] on Weighted Settings export. (#874)
* WebHost: Fix OptionDicts that define valid_keys from outputting as [] on Weighted Settings export
2022-08-06 13:25:37 +02:00
Joethepic
69e5627cd7 HK: fix indentation on mimic grubs (#868) 2022-08-06 02:11:10 +02:00
PoryGone
ae3e6c29e3 DKC3: Add Link to Tracker from Setup Guide (#871) 2022-08-06 00:53:48 +02:00
black-sliver
f6da81ac70 Core: cleanup Item classes (#849) 2022-08-06 00:49:54 +02:00
Jarno Westhof
dd6e212519 [Core] Colorama fix 2022-08-05 17:17:40 +02:00
Fabian Dill
95bba50223 WebHost: fix filename rename in flask update 2022-08-05 17:16:26 +02:00
Fabian Dill
21f7c6c0ad Core: optimize away Item.world (#840)
* Core: optimize away Item.world

* Update test/general/TestFill.py

* Test: undo unnecessary changes

* lttp: remove two more Item.world writes

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-08-05 17:09:21 +02:00
Fabian Dill
d15c30f63b Stats: limit to recognized games 2022-08-05 17:01:02 +02:00
211 changed files with 4734 additions and 2701 deletions

35
.github/ISSUE_TEMPLATE/bug_report.yaml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Bug Report
description: File a bug report.
title: "Bug: "
labels:
- bug / fix
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report! If this bug occurred during local generation check your
Archipelago install for a log (probably `C:\ProgramData\Archipelago\logs`)
and upload it with this report, as well as all yaml files used.
- type: textarea
id: what-happened
attributes:
label: What happened?
validations:
required: true
- type: textarea
id: expected-results
attributes:
label: What were the expected results?
validations:
required: true
- type: dropdown
id: version
attributes:
label: Software
description: Where did this bug occur?
options:
- Website
- Local generation
- While playing
validations:
required: true

View File

@@ -0,0 +1,17 @@
name: Feature Request
description: Request a feature!
title: "Category: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Please replace `Category` in the title with what this feature will be targeting, such as Core generation,
website, documentation, or a game.
Note: this is not for requesting new games to be added. If you would like to request a game, the best place to
ask is about it is in the [discord](https://archipelago.gg/discord).
- type: textarea
id: feature
attributes:
label: What feature would you like to see?

10
.github/ISSUE_TEMPLATE/task.yaml vendored Normal file
View File

@@ -0,0 +1,10 @@
name: Task
description: Submit a task to be done. If this is not targeting core, it should likely be elsewhere.
title: "Core: "
labels:
- core
- enhancement
body:
- type: textarea
attributes:
label: What task needs to be completed?

12
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,12 @@
Please format your title with what portion of the project this pull request is
targeting and what it's changing.
ex. "MyGame4: implement new game" or "Docs: add new guide for customizing MyGame3"
## What is this fixing or adding?
## How was this tested?
## If this makes graphical changes, please attach screenshots.

View File

@@ -4,6 +4,11 @@ name: Build
on: workflow_dispatch
env:
SNI_VERSION: v0.0.84
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
jobs:
# build-release-macos: # LF volunteer
@@ -17,9 +22,9 @@ jobs:
python-version: '3.8'
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-windows-amd64.zip -OutFile sni.zip
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/${Env:SNI_VERSION}/sni-${Env:SNI_VERSION}-windows-amd64.zip -OutFile sni.zip
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/7.0.1/win-x64.zip -OutFile enemizer.zip
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
- name: Build
run: |
@@ -43,6 +48,7 @@ jobs:
build-ubuntu1804:
runs-on: ubuntu-18.04
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v2
- name: Install base dependencies
run: |
@@ -56,18 +62,18 @@ jobs:
- 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
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/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/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-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/7.0.1/ubuntu.16.04-x64.7z
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
run: |
@@ -84,6 +90,7 @@ jobs:
(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
# - copy code above to release.yml -
- name: Store AppImage
uses: actions/upload-artifact@v2
with:

View File

@@ -18,8 +18,8 @@ jobs:
python-version: 3.9
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
python -m pip install --upgrade pip wheel
pip install flake8 pytest pytest-subtests
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |

View File

@@ -7,6 +7,11 @@ on:
tags:
- '*.*.*'
env:
SNI_VERSION: v0.0.84
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
jobs:
create-release:
runs-on: ubuntu-latest
@@ -44,22 +49,23 @@ jobs:
- 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
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/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/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-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/7.0.1/ubuntu.16.04-x64.7z
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/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
# pygobject is an optional dependency for kivy that's not in requirements
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
pip install -r requirements.txt

View File

@@ -32,8 +32,8 @@ jobs:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install flake8 pytest
python -m pip install --upgrade pip wheel
pip install flake8 pytest pytest-subtests
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Unittests
run: |

1
.gitignore vendored
View File

@@ -28,6 +28,7 @@ README.html
.vs/
EnemizerCLI/
/Players/
/SNI/
/options.yaml
/config.yaml
/logs/

View File

@@ -166,7 +166,7 @@ class MultiWorld():
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():
for option_key, option in world_type.option_definitions.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)
@@ -204,7 +204,7 @@ class MultiWorld():
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:
for option_key in world_type.option_definitions:
setattr(self, option_key, getattr(args, option_key, {}))
self.worlds[player] = world_type(self, player)
@@ -384,7 +384,6 @@ class MultiWorld():
return self.worlds[player].create_item(item_name)
def push_precollected(self, item: Item):
item.world = self
self.precollected_items[item.player].append(item)
self.state.collect(item, True)
@@ -392,7 +391,6 @@ class MultiWorld():
assert location.can_fill(self.state, item, False), f"Cannot place {item} into {location}."
location.item = item
item.location = location
item.world = self # try to not have this here anymore and create it with item?
if collect:
self.state.collect(item, location.event, location)
@@ -1066,26 +1064,25 @@ class LocationProgressType(IntEnum):
class Location:
# If given as integer, then this is the shop's inventory index
shop_slot: Optional[int] = None
shop_slot_disabled: bool = False
game: str = "Generic"
player: int
name: str
address: Optional[int]
parent_region: Optional[Region]
event: bool = False
locked: bool = False
game: str = "Generic"
show_in_spoiler: bool = True
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
def __init__(self, player: int, name: str = '', address: Optional[int] = None, parent: Optional[Region] = None):
self.player = player
self.name = name
self.address = address
self.parent_region = parent
self.player: int = player
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)))
@@ -1102,7 +1099,6 @@ class Location:
self.item = item
item.location = self
self.event = item.advancement
self.item.world = self.parent_region.world
self.locked = True
def __repr__(self):
@@ -1147,39 +1143,28 @@ class ItemClassification(IntFlag):
class Item:
location: Optional[Location] = None
world: Optional[MultiWorld] = None
code: Optional[int] = None # an item with ID None is called an Event, and does not get written to multidata
name: str
game: str = "Generic"
type: str = None
__slots__ = ("name", "classification", "code", "player", "location")
name: str
classification: ItemClassification
# 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"
sickkid_credit_text: Optional[str] = None
magicshop_credit_text: Optional[str] = None
zora_credit_text: Optional[str] = None
fluteboy_credit_text: Optional[str] = None
# hopefully temporary attributes to satisfy legacy LttP code, proper implementation in subclass ALttPItem
smallkey: bool = False
bigkey: bool = False
map: bool = False
compass: bool = False
code: Optional[int]
"""an item with code None is called an Event, and does not get written to multidata"""
player: int
location: Optional[Location]
def __init__(self, name: str, classification: ItemClassification, code: Optional[int], player: int):
self.name = name
self.classification = classification
self.player = player
self.code = code
self.location = None
@property
def hint_text(self):
def hint_text(self) -> str:
return getattr(self, "_hint_text", self.name.replace("_", " ").replace("-", " "))
@property
def pedestal_hint_text(self):
def pedestal_hint_text(self) -> str:
return getattr(self, "_pedestal_hint_text", self.name.replace("_", " ").replace("-", " "))
@property
@@ -1205,7 +1190,7 @@ class Item:
def __eq__(self, other):
return self.name == other.name and self.player == other.player
def __lt__(self, other: Item):
def __lt__(self, other: Item) -> bool:
if other.player != self.player:
return other.player < self.player
return self.name < other.name
@@ -1213,11 +1198,13 @@ class Item:
def __hash__(self):
return hash((self.name, self.player))
def __repr__(self):
def __repr__(self) -> str:
return self.__str__()
def __str__(self):
return self.world.get_name_string_for_object(self) if self.world else f'{self.name} (Player {self.player})'
def __str__(self) -> str:
if self.location and self.location.parent_region and self.location.parent_region.world:
return self.location.parent_region.world.get_name_string_for_object(self)
return f"{self.name} (Player {self.player})"
class Spoiler():
@@ -1401,7 +1388,7 @@ class Spoiler():
outfile.write('Game: %s\n' % self.world.game[player])
for f_option, option in Options.per_game_common_options.items():
write_option(f_option, option)
options = self.world.worlds[player].options
options = self.world.worlds[player].option_definitions
if options:
for f_option, option in options.items():
write_option(f_option, option)

View File

@@ -5,6 +5,7 @@ import urllib.parse
import sys
import typing
import time
import functools
import ModuleUpdate
ModuleUpdate.update()
@@ -17,7 +18,8 @@ if __name__ == "__main__":
Utils.init_logging("TextClient", exception_logger="Client")
from MultiServer import CommandProcessor
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot
from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \
ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser
from Utils import Version, stream_input
from worlds import network_data_package, AutoWorldRegister
import os
@@ -152,8 +154,9 @@ class CommonContext:
# locations
locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int]
missing_locations: typing.Set[int]
missing_locations: typing.Set[int] # server state
checked_locations: typing.Set[int] # server state
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
locations_info: typing.Dict[int, NetworkItem]
# internals
@@ -184,8 +187,9 @@ class CommonContext:
self.locations_checked = set() # local state
self.locations_scouted = set()
self.items_received = []
self.missing_locations = set()
self.missing_locations = set() # server state
self.checked_locations = set() # server state
self.server_locations = set() # all locations the server knows of, missing_location | checked_locations
self.locations_info = {}
self.input_queue = asyncio.Queue()
@@ -202,6 +206,10 @@ class CommonContext:
# execution
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@functools.cached_property
def raw_text_parser(self) -> RawJSONtoTextParser:
return RawJSONtoTextParser(self)
@property
def total_locations(self) -> typing.Optional[int]:
"""Will return None until connected."""
@@ -345,6 +353,8 @@ class CommonContext:
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
needed_updates: typing.Set[str] = set()
for game in relevant_games:
if game not in remote_datepackage_versions:
continue
remote_version: int = remote_datepackage_versions[game]
if remote_version == 0: # custom datapackage for this game
@@ -493,7 +503,8 @@ async def server_loop(ctx: CommonContext, address=None):
logger.info(f'Connecting to Archipelago server at {address}')
try:
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
ctx.ui.update_address_bar(server_url.netloc)
if ctx.ui is not None:
ctx.ui.update_address_bar(server_url.netloc)
ctx.server = Endpoint(socket)
logger.info('Connected')
ctx.server_address = address
@@ -562,18 +573,21 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
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'])
players = args.get("players", [])
if len(players) < 1:
logger.info('No player connected')
else:
players.sort()
current_team = -1
logger.info('Connected Players:')
for network_player in 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 "players" in args: # TODO remove when servers sending this are outdated
players = args.get("players", [])
if len(players) < 1:
logger.info('No player connected')
else:
players.sort()
current_team = -1
logger.info('Connected Players:')
for network_player in 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))
# update datapackage
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
@@ -628,6 +642,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
# 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"])
ctx.server_locations = ctx.missing_locations | ctx. checked_locations
elif cmd == 'ReceivedItems':
start_index = args["index"]
@@ -723,7 +738,7 @@ if __name__ == '__main__':
class TextContext(CommonContext):
tags = {"AP", "IgnoreGame", "TextOnly"}
game = "" # empty matches any game since 0.3.2
items_handling = 0 # don't receive any NetworkItems
items_handling = 0b111 # receive all items for /received
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:

View File

@@ -1,4 +1,5 @@
import asyncio
import copy
import json
import time
from asyncio import StreamReader, StreamWriter
@@ -6,7 +7,7 @@ from typing import List
import Utils
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
SYSTEM_MESSAGE_ID = 0
@@ -64,7 +65,7 @@ class FF1Context(CommonContext):
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[(time.time(), msg_id)] = msg
self.messages[time.time(), msg_id] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
@@ -73,32 +74,28 @@ class FF1Context(CommonContext):
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == 'PrintJSON':
print_type = args['type']
item = args['item']
receiving_player_id = args['receiving']
receiving_player_name = self.player_names[receiving_player_id]
sending_player_id = item.player
sending_player_name = self.player_names[item.player]
if print_type == 'Hint':
msg = f"Hint: Your {self.item_names[item.item]} is at" \
f" {self.player_names[item.player]}'s {self.location_names[item.location]}"
self._set_message(msg, item.item)
elif print_type == 'ItemSend' and receiving_player_id != self.slot:
if sending_player_id == self.slot:
if receiving_player_id == self.slot:
msg = f"You found your own {self.item_names[item.item]}"
else:
msg = f"You sent {self.item_names[item.item]} to {receiving_player_name}"
else:
if receiving_player_id == sending_player_id:
msg = f"{sending_player_name} found their {self.item_names[item.item]}"
else:
msg = f"{sending_player_name} sent {self.item_names[item.item]} to " \
f"{receiving_player_name}"
def on_print_json(self, args: dict):
if self.ui:
self.ui.print_json(copy.deepcopy(args["data"]))
else:
text = self.jsontotextparser(copy.deepcopy(args["data"]))
logger.info(text)
relevant = args.get("type", None) in {"Hint", "ItemSend"}
if relevant:
item = args["item"]
# goes to this world
if self.slot_concerns_self(args["receiving"]):
relevant = True
# found in this world
elif self.slot_concerns_self(item.player):
relevant = True
# not related
else:
relevant = False
if relevant:
item = args["item"]
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
self._set_message(msg, item.item)
def run_gui(self):

View File

@@ -20,8 +20,7 @@ import Utils
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
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser
from MultiServer import mark_raw
from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart

View File

@@ -220,8 +220,8 @@ def distribute_items_restrictive(world: MultiWorld) -> None:
world.push_item(defaultlocations.pop(i), item_to_place, False)
break
else:
logging.warning(
f"Could not place non_local_item {item_to_place} among {defaultlocations}, tossing.")
raise Exception(f"Could not place non_local_item {item_to_place} among {defaultlocations}. "
f"Too many non-local items for too few remaining locations.")
world.random.shuffle(defaultlocations)

View File

@@ -7,7 +7,7 @@ import urllib.request
import urllib.parse
from typing import Set, Dict, Tuple, Callable, Any, Union
import os
from collections import Counter
from collections import Counter, ChainMap
import string
import enum
@@ -63,7 +63,7 @@ class PlandoSettings(enum.IntFlag):
def __str__(self) -> str:
if self.value:
return ", ".join((flag.name for flag in PlandoSettings if self.value & flag.value))
return ", ".join(flag.name for flag in PlandoSettings if self.value & flag.value)
return "Off"
@@ -84,11 +84,6 @@ def mystery_argparse():
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.") # 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"])
@@ -133,12 +128,14 @@ def main(args=None, callback=ERmain):
if args.meta_file_path and os.path.exists(args.meta_file_path):
try:
weights_cache[args.meta_file_path] = read_weights_yamls(args.meta_file_path)
meta_weights = read_weights_yamls(args.meta_file_path)[-1]
except Exception as e:
raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e
meta_weights = weights_cache[args.meta_file_path][-1]
print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}")
del(meta_weights["meta_description"])
try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file
del(meta_weights["meta_description"])
except Exception as e:
raise ValueError("No meta description found for meta.yaml. Unable to verify.") from e
if args.samesettings:
raise Exception("Cannot mix --samesettings with --meta")
else:
@@ -164,7 +161,7 @@ def main(args=None, callback=ERmain):
player_files[player_id] = filename
player_id += 1
args.multi = max(player_id-1, args.multi)
args.multi = max(player_id - 1, args.multi)
print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: "
f"{args.plando}")
@@ -181,31 +178,29 @@ def main(args=None, callback=ERmain):
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
erargs.lttp_rom = args.lttp_rom
erargs.sm_rom = args.sm_rom
erargs.enemizercli = args.enemizercli
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
{fname: ( tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
for fname, yamls in weights_cache.items()}
player_path_cache = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None)
for fname, yamls in weights_cache.items()}
if meta_weights:
for category_name, category_dict in meta_weights.items():
for key in category_dict:
option = get_choice(key, category_dict)
option = roll_meta_option(key, category_name, category_dict)
if option is not None:
for player, path in player_path_cache.items():
for path in weights_cache:
for yaml in weights_cache[path]:
if category_name is None:
yaml[key] = option
for category in yaml:
if category in AutoWorldRegister.world_types and key in Options.common_options:
yaml[category][key] = option
elif category_name not in yaml:
logging.warning(f"Meta: Category {category_name} is not present in {path}.")
else:
yaml[category_name][key] = option
yaml[category_name][key] = option
player_path_cache = {}
for player in range(1, args.multi + 1):
player_path_cache[player] = player_files.get(player, args.weights_file_path)
name_counter = Counter()
erargs.player_settings = {}
@@ -387,6 +382,28 @@ def update_weights(weights: dict, new_weights: dict, type: str, name: str) -> di
return weights
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
if not game:
return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types:
game_world = AutoWorldRegister.world_types[game]
options = ChainMap(game_world.option_definitions, Options.per_game_common_options)
if option_key in options:
if options[option_key].supports_weighting:
return get_choice(option_key, category_dict)
return options[option_key]
if game == "A Link to the Past": # TODO wow i hate this
if option_key in {"glitches_required", "dark_room_logic", "entrance_shuffle", "goals", "triforce_pieces_mode",
"triforce_pieces_percentage", "triforce_pieces_available", "triforce_pieces_extra",
"triforce_pieces_required", "shop_shuffle", "mode", "item_pool", "item_functionality",
"boss_shuffle", "enemy_damage", "enemy_health", "timer", "countdown_start_time",
"red_clock_time", "blue_clock_time", "green_clock_time", "dungeon_counters", "shuffle_prizes",
"misery_mire_medallion", "turtle_rock_medallion", "sprite_pool", "sprite",
"random_sprite_on_event"}:
return get_choice(option_key, category_dict)
raise Exception(f"Error generating meta option {option_key} for {game}.")
def roll_linked_options(weights: dict) -> dict:
weights = copy.deepcopy(weights) # make sure we don't write back to other weights sets in same_settings
for option_set in weights["linked_options"]:
@@ -531,7 +548,7 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default)))
if ret.game in AutoWorldRegister.world_types:
for option_key, option in world_type.options.items():
for option_key, option in world_type.option_definitions.items():
handle_option(ret, game_weights, option_key, option)
for option_key, option in Options.per_game_common_options.items():
# skip setting this option if already set from common_options, defaulting to root option

View File

@@ -10,16 +10,21 @@ 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, open_filename, messagebox,\
is_windows, is_macos, is_linux
from shutil import which
import shlex
import subprocess
import sys
from enum import Enum, auto
from os.path import isfile
from shutil import which
from typing import Iterable, Sequence, Callable, Union, Optional
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
is_windows, is_macos, is_linux
def open_host_yaml():
@@ -65,6 +70,7 @@ def browse_files():
webbrowser.open(file)
# noinspection PyArgumentList
class Type(Enum):
TOOL = auto()
FUNC = auto() # not a real component

View File

@@ -83,9 +83,9 @@ def main():
parser.add_argument('--ow_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick'])
parser.add_argument('--link_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick'])
# parser.add_argument('--link_palettes', default='default',
# choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
# 'sick'])
parser.add_argument('--shield_palettes', default='default',
choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy',
'sick'])
@@ -752,6 +752,7 @@ class SpriteSelector():
self.window['pady'] = 5
self.spritesPerRow = 32
self.all_sprites = []
self.invalid_sprites = []
self.sprite_pool = spritePool
def open_custom_sprite_dir(_evt):
@@ -833,6 +834,13 @@ class SpriteSelector():
self.window.focus()
tkinter_center_window(self.window)
if self.invalid_sprites:
invalid = sorted(self.invalid_sprites)
logging.warning(f"The following sprites are invalid: {', '.join(invalid)}")
msg = f"{invalid[0]} "
msg += f"and {len(invalid)-1} more are invalid" if len(invalid) > 1 else "is invalid"
messagebox.showerror("Invalid sprites detected", msg, parent=self.window)
def remove_from_sprite_pool(self, button, spritename):
self.callback(("remove", spritename))
self.spritePoolButtons.buttons.remove(button)
@@ -897,7 +905,13 @@ class SpriteSelector():
sprites = []
for file in os.listdir(path):
sprites.append((file, Sprite(os.path.join(path, file))))
if file == '.gitignore':
continue
sprite = Sprite(os.path.join(path, file))
if sprite.valid:
sprites.append((file, sprite))
else:
self.invalid_sprites.append(file)
sprites.sort(key=lambda s: str.lower(s[1].name or "").strip())

View File

@@ -70,7 +70,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.required_medallions = args.required_medallions.copy()
world.game = args.game.copy()
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.
@@ -217,9 +216,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info("Running Item Plando")
for item in world.itempool:
item.world = world
distribute_planned(world)
logger.info('Running Pre Main Fill.')
@@ -426,7 +422,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
zipfilename = output_path(f"AP_{world.seed_name}.zip")
logger.info(f'Creating final archive at {zipfilename}.')
logger.info(f"Creating final archive at {zipfilename}")
with zipfile.ZipFile(zipfilename, mode="w", compression=zipfile.ZIP_DEFLATED,
compresslevel=9) as zf:
for file in os.scandir(temp_dir):

View File

@@ -30,17 +30,13 @@ except ImportError:
OperationalError = ConnectionError
import NetUtils
from worlds.AutoWorld import AutoWorldRegister
proxy_worlds = {name: world(None, 0) for name, world in AutoWorldRegister.world_types.items()}
from worlds import network_data_package, lookup_any_item_id_to_name, lookup_any_location_id_to_name
import Utils
from Utils import get_item_name_from_id, get_location_name_from_id, \
version_tuple, restricted_loads, Version
from Utils import version_tuple, restricted_loads, Version
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType
min_client_version = Version(0, 1, 6)
print_command_compatability_threshold = Version(0, 3, 5) # Remove backwards compatibility around 0.3.7
colorama.init()
# functions callable on storable data on the server by clients
@@ -126,6 +122,11 @@ class Context:
stored_data: typing.Dict[str, object]
stored_data_notification_clients: typing.Dict[str, typing.Set[Client]]
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
forced_auto_forfeits: typing.Dict[str, bool]
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,
@@ -190,8 +191,43 @@ class Context:
self.stored_data = {}
self.stored_data_notification_clients = collections.defaultdict(weakref.WeakSet)
# General networking
# init empty to satisfy linter, I suppose
self.gamespackage = {}
self.item_name_groups = {}
self.all_item_and_group_names = {}
self.forced_auto_forfeits = collections.defaultdict(lambda: False)
self.non_hintable_names = {}
self._load_game_data()
self._init_game_data()
# Datapackage retrieval
def _load_game_data(self):
import worlds
self.gamespackage = worlds.network_data_package["games"]
self.item_name_groups = {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
self.forced_auto_forfeits[world_name] = world.forced_auto_forfeit
self.non_hintable_names[world_name] = world.hint_blacklist
def _init_game_data(self):
for game_name, game_package in self.gamespackage.items():
for item_name, item_id in game_package["item_name_to_id"].items():
self.item_names[item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[location_id] = location_name
self.all_item_and_group_names[game_name] = \
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
def item_names_for_game(self, game: str) -> typing.Dict[str, int]:
return self.gamespackage[game]["item_name_to_id"]
def location_names_for_game(self, game: str) -> typing.Dict[str, int]:
return self.gamespackage[game]["location_name_to_id"]
# General networking
async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool:
if not endpoint.socket or not endpoint.socket.open:
return False
@@ -256,20 +292,27 @@ class Context:
# text
def notify_all(self, text):
def notify_all(self, text: str):
logging.info("Notice (all): %s" % text)
self.broadcast_all([{"cmd": "Print", "text": text}])
broadcast_text_all(self, text)
def notify_client(self, client: Client, text: str):
if not client.auth:
return
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
if client.version >= print_command_compatability_threshold:
asyncio.create_task(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
else:
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
if not client.auth:
return
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
if client.version >= print_command_compatability_threshold:
asyncio.create_task(self.send_msgs(client,
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
else:
asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
# loading
@@ -544,12 +587,13 @@ class Context:
finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \
f' has completed their goal.'
self.notify_all(finished_msg)
if "auto" in self.forfeit_mode:
forfeit_player(self, client.team, client.slot)
elif proxy_worlds[self.games[client.slot]].forced_auto_forfeit:
forfeit_player(self, client.team, client.slot)
if "auto" in self.collect_mode:
collect_player(self, client.team, client.slot)
if "auto" in self.forfeit_mode:
forfeit_player(self, client.team, client.slot)
elif self.forced_auto_forfeits[self.games[client.slot]]:
forfeit_player(self, client.team, client.slot)
self.save() # save goal completion flag
def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False):
@@ -642,9 +686,10 @@ async def on_client_connected(ctx: Context, client: Client):
'permissions': get_permissions(ctx),
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points,
'datapackage_version': network_data_package["version"],
'datapackage_version': sum(game_data["version"] for game_data in ctx.gamespackage.values())
if all(game_data["version"] for game_data in ctx.gamespackage.values()) else 0,
'datapackage_versions': {game: game_data["version"] for game, game_data
in network_data_package["games"].items()},
in ctx.gamespackage.items()},
'seed_name': ctx.seed_name,
'time': time.time(),
}])
@@ -685,20 +730,37 @@ async def on_client_left(ctx: Context, client: Client):
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
async def countdown(ctx: Context, timer):
ctx.notify_all(f'[Server]: Starting countdown of {timer}s')
async def countdown(ctx: Context, timer: int):
broadcast_countdown(ctx, timer, f"[Server]: Starting countdown of {timer}s")
if ctx.countdown_timer:
ctx.countdown_timer = timer # timer is already running, set it to a different time
else:
ctx.countdown_timer = timer
while ctx.countdown_timer > 0:
ctx.notify_all(f'[Server]: {ctx.countdown_timer}')
broadcast_countdown(ctx, ctx.countdown_timer, f"[Server]: {ctx.countdown_timer}")
ctx.countdown_timer -= 1
await asyncio.sleep(1)
ctx.notify_all(f'[Server]: GO')
broadcast_countdown(ctx, 0, f"[Server]: GO")
ctx.countdown_timer = 0
def broadcast_text_all(ctx: Context, text: str, additional_arguments: dict = {}):
old_clients, new_clients = [], []
for teams in ctx.clients.values():
for clients in teams.values():
for client in clients:
new_clients.append(client) if client.version >= print_command_compatability_threshold \
else old_clients.append(client)
ctx.broadcast(old_clients, [{"cmd": "Print", "text": text }])
ctx.broadcast(new_clients, [{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
def broadcast_countdown(ctx: Context, timer: int, message: str):
broadcast_text_all(ctx, message, {"type": "Countdown", "countdown": timer})
def get_players_string(ctx: Context):
auth_clients = {(c.team, c.slot) for c in ctx.endpoints if c.auth}
@@ -822,8 +884,8 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
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),
ctx.player_names[(team, target_player)], get_location_name_from_id(location)))
team + 1, ctx.player_names[(team, slot)], ctx.item_names[item_id],
ctx.player_names[(team, target_player)], ctx.location_names[location]))
info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text])
@@ -838,13 +900,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
ctx.save()
def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[NetUtils.Hint]:
def collect_hints(ctx: Context, team: int, slot: int, item_name: str) -> typing.List[NetUtils.Hint]:
hints = []
slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items():
if slot in group:
slots.add(group_id)
seeked_item_id = proxy_worlds[ctx.games[slot]].item_name_to_id[item]
seeked_item_id = ctx.item_names_for_game(ctx.games[slot])[item_name]
for finding_player, check_data in ctx.locations.items():
for location_id, (item_id, receiving_player, item_flags) in check_data.items():
if receiving_player in slots and item_id == seeked_item_id:
@@ -857,7 +920,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item: str) -> typing.List[
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]
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
return collect_hint_location_id(ctx, team, slot, seeked_location)
@@ -874,8 +937,8 @@ def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
f"{lookup_any_item_id_to_name[hint.item]} is " \
f"at {get_location_name_from_id(hint.location)} " \
f"{ctx.item_names[hint.item]} is " \
f"at {ctx.location_names[hint.location]} " \
f"in {ctx.player_names[team, hint.finding_player]}'s World"
if hint.entrance:
@@ -1133,8 +1196,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
forfeit_player(self.ctx, self.client.team, self.client.slot)
return True
elif "disabled" in self.ctx.forfeit_mode:
self.output(
"Sorry, client item releasing has been disabled on this server. You can ask the server admin for a /release")
self.output("Sorry, client item releasing has been disabled on this server. "
"You can ask the server admin for a /release")
return False
else: # is auto or goal
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
@@ -1170,7 +1233,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.remaining_mode == "enabled":
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item")
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
@@ -1183,7 +1246,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids:
self.output("Remaining items: " + ", ".join(lookup_any_item_id_to_name.get(item_id, "unknown item")
self.output("Remaining items: " + ", ".join(self.ctx.item_names[item_id]
for item_id in remaining_item_ids))
else:
self.output("No remaining items found.")
@@ -1199,7 +1262,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
if locations:
texts = [f'Missing: {get_location_name_from_id(location)}' for location in locations]
texts = [f'Missing: {self.ctx.location_names[location]}' for location in locations]
texts.append(f"Found {len(locations)} missing location checks")
self.ctx.notify_client_multiple(self.client, texts)
else:
@@ -1212,7 +1275,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
if locations:
texts = [f'Checked: {get_location_name_from_id(location)}' for location in locations]
texts = [f'Checked: {self.ctx.location_names[location]}' for location in locations]
texts.append(f"Found {len(locations)} done location checks")
self.ctx.notify_client_multiple(self.client, texts)
else:
@@ -1241,11 +1304,13 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_getitem(self, item_name: str) -> bool:
"""Cheat in an item, if it is enabled on this server"""
if self.ctx.item_cheat:
world = proxy_worlds[self.ctx.games[self.client.slot]]
item_name, usable, response = get_intended_text(item_name,
world.item_names)
names = self.ctx.item_names_for_game(self.ctx.games[self.client.slot])
item_name, usable, response = get_intended_text(
item_name,
names
)
if usable:
new_item = NetworkItem(world.create_item(item_name).code, -1, self.client.slot)
new_item = NetworkItem(names[item_name], -1, self.client.slot)
get_received_items(self.ctx, self.client.team, self.client.slot, False).append(new_item)
get_received_items(self.ctx, self.client.team, self.client.slot, True).append(new_item)
self.ctx.notify_all(
@@ -1271,20 +1336,22 @@ class ClientMessageProcessor(CommonCommandProcessor):
f"You have {points_available} points.")
return True
else:
world = proxy_worlds[self.ctx.games[self.client.slot]]
names = world.location_names if for_location else world.all_item_and_group_names
game = self.ctx.games[self.client.slot]
names = self.ctx.location_names_for_game(game) \
if for_location else \
self.ctx.all_item_and_group_names[game]
hint_name, usable, response = get_intended_text(input_text,
names)
if usable:
if hint_name in world.hint_blacklist:
if hint_name in self.ctx.non_hintable_names[game]:
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 self.ctx.item_name_groups[game]: # item group name
hints = []
for item in world.item_name_groups[hint_name]:
if item in world.item_name_to_id: # ensure item has an ID
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
for item_name in self.ctx.item_name_groups[game][hint_name]:
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
@@ -1346,12 +1413,12 @@ class ClientMessageProcessor(CommonCommandProcessor):
return False
@mark_raw
def _cmd_hint(self, item: str = "") -> bool:
def _cmd_hint(self, item_name: str = "") -> bool:
"""Use !hint {item_name},
for example !hint Lamp to get a spoiler peek for that item.
If hint costs are on, this will only give you one new result,
you can rerun the command to get more in that case."""
return self.get_hints(item)
return self.get_hints(item_name)
@mark_raw
def _cmd_hint_location(self, location: str = "") -> bool:
@@ -1477,23 +1544,23 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
elif cmd == "GetDataPackage":
exclusions = args.get("exclusions", [])
if "games" in args:
games = {name: game_data for name, game_data in network_data_package["games"].items()
games = {name: game_data for name, game_data in ctx.gamespackage.items()
if name in set(args.get("games", []))}
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": {"games": games}}])
# TODO: remove exclusions behaviour around 0.5.0
elif exclusions:
exclusions = set(exclusions)
games = {name: game_data for name, game_data in network_data_package["games"].items()
games = {name: game_data for name, game_data in ctx.gamespackage.items()
if name not in exclusions}
package = network_data_package.copy()
package["games"] = games
package = {"games": games}
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": package}])
else:
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": network_data_package}])
"data": {"games": ctx.gamespackage}}])
elif client.auth:
if cmd == "ConnectUpdate":
@@ -1549,7 +1616,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
create_as_hint: int = int(args.get("create_as_hint", 0))
hints = []
for location in args["locations"]:
if type(location) is not int or location not in lookup_any_location_id_to_name:
if type(location) is not int:
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
"original_cmd": cmd}])
@@ -1763,18 +1830,18 @@ class ServerCommandProcessor(CommonCommandProcessor):
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
team, slot = self.ctx.player_name_lookup[seeked_player]
item = " ".join(item_name)
world = proxy_worlds[self.ctx.games[slot]]
item, usable, response = get_intended_text(item, world.item_names)
item_name = " ".join(item_name)
names = self.ctx.item_names_for_game(self.ctx.games[slot])
item_name, usable, response = get_intended_text(item_name, names)
if usable:
amount: int = int(amount)
new_items = [NetworkItem(world.item_name_to_id[item], -1, 0) for i in range(int(amount))]
new_items = [NetworkItem(names[item_name], -1, 0) for _ 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)}')
f'"{item_name}" to {self.ctx.get_aliased_name(team, slot)}')
return True
else:
self.output(response)
@@ -1787,22 +1854,22 @@ class ServerCommandProcessor(CommonCommandProcessor):
"""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:
def _cmd_hint(self, player_name: str, *item_name: 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())
if usable:
team, slot = self.ctx.player_name_lookup[seeked_player]
item = " ".join(item)
world = proxy_worlds[self.ctx.games[slot]]
item, usable, response = get_intended_text(item, world.all_item_and_group_names)
item_name = " ".join(item_name)
game = self.ctx.games[slot]
item_name, usable, response = get_intended_text(item_name, self.ctx.all_item_and_group_names[game])
if usable:
if item in world.item_name_groups:
if item_name in self.ctx.item_name_groups[game]:
hints = []
for item in world.item_name_groups[item]:
if item in world.item_name_to_id: # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item))
for item_name_from_group in self.ctx.item_name_groups[game][item_name]:
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
else: # item name
hints = collect_hints(self.ctx, team, slot, item)
hints = collect_hints(self.ctx, team, slot, item_name)
if hints:
notify_hints(self.ctx, team, hints)
@@ -1818,16 +1885,16 @@ class ServerCommandProcessor(CommonCommandProcessor):
self.output(response)
return False
def _cmd_hint_location(self, player_name: str, *location: str) -> bool:
def _cmd_hint_location(self, player_name: str, *location_name: str) -> bool:
"""Send out a hint for a player's location to their team"""
seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values())
if usable:
team, slot = self.ctx.player_name_lookup[seeked_player]
item = " ".join(location)
world = proxy_worlds[self.ctx.games[slot]]
item, usable, response = get_intended_text(item, world.location_names)
location_name = " ".join(location_name)
location_name, usable, response = get_intended_text(location_name,
self.ctx.location_names_for_game(self.ctx.games[slot]))
if usable:
hints = collect_hint_location_name(self.ctx, team, slot, item)
hints = collect_hint_location_name(self.ctx, team, slot, location_name)
if hints:
notify_hints(self.ctx, team, hints)
else:

View File

@@ -270,7 +270,7 @@ class RawJSONtoTextParser(JSONtoTextParser):
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
'blue_bg': 44, 'purple_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
'blue_bg': 44, 'magenta_bg': 45, 'cyan_bg': 46, 'white_bg': 47}
def color_code(*args):

View File

@@ -298,7 +298,7 @@ class Toggle(NumericOption):
if type(data) == str:
return cls.from_text(data)
else:
return cls(data)
return cls(int(data))
@classmethod
def get_option_name(cls, value):

View File

@@ -17,7 +17,7 @@ ModuleUpdate.update()
import Utils
current_patch_version = 4
current_patch_version = 5
class AutoPatchRegister(type):
@@ -128,6 +128,7 @@ class APDeltaPatch(APContainer, metaclass=AutoPatchRegister):
manifest = super(APDeltaPatch, self).get_manifest()
manifest["base_checksum"] = self.hash
manifest["result_file_ending"] = self.result_file_ending
manifest["patch_file_ending"] = self.patch_file_ending
return manifest
@classmethod

View File

@@ -61,26 +61,10 @@ This project makes use of multiple other projects. We wouldn't be here without t
* [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer)
## Contributing
Contributions are welcome. We have a few asks of any new contributors.
* Ensure that all changes which affect logic are covered by unit tests.
* Do not introduce any unit test failures/regressions.
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 and other documentation on how Archipelago functions, please see [the docs folder](docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord.
For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md)
## FAQ
For frequently asked questions see the website's [FAQ Page](https://archipelago.gg/faq/en/)
For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/)
## 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:
* Be welcoming and inclusive in tone and language.
* Be respectful of others and their abilities.
* Show empathy when speaking with others.
* Be gracious and accept feedback and constructive criticism.
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private, such as private messaging or emails.
Any incidents of abuse may be reported directly to Ijwu at hmfarran@gmail.com.
Please refer to our [code of conduct.](/docs/code_of_conduct.md)

View File

@@ -15,9 +15,6 @@ import typing
from json import loads, dumps
import ModuleUpdate
ModuleUpdate.update()
from Utils import init_logging, messagebox
if __name__ == "__main__":
@@ -149,8 +146,8 @@ class Context(CommonContext):
def event_invalid_slot(self):
if self.snes_socket is not None and not self.snes_socket.closed:
asyncio.create_task(self.snes_socket.close())
raise Exception('Invalid ROM detected, '
'please verify that you have loaded the correct rom and reconnect your snes (/snes)')
raise Exception("Invalid ROM detected, "
"please verify that you have loaded the correct rom and reconnect your snes (/snes)")
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -158,7 +155,7 @@ class Context(CommonContext):
if self.rom is None:
self.awaiting_rom = True
snes_logger.info(
'No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)')
"No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)")
return
self.awaiting_rom = False
self.auth = self.rom
@@ -262,7 +259,7 @@ async def deathlink_kill_player(ctx: Context):
SNES_RECONNECT_DELAY = 5
# LttP
# FXPAK Pro protocol memory mapping used by SNI
ROM_START = 0x000000
WRAM_START = 0xF50000
WRAM_SIZE = 0x20000
@@ -293,21 +290,24 @@ SHOP_LEN = (len(Shops.shop_table) * 3) + 5
DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte
# SM
SM_ROMNAME_START = 0x007FC0
SM_ROMNAME_START = ROM_START + 0x007FC0
SM_INGAME_MODES = {0x07, 0x09, 0x0b}
SM_ENDGAME_MODES = {0x26, 0x27}
SM_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A}
SM_RECV_PROGRESS_ADDR = SRAM_START + 0x2000 # 2 bytes
SM_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte
SM_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte
# RECV and SEND are from the gameplay's perspective: SNIClient writes to RECV queue and reads from SEND queue
SM_RECV_QUEUE_START = SRAM_START + 0x2000
SM_RECV_QUEUE_WCOUNT = SRAM_START + 0x2602
SM_SEND_QUEUE_START = SRAM_START + 0x2700
SM_SEND_QUEUE_RCOUNT = SRAM_START + 0x2680
SM_SEND_QUEUE_WCOUNT = SRAM_START + 0x2682
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_ROMNAME_START = ROM_START + 0x00FFC0
SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b}
SMZ3_ENDGAME_MODES = {0x26, 0x27}
@@ -1083,6 +1083,9 @@ async def game_watcher(ctx: Context):
if ctx.awaiting_rom:
await ctx.server_auth(False)
elif ctx.server is None:
snes_logger.warning("ROM detected but no active multiworld server connection. " +
"Connect using command: /connect server:port")
if ctx.auth and ctx.auth != ctx.rom:
snes_logger.warning("ROM change detected, please reconnect to the multiworld server")
@@ -1159,6 +1162,9 @@ async def game_watcher(ctx: Context):
await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}])
await track_locations(ctx, roomid, roomdata)
elif ctx.game == GAME_SM:
if ctx.server is None or ctx.slot is None:
# not successfully connected to a multiworld server, cannot process the game sending items
continue
gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1)
if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time():
currently_dead = gamemode[0] in SM_DEATH_MODES
@@ -1169,25 +1175,25 @@ async def game_watcher(ctx: Context):
ctx.finished_game = True
continue
data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x680, 4)
data = await snes_read(ctx, SM_SEND_QUEUE_RCOUNT, 4)
if data is None:
continue
recv_index = data[0] | (data[1] << 8)
recv_item = data[2] | (data[3] << 8)
recv_item = data[2] | (data[3] << 8) # this is actually SM_SEND_QUEUE_WCOUNT
while (recv_index < recv_item):
itemAdress = recv_index * 8
message = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8)
message = await snes_read(ctx, SM_SEND_QUEUE_START + itemAdress, 8)
# worldId = message[0] | (message[1] << 8) # unused
# itemId = message[2] | (message[3] << 8) # unused
itemIndex = (message[4] | (message[5] << 8)) >> 3
recv_index += 1
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x680,
snes_buffered_write(ctx, SM_SEND_QUEUE_RCOUNT,
bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF]))
from worlds.sm.Locations import locations_start_id
from worlds.sm import locations_start_id
location_id = locations_start_id + itemIndex
ctx.locations_checked.add(location_id)
@@ -1196,15 +1202,14 @@ async def game_watcher(ctx: Context):
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)
data = await snes_read(ctx, SM_RECV_QUEUE_WCOUNT, 2)
if data is None:
continue
# recv_itemOutPtr = data[0] | (data[1] << 8) # unused
itemOutPtr = data[2] | (data[3] << 8)
itemOutPtr = data[0] | (data[1] << 8)
from worlds.sm.Items import items_start_id
from worlds.sm.Locations import locations_start_id
from worlds.sm import items_start_id
from worlds.sm import locations_start_id
if itemOutPtr < len(ctx.items_received):
item = ctx.items_received[itemOutPtr]
itemId = item.item - items_start_id
@@ -1214,10 +1219,10 @@ async def game_watcher(ctx: Context):
locationId = 0x00 #backward compat
playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes(
snes_buffered_write(ctx, SM_RECV_QUEUE_START + itemOutPtr * 4, bytes(
[playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, locationId & 0xFF]))
itemOutPtr += 1
snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602,
snes_buffered_write(ctx, SM_RECV_QUEUE_WCOUNT,
bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF]))
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_names[item.item], 'red', 'bold'),
@@ -1225,6 +1230,9 @@ async def game_watcher(ctx: Context):
ctx.location_names[item.location], itemOutPtr, len(ctx.items_received)))
await snes_flush_writes(ctx)
elif ctx.game == GAME_SMZ3:
if ctx.server is None or ctx.slot is None:
# not successfully connected to a multiworld server, cannot process the game sending items
continue
currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2)
if (currentGame is not None):
if (currentGame[0] != 0):
@@ -1260,7 +1268,8 @@ async def game_watcher(ctx: Context):
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
from worlds.smz3 import convertLocSMZ3IDToAPID
location_id = locations_start_id + convertLocSMZ3IDToAPID(itemIndex)
ctx.locations_checked.add(location_id)
location = ctx.location_names[location_id]

View File

@@ -1,31 +1,32 @@
from __future__ import annotations
import multiprocessing
import logging
import asyncio
import copy
import ctypes
import logging
import multiprocessing
import os.path
import re
import sys
import typing
import queue
from pathlib import Path
import nest_asyncio
import sc2
from sc2.main import run_game
from sc2.data import Race
from sc2.bot_ai import BotAI
from sc2.data import Race
from sc2.main import run_game
from sc2.player import Bot
from worlds.sc2wol.Regions import MissionInfo
from worlds.sc2wol.MissionTables import lookup_id_to_mission
from worlds.sc2wol.Items import lookup_id_to_name, item_table
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
from worlds.sc2wol import SC2WoLWorld
from pathlib import Path
import re
import NetUtils
from MultiServer import mark_raw
import ctypes
import sys
from Utils import init_logging, is_windows
from worlds.sc2wol import SC2WoLWorld
from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups
from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET
from worlds.sc2wol.MissionTables import lookup_id_to_mission
from worlds.sc2wol.Regions import MissionInfo
if __name__ == "__main__":
init_logging("SC2Client", exception_logger="Client")
@@ -35,20 +36,49 @@ sc2_logger = logging.getLogger("Starcraft2")
import colorama
from NetUtils import *
from NetUtils import ClientStatus, RawJSONtoTextParser
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
nest_asyncio.apply()
max_bonus: int = 8
victory_modulo: int = 100
class StarcraftClientProcessor(ClientCommandProcessor):
ctx: SC2Context
def _cmd_difficulty(self, difficulty: str = "") -> bool:
"""Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal"""
options = difficulty.split()
num_options = len(options)
difficulty_choice = options[0].lower()
if num_options > 0:
if difficulty_choice == "casual":
self.ctx.difficulty_override = 0
elif difficulty_choice == "normal":
self.ctx.difficulty_override = 1
elif difficulty_choice == "hard":
self.ctx.difficulty_override = 2
elif difficulty_choice == "brutal":
self.ctx.difficulty_override = 3
else:
self.output("Unable to parse difficulty '" + options[0] + "'")
return False
self.output("Difficulty set to " + options[0])
return True
else:
self.output("Difficulty needs to be specified in the command.")
return False
def _cmd_disable_mission_check(self) -> bool:
"""Disables the check to see if a mission is available to play. Meant for co-op runs where one player can play
the next mission in a chain the other player is doing."""
self.ctx.missions_unlocked = True
sc2_logger.info("Mission check has been disabled")
return True
def _cmd_play(self, mission_id: str = "") -> bool:
"""Start a Starcraft 2 mission"""
@@ -64,19 +94,20 @@ class StarcraftClientProcessor(ClientCommandProcessor):
else:
sc2_logger.info(
"Mission ID needs to be specified. Use /unfinished or /available to view ids for available missions.")
return False
return True
def _cmd_available(self) -> bool:
"""Get what missions are currently available to play"""
request_available_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui)
request_available_missions(self.ctx)
return True
def _cmd_unfinished(self) -> bool:
"""Get what missions are currently available to play and have not had all locations checked"""
request_unfinished_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui, self.ctx)
request_unfinished_missions(self.ctx)
return True
@mark_raw
@@ -97,17 +128,19 @@ class SC2Context(CommonContext):
items_handling = 0b111
difficulty = -1
all_in_choice = 0
mission_req_table = None
items_rec_to_announce = []
rec_announce_pos = 0
items_sent_to_announce = []
sent_announce_pos = 0
announcements = []
announcement_pos = 0
mission_req_table: typing.Dict[str, MissionInfo] = {}
announcements = queue.Queue()
sc2_run_task: typing.Optional[asyncio.Task] = None
missions_unlocked = False
missions_unlocked: bool = False # allow launching missions ignoring requirements
current_tooltip = None
last_loc_list = None
difficulty_override = -1
mission_id_to_location_ids: typing.Dict[int, typing.List[int]] = {}
last_bot: typing.Optional[ArchipelagoBot] = None
def __init__(self, *args, **kwargs):
super(SC2Context, self).__init__(*args, **kwargs)
self.raw_text_parser = RawJSONtoTextParser(self)
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -120,30 +153,35 @@ class SC2Context(CommonContext):
self.difficulty = args["slot_data"]["game_difficulty"]
self.all_in_choice = args["slot_data"]["all_in_map"]
slot_req_table = args["slot_data"]["mission_req"]
self.mission_req_table = {}
# Compatibility for 0.3.2 server data.
if "category" not in next(iter(slot_req_table)):
for i, mission_data in enumerate(slot_req_table.values()):
mission_data["category"] = wol_default_categories[i]
for mission in slot_req_table:
self.mission_req_table[mission] = MissionInfo(**slot_req_table[mission])
self.mission_req_table = {
mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table
}
self.build_location_to_mission_mapping()
# Look for and set SC2PATH.
# check_game_install_path() returns True if and only if it finds + sets SC2PATH.
if "SC2PATH" not in os.environ and check_game_install_path():
check_mod_install()
if cmd in {"PrintJSON"}:
if "receiving" in args:
if self.slot_concerns_self(args["receiving"]):
self.announcements.append(args["data"])
return
if "item" in args:
if self.slot_concerns_self(args["item"].player):
self.announcements.append(args["data"])
def on_print_json(self, args: dict):
# goes to this world
if "receiving" in args and self.slot_concerns_self(args["receiving"]):
relevant = True
# found in this world
elif "item" in args and self.slot_concerns_self(args["item"].player):
relevant = True
# not related
else:
relevant = False
if relevant:
self.announcements.put(self.raw_text_parser(copy.deepcopy(args["data"])))
super(SC2Context, self).on_print_json(args)
def run_gui(self):
from kvui import GameManager, HoverBehavior, ServerToolTip, fade_in_animation
from kvui import GameManager, HoverBehavior, ServerToolTip
from kivy.app import App
from kivy.clock import Clock
from kivy.uix.tabbedpanel import TabbedPanelItem
@@ -161,6 +199,7 @@ class SC2Context(CommonContext):
class MissionButton(HoverableButton):
tooltip_text = StringProperty("Test")
ctx: SC2Context
def __init__(self, *args, **kwargs):
super(HoverableButton, self).__init__(*args, **kwargs)
@@ -181,10 +220,7 @@ class SC2Context(CommonContext):
self.ctx.current_tooltip = self.layout
def on_leave(self):
if self.ctx.current_tooltip:
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
self.ctx.current_tooltip = None
self.ctx.ui.clear_tooltip()
@property
def ctx(self) -> CommonContext:
@@ -206,13 +242,20 @@ class SC2Context(CommonContext):
mission_panel = None
last_checked_locations = {}
mission_id_to_button = {}
launching = False
launching: typing.Union[bool, int] = False # if int -> mission ID
refresh_from_launching = True
first_check = True
ctx: SC2Context
def __init__(self, ctx):
super().__init__(ctx)
def clear_tooltip(self):
if self.ctx.current_tooltip:
App.get_running_app().root.remove_widget(self.ctx.current_tooltip)
self.ctx.current_tooltip = None
def build(self):
container = super().build()
@@ -227,7 +270,7 @@ class SC2Context(CommonContext):
def build_mission_table(self, dt):
if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or
not self.refresh_from_launching)) or self.first_check:
not self.refresh_from_launching)) or self.first_check:
self.refresh_from_launching = True
self.mission_panel.clear_widgets()
@@ -238,12 +281,7 @@ class SC2Context(CommonContext):
self.mission_id_to_button = {}
categories = {}
available_missions = []
unfinished_locations = initialize_blank_mission_dict(self.ctx.mission_req_table)
unfinished_missions = calc_unfinished_missions(self.ctx.checked_locations,
self.ctx.mission_req_table,
self.ctx, available_missions=available_missions,
unfinished_locations=unfinished_locations)
available_missions, unfinished_missions = calc_unfinished_missions(self.ctx)
# separate missions into categories
for mission in self.ctx.mission_req_table:
@@ -254,7 +292,8 @@ class SC2Context(CommonContext):
for category in categories:
category_panel = MissionCategory()
category_panel.add_widget(Label(text=category, size_hint_y=None, height=50, outline_width=1))
category_panel.add_widget(
Label(text=category, size_hint_y=None, height=50, outline_width=1))
# Map is completed
for mission in categories[category]:
@@ -266,7 +305,9 @@ class SC2Context(CommonContext):
text = f"[color=6495ED]{text}[/color]"
tooltip = f"Uncollected locations:\n"
tooltip += "\n".join(location for location in unfinished_locations[mission])
tooltip += "\n".join([self.ctx.location_names[loc] for loc in
self.ctx.locations_for_mission(mission)
if loc in self.ctx.missing_locations])
elif mission in available_missions:
text = f"[color=FFFFFF]{text}[/color]"
# Map requirements not met
@@ -274,7 +315,7 @@ class SC2Context(CommonContext):
text = f"[color=a9a9a9]{text}[/color]"
tooltip = f"Requires: "
if len(self.ctx.mission_req_table[mission].required_world) > 0:
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission-1] for
tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for
req_mission in
self.ctx.mission_req_table[mission].required_world)
@@ -296,13 +337,16 @@ class SC2Context(CommonContext):
self.refresh_from_launching = False
self.mission_panel.clear_widgets()
self.mission_panel.add_widget(Label(text="Launching Mission"))
self.mission_panel.add_widget(Label(text="Launching Mission: " +
lookup_id_to_mission[self.launching]))
if self.ctx.ui:
self.ctx.ui.clear_tooltip()
def mission_callback(self, button):
if not self.launching:
self.ctx.play_mission(list(self.mission_id_to_button.keys())
[list(self.mission_id_to_button.values()).index(button)])
self.launching = True
mission_id: int = next(k for k, v in self.mission_id_to_button.items() if v == button)
self.ctx.play_mission(mission_id)
self.launching = mission_id
Clock.schedule_once(self.finish_launching, 10)
def finish_launching(self, dt):
@@ -315,12 +359,14 @@ class SC2Context(CommonContext):
async def shutdown(self):
await super(SC2Context, self).shutdown()
if self.last_bot:
self.last_bot.want_close = True
if self.sc2_run_task:
self.sc2_run_task.cancel()
def play_mission(self, mission_id):
def play_mission(self, mission_id: int):
if self.missions_unlocked or \
is_mission_available(mission_id, self.checked_locations, self.mission_req_table):
is_mission_available(self, mission_id):
if self.sc2_run_task:
if not self.sc2_run_task.done():
sc2_logger.warning("Starcraft 2 Client is still running!")
@@ -329,12 +375,29 @@ class SC2Context(CommonContext):
sc2_logger.warning("Launching Mission without Archipelago authentication, "
"checks will not be registered to server.")
self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id),
name="Starcraft 2 Launch")
name="Starcraft 2 Launch")
else:
sc2_logger.info(
f"{lookup_id_to_mission[mission_id]} is not currently unlocked. "
f"Use /unfinished or /available to see what is available.")
def build_location_to_mission_mapping(self):
mission_id_to_location_ids: typing.Dict[int, typing.Set[int]] = {
mission_info.id: set() for mission_info in self.mission_req_table.values()
}
for loc in self.server_locations:
mission_id, objective = divmod(loc - SC2WOL_LOC_ID_OFFSET, victory_modulo)
mission_id_to_location_ids[mission_id].add(objective)
self.mission_id_to_location_ids = {mission_id: sorted(objectives) for mission_id, objectives in
mission_id_to_location_ids.items()}
def locations_for_mission(self, mission: str):
mission_id: int = self.mission_req_table[mission].id
objectives = self.mission_id_to_location_ids[self.mission_req_table[mission].id]
for objective in objectives:
yield SC2WOL_LOC_ID_OFFSET + mission_id * 100 + objective
async def main():
multiprocessing.freeze_support()
@@ -374,47 +437,27 @@ wol_default_categories = [
]
def calculate_items(items):
unit_unlocks = 0
armory1_unlocks = 0
armory2_unlocks = 0
upgrade_unlocks = 0
building_unlocks = 0
merc_unlocks = 0
lab_unlocks = 0
protoss_unlock = 0
minerals = 0
vespene = 0
supply = 0
def calculate_items(items: typing.List[NetUtils.NetworkItem]) -> typing.List[int]:
network_item: NetUtils.NetworkItem
accumulators: typing.List[int] = [0 for _ in type_flaggroups]
for item in items:
data = lookup_id_to_name[item.item]
for network_item in items:
name: str = lookup_id_to_name[network_item.item]
item_data: ItemData = item_table[name]
if item_table[data].type == "Unit":
unit_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Upgrade":
upgrade_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Armory 1":
armory1_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Armory 2":
armory2_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Building":
building_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Mercenary":
merc_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Laboratory":
lab_unlocks += (1 << item_table[data].number)
elif item_table[data].type == "Protoss":
protoss_unlock += (1 << item_table[data].number)
elif item_table[data].type == "Minerals":
minerals += item_table[data].number
elif item_table[data].type == "Vespene":
vespene += item_table[data].number
elif item_table[data].type == "Supply":
supply += item_table[data].number
# exists exactly once
if item_data.quantity == 1:
accumulators[type_flaggroups[item_data.type]] |= 1 << item_data.number
return [unit_unlocks, upgrade_unlocks, armory1_unlocks, armory2_unlocks, building_unlocks, merc_unlocks,
lab_unlocks, protoss_unlock, minerals, vespene, supply]
# exists multiple times
elif item_data.type == "Upgrade":
accumulators[type_flaggroups[item_data.type]] += 1 << item_data.number
# sum
else:
accumulators[type_flaggroups[item_data.type]] += item_data.number
return accumulators
def calc_difficulty(difficulty):
@@ -430,11 +473,7 @@ def calc_difficulty(difficulty):
return 'X'
async def starcraft_launch(ctx: SC2Context, mission_id):
ctx.rec_announce_pos = len(ctx.items_rec_to_announce)
ctx.sent_announce_pos = len(ctx.items_sent_to_announce)
ctx.announcements_pos = len(ctx.announcements)
async def starcraft_launch(ctx: SC2Context, mission_id: int):
sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.")
with DllDirectory(None):
@@ -443,34 +482,39 @@ async def starcraft_launch(ctx: SC2Context, mission_id):
class ArchipelagoBot(sc2.bot_ai.BotAI):
game_running = False
mission_completed = False
first_bonus = False
second_bonus = False
third_bonus = False
fourth_bonus = False
fifth_bonus = False
sixth_bonus = False
seventh_bonus = False
eight_bonus = False
ctx: SC2Context = None
mission_id = 0
game_running: bool = False
mission_completed: bool = False
boni: typing.List[bool]
setup_done: bool
ctx: SC2Context
mission_id: int
want_close: bool = False
can_read_game = False
last_received_update = 0
last_received_update: int = 0
def __init__(self, ctx: SC2Context, mission_id):
self.setup_done = False
self.ctx = ctx
self.ctx.last_bot = self
self.mission_id = mission_id
self.boni = [False for _ in range(max_bonus)]
super(ArchipelagoBot, self).__init__()
async def on_step(self, iteration: int):
if self.want_close:
self.want_close = False
await self._client.leave()
return
game_state = 0
if iteration == 0:
if not self.setup_done:
self.setup_done = True
start_items = calculate_items(self.ctx.items_received)
difficulty = calc_difficulty(self.ctx.difficulty)
if self.ctx.difficulty_override >= 0:
difficulty = calc_difficulty(self.ctx.difficulty_override)
else:
difficulty = calc_difficulty(self.ctx.difficulty)
await self.chat_send("ArchipelagoLoad {} {} {} {} {} {} {} {} {} {} {} {} {}".format(
difficulty,
start_items[0], start_items[1], start_items[2], start_items[3], start_items[4],
@@ -479,36 +523,10 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
self.last_received_update = len(self.ctx.items_received)
else:
if self.ctx.announcement_pos < len(self.ctx.announcements):
index = 0
message = ""
while index < len(self.ctx.announcements[self.ctx.announcement_pos]):
message += self.ctx.announcements[self.ctx.announcement_pos][index]["text"]
index += 1
index = 0
start_rem_pos = -1
# Remove unneeded [Color] tags
while index < len(message):
if message[index] == '[':
start_rem_pos = index
index += 1
elif message[index] == ']' and start_rem_pos > -1:
temp_msg = ""
if start_rem_pos > 0:
temp_msg = message[:start_rem_pos]
if index < len(message) - 1:
temp_msg += message[index + 1:]
message = temp_msg
index += start_rem_pos - index
start_rem_pos = -1
else:
index += 1
if not self.ctx.announcements.empty():
message = self.ctx.announcements.get(timeout=1)
await self.chat_send("SendMessage " + message)
self.ctx.announcement_pos += 1
self.ctx.announcements.task_done()
# Archipelago reads the health
for unit in self.all_own_units():
@@ -536,169 +554,97 @@ class ArchipelagoBot(sc2.bot_ai.BotAI):
if game_state & (1 << 1) and not self.mission_completed:
if self.mission_id != 29:
print("Mission Completed")
await self.ctx.send_msgs([
{"cmd": 'LocationChecks', "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id]}])
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id]}])
self.mission_completed = True
else:
print("Game Complete")
await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}])
self.mission_completed = True
if game_state & (1 << 2) and not self.first_bonus:
print("1st Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 1]}])
self.first_bonus = True
if not self.second_bonus and game_state & (1 << 3):
print("2nd Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 2]}])
self.second_bonus = True
if not self.third_bonus and game_state & (1 << 4):
print("3rd Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 3]}])
self.third_bonus = True
if not self.fourth_bonus and game_state & (1 << 5):
print("4th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 4]}])
self.fourth_bonus = True
if not self.fifth_bonus and game_state & (1 << 6):
print("5th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 5]}])
self.fifth_bonus = True
if not self.sixth_bonus and game_state & (1 << 7):
print("6th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 6]}])
self.sixth_bonus = True
if not self.seventh_bonus and game_state & (1 << 8):
print("6th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 7]}])
self.seventh_bonus = True
if not self.eight_bonus and game_state & (1 << 9):
print("6th Bonus Collected")
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 8]}])
self.eight_bonus = True
for x, completed in enumerate(self.boni):
if not completed and game_state & (1 << (x + 2)):
await self.ctx.send_msgs(
[{"cmd": 'LocationChecks',
"locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id + x + 1]}])
self.boni[x] = True
else:
await self.chat_send("LostConnection - Lost connection to game.")
def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx):
objectives_complete = 0
if missions_info[mission].extra_locations > 0:
for i in range(missions_info[mission].extra_locations):
if (missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i) in locations_done:
objectives_complete += 1
else:
unfinished_locations[mission].append(ctx.location_names[
missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i])
return objectives_complete
else:
return -1
def request_unfinished_missions(locations_done, location_table, ui, ctx):
if location_table:
def request_unfinished_missions(ctx: SC2Context):
if ctx.mission_req_table:
message = "Unfinished Missions: "
unlocks = initialize_blank_mission_dict(location_table)
unfinished_locations = initialize_blank_mission_dict(location_table)
unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
unfinished_locations = initialize_blank_mission_dict(ctx.mission_req_table)
unfinished_missions = calc_unfinished_missions(locations_done, location_table, ctx, unlocks=unlocks,
unfinished_locations=unfinished_locations)
_, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks)
message += ", ".join(f"{mark_up_mission_name(mission, location_table, ui,unlocks)}[{location_table[mission].id}] " +
message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " +
mark_up_objectives(
f"[{unfinished_missions[mission]}/{location_table[mission].extra_locations}]",
f"[{len(unfinished_missions[mission])}/"
f"{sum(1 for _ in ctx.locations_for_mission(mission))}]",
ctx, unfinished_locations, mission)
for mission in unfinished_missions)
if ui:
ui.log_panels['All'].on_message_markup(message)
ui.log_panels['Starcraft2'].on_message_markup(message)
if ctx.ui:
ctx.ui.log_panels['All'].on_message_markup(message)
ctx.ui.log_panels['Starcraft2'].on_message_markup(message)
else:
sc2_logger.info(message)
else:
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
def calc_unfinished_missions(locations_done, locations, ctx, unlocks=None, unfinished_locations=None,
available_missions=[]):
def calc_unfinished_missions(ctx: SC2Context, unlocks=None):
unfinished_missions = []
locations_completed = []
if not unlocks:
unlocks = initialize_blank_mission_dict(locations)
unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
if not unfinished_locations:
unfinished_locations = initialize_blank_mission_dict(locations)
if len(available_missions) > 0:
available_missions = []
available_missions.extend(calc_available_missions(locations_done, locations, unlocks))
available_missions = calc_available_missions(ctx, unlocks)
for name in available_missions:
if not locations[name].extra_locations == -1:
objectives_completed = calc_objectives_completed(name, locations, locations_done, unfinished_locations, ctx)
if objectives_completed < locations[name].extra_locations:
objectives = set(ctx.locations_for_mission(name))
if objectives:
objectives_completed = ctx.checked_locations & objectives
if len(objectives_completed) < len(objectives):
unfinished_missions.append(name)
locations_completed.append(objectives_completed)
else:
else: # infer that this is the final mission as it has no objectives
unfinished_missions.append(name)
locations_completed.append(-1)
return {unfinished_missions[i]: locations_completed[i] for i in range(len(unfinished_missions))}
return available_missions, dict(zip(unfinished_missions, locations_completed))
def is_mission_available(mission_id_to_check, locations_done, locations):
unfinished_missions = calc_available_missions(locations_done, locations)
def is_mission_available(ctx: SC2Context, mission_id_to_check):
unfinished_missions = calc_available_missions(ctx)
return any(mission_id_to_check == locations[mission].id for mission in unfinished_missions)
return any(mission_id_to_check == ctx.mission_req_table[mission].id for mission in unfinished_missions)
def mark_up_mission_name(mission, location_table, ui, unlock_table):
def mark_up_mission_name(ctx: SC2Context, mission, unlock_table):
"""Checks if the mission is required for game completion and adds '*' to the name to mark that."""
if location_table[mission].completion_critical:
if ui:
if ctx.mission_req_table[mission].completion_critical:
if ctx.ui:
message = "[color=AF99EF]" + mission + "[/color]"
else:
message = "*" + mission + "*"
else:
message = mission
if ui:
if ctx.ui:
unlocks = unlock_table[mission]
if len(unlocks) > 0:
pre_message = f"[ref={list(location_table).index(mission)}|Unlocks: "
pre_message += ", ".join(f"{unlock}({location_table[unlock].id})" for unlock in unlocks)
pre_message = f"[ref={list(ctx.mission_req_table).index(mission)}|Unlocks: "
pre_message += ", ".join(f"{unlock}({ctx.mission_req_table[unlock].id})" for unlock in unlocks)
pre_message += f"]"
message = pre_message + message + "[/ref]"
@@ -711,7 +657,7 @@ def mark_up_objectives(message, ctx, unfinished_locations, mission):
if ctx.ui:
locations = unfinished_locations[mission]
pre_message = f"[ref={list(ctx.mission_req_table).index(mission)+30}|"
pre_message = f"[ref={list(ctx.mission_req_table).index(mission) + 30}|"
pre_message += "<br>".join(location for location in locations)
pre_message += f"]"
formatted_message = pre_message + message + "[/ref]"
@@ -719,90 +665,91 @@ def mark_up_objectives(message, ctx, unfinished_locations, mission):
return formatted_message
def request_available_missions(locations_done, location_table, ui):
if location_table:
def request_available_missions(ctx: SC2Context):
if ctx.mission_req_table:
message = "Available Missions: "
# Initialize mission unlock table
unlocks = initialize_blank_mission_dict(location_table)
unlocks = initialize_blank_mission_dict(ctx.mission_req_table)
missions = calc_available_missions(locations_done, location_table, unlocks)
missions = calc_available_missions(ctx, unlocks)
message += \
", ".join(f"{mark_up_mission_name(mission, location_table, ui, unlocks)}[{location_table[mission].id}]"
", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}"
f"[{ctx.mission_req_table[mission].id}]"
for mission in missions)
if ui:
ui.log_panels['All'].on_message_markup(message)
ui.log_panels['Starcraft2'].on_message_markup(message)
if ctx.ui:
ctx.ui.log_panels['All'].on_message_markup(message)
ctx.ui.log_panels['Starcraft2'].on_message_markup(message)
else:
sc2_logger.info(message)
else:
sc2_logger.warning("No mission table found, you are likely not connected to a server.")
def calc_available_missions(locations_done, locations, unlocks=None):
def calc_available_missions(ctx: SC2Context, unlocks=None):
available_missions = []
missions_complete = 0
# Get number of missions completed
for loc in locations_done:
if loc % 100 == 0:
for loc in ctx.checked_locations:
if loc % victory_modulo == 0:
missions_complete += 1
for name in locations:
for name in ctx.mission_req_table:
# Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips
if unlocks:
for unlock in locations[name].required_world:
unlocks[list(locations)[unlock-1]].append(name)
for unlock in ctx.mission_req_table[name].required_world:
unlocks[list(ctx.mission_req_table)[unlock - 1]].append(name)
if mission_reqs_completed(name, missions_complete, locations_done, locations):
if mission_reqs_completed(ctx, name, missions_complete):
available_missions.append(name)
return available_missions
def mission_reqs_completed(location_to_check, missions_complete, locations_done, locations):
def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete):
"""Returns a bool signifying if the mission has all requirements complete and can be done
Keyword arguments:
Arguments:
ctx -- instance of SC2Context
locations_to_check -- the mission string name to check
missions_complete -- an int of how many missions have been completed
locations_done -- a list of the location ids that have been complete
locations -- a dict of MissionInfo for mission requirements for this world"""
if len(locations[location_to_check].required_world) >= 1:
"""
if len(ctx.mission_req_table[mission_name].required_world) >= 1:
# A check for when the requirements are being or'd
or_success = False
# Loop through required missions
for req_mission in locations[location_to_check].required_world:
for req_mission in ctx.mission_req_table[mission_name].required_world:
req_success = True
# Check if required mission has been completed
if not (locations[list(locations)[req_mission-1]].id * 100 + SC2WOL_LOC_ID_OFFSET) in locations_done:
if not locations[location_to_check].or_requirements:
if not (ctx.mission_req_table[list(ctx.mission_req_table)[req_mission - 1]].id *
victory_modulo + SC2WOL_LOC_ID_OFFSET) in ctx.checked_locations:
if not ctx.mission_req_table[mission_name].or_requirements:
return False
else:
req_success = False
# Recursively check required mission to see if it's requirements are met, in case !collect has been done
if not mission_reqs_completed(list(locations)[req_mission-1], missions_complete, locations_done,
locations):
if not locations[location_to_check].or_requirements:
if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete):
if not ctx.mission_req_table[mission_name].or_requirements:
return False
else:
req_success = False
# If requirement check succeeded mark or as satisfied
if locations[location_to_check].or_requirements and req_success:
if ctx.mission_req_table[mission_name].or_requirements and req_success:
or_success = True
if locations[location_to_check].or_requirements:
if ctx.mission_req_table[mission_name].or_requirements:
# Return false if or requirements not met
if not or_success:
return False
# Check number of missions
if missions_complete >= locations[location_to_check].number:
if missions_complete >= ctx.mission_req_table[mission_name].number:
return True
else:
return False
@@ -897,7 +844,7 @@ class DllDirectory:
self.set(self._old)
@staticmethod
def get() -> str:
def get() -> typing.Optional[str]:
if sys.platform == "win32":
n = ctypes.windll.kernel32.GetDllDirectoryW(0, None)
buf = ctypes.create_unicode_buffer(n)

178
Utils.py
View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import shutil
import typing
import builtins
import os
@@ -12,12 +11,18 @@ import io
import collections
import importlib
import logging
import decimal
from yaml import load, load_all, dump, SafeLoader
try:
from yaml import CLoader as UnsafeLoader
from yaml import CDumper as Dumper
except ImportError:
from yaml import Loader as UnsafeLoader
from yaml import Dumper
if typing.TYPE_CHECKING:
from tkinter import Tk
else:
Tk = typing.Any
import tkinter
import pathlib
def tuplize_version(version: str) -> Version:
@@ -30,21 +35,13 @@ class Version(typing.NamedTuple):
build: int
__version__ = "0.3.4"
__version__ = "0.3.5"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith('linux')
is_macos = sys.platform == 'darwin'
is_linux = sys.platform.startswith("linux")
is_macos = sys.platform == "darwin"
is_windows = sys.platform in ("win32", "cygwin", "msys")
import jellyfish
from yaml import load, load_all, dump, SafeLoader
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader
def int16_as_bytes(value: int) -> typing.List[int]:
value = value & 0xFFFF
@@ -125,17 +122,18 @@ def home_path(*path: str) -> str:
def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, 'cached_path'):
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'):
if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")):
import shutil
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'):
for fn in ("manifest.json", "host.yaml"):
shutil.copy2(local_path(fn), user_path(fn))
return os.path.join(user_path.cached_path, *path)
@@ -150,11 +148,12 @@ def output_path(*path: str):
return path
def open_file(filename):
if sys.platform == 'win32':
def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
if is_windows:
os.startfile(filename)
else:
open_command = 'open' if sys.platform == 'darwin' else 'xdg-open'
from shutil import which
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
subprocess.call([open_command, filename])
@@ -173,7 +172,9 @@ class UniqueKeyLoader(SafeLoader):
parse_yaml = functools.partial(load, Loader=UniqueKeyLoader)
parse_yamls = functools.partial(load_all, Loader=UniqueKeyLoader)
unsafe_parse_yaml = functools.partial(load, Loader=Loader)
unsafe_parse_yaml = functools.partial(load, Loader=UnsafeLoader)
del load, load_all # should not be used. don't leak their names
def get_cert_none_ssl_context():
@@ -191,11 +192,12 @@ def get_public_ipv4() -> str:
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen('https://checkip.amazonaws.com/', context=ctx).read().decode('utf8').strip()
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx).read().decode("utf8").strip()
except Exception as e:
# noinspection PyBroadException
try:
ip = urllib.request.urlopen('https://v4.ident.me', context=ctx).read().decode('utf8').strip()
except:
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip()
except Exception:
logging.exception(e)
pass # we could be offline, in a local game, so no point in erroring out
return ip
@@ -208,7 +210,7 @@ def get_public_ipv6() -> str:
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen('https://v6.ident.me', context=ctx).read().decode('utf8').strip()
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx).read().decode("utf8").strip()
except Exception as e:
logging.exception(e)
pass # we could be offline, in a local game, or ipv6 may not be available
@@ -309,33 +311,19 @@ def update_options(src: dict, dest: dict, filename: str, keys: list) -> dict:
@cache_argsless
def get_options() -> dict:
if not hasattr(get_options, "options"):
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]
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):
with open(location) as f:
options = parse_yaml(f.read())
for location in locations:
if os.path.exists(location):
with open(location) as f:
options = parse_yaml(f.read())
return update_options(get_default_options(), options, location, list())
get_options.options = update_options(get_default_options(), options, location, list())
break
else:
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
return get_options.options
def get_item_name_from_id(code: int) -> str:
from worlds import lookup_any_item_id_to_name
return lookup_any_item_id_to_name.get(code, f'Unknown item (ID:{code})')
def get_location_name_from_id(code: int) -> str:
from worlds import lookup_any_location_id_to_name
return lookup_any_location_id_to_name.get(code, f'Unknown location (ID:{code})')
raise FileNotFoundError(f"Could not find {filenames[1]} to load options.")
def persistent_store(category: str, key: typing.Any, value: typing.Any):
@@ -344,10 +332,10 @@ def persistent_store(category: str, key: typing.Any, value: typing.Any):
category = storage.setdefault(category, {})
category[key] = value
with open(path, "wt") as f:
f.write(dump(storage))
f.write(dump(storage, Dumper=Dumper))
def persistent_load() -> typing.Dict[dict]:
def persistent_load() -> typing.Dict[str, dict]:
storage = getattr(persistent_load, "storage", None)
if storage:
return storage
@@ -365,8 +353,8 @@ def persistent_load() -> typing.Dict[dict]:
return storage
def get_adjuster_settings(gameName: str):
adjuster_settings = persistent_load().get("adjuster", {}).get(gameName, {})
def get_adjuster_settings(game_name: str):
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
return adjuster_settings
@@ -382,10 +370,10 @@ def get_unique_identifier():
return uuid
safe_builtins = {
safe_builtins = frozenset((
'set',
'frozenset',
}
))
class RestrictedUnpickler(pickle.Unpickler):
@@ -413,8 +401,7 @@ class RestrictedUnpickler(pickle.Unpickler):
if issubclass(obj, self.options_module.Option):
return obj
# Forbid everything else.
raise pickle.UnpicklingError("global '%s.%s' is forbidden" %
(module, name))
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
def restricted_loads(s):
@@ -423,6 +410,9 @@ def restricted_loads(s):
class KeyedDefaultDict(collections.defaultdict):
"""defaultdict variant that uses the missing key as argument to default_factory"""
default_factory: typing.Callable[[typing.Any], typing.Any]
def __missing__(self, key):
self[key] = value = self.default_factory(key)
return value
@@ -432,6 +422,10 @@ def get_text_between(text: str, start: str, end: str) -> str:
return text[text.index(start) + len(start): text.rindex(end)]
def get_text_after(text: str, start: str) -> str:
return text[text.index(start) + len(start):]
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
@@ -493,11 +487,11 @@ def stream_input(stream, queue):
return thread
def tkinter_center_window(window: Tk):
def tkinter_center_window(window: "tkinter.Tk") -> None:
window.update()
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))
x = int(window.winfo_screenwidth() / 2 - window.winfo_reqwidth() / 2)
y = int(window.winfo_screenheight() / 2 - window.winfo_reqheight() / 2)
window.geometry(f"+{x}+{y}")
class VersionException(Exception):
@@ -514,24 +508,27 @@ def chaining_prefix(index: int, labels: typing.Tuple[str]) -> str:
# noinspection PyPep8Naming
def format_SI_prefix(value, power=1000, power_labels=('', 'k', 'M', 'G', 'T', "P", "E", "Z", "Y")) -> str:
def format_SI_prefix(value, power=1000, power_labels=("", "k", "M", "G", "T", "P", "E", "Z", "Y")) -> str:
"""Formats a value into a value + metric/si prefix. More info at https://en.wikipedia.org/wiki/Metric_prefix"""
import decimal
n = 0
value = decimal.Decimal(value)
while value >= power:
limit = power - decimal.Decimal("0.005")
while value >= limit:
value /= power
n += 1
return f"{value.quantize(decimal.Decimal('1.00'))} {chaining_prefix(n, power_labels)}"
def get_fuzzy_ratio(word1: str, word2: str) -> float:
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: typing.Optional[int] = None) \
-> typing.List[typing.Tuple[str, int]]:
import jellyfish
def get_fuzzy_ratio(word1: str, word2: str) -> float:
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
limit: int = limit if limit else len(wordlist)
return list(
map(
@@ -549,18 +546,19 @@ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: ty
def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \
-> typing.Optional[str]:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux:
# prefer native dialog
kdialog = shutil.which('kdialog')
from shutil import which
kdialog = which("kdialog")
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return run(kdialog, f'--title={title}', '--getopenfilename', '.', k_filters)
zenity = shutil.which('zenity')
return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters)
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
return run(zenity, f'--title={title}', '--file-selection', *z_filters)
return run(zenity, f"--title={title}", "--file-selection", *z_filters)
# fall back to tk
try:
@@ -578,10 +576,10 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin
def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split('\n', 1)[0] or None
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
def is_kivy_running():
if 'kivy' in sys.modules:
if "kivy" in sys.modules:
from kivy.app import App
return App.get_running_app() is not None
return False
@@ -591,14 +589,15 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
MessageBox(title, text, error).open()
return
if is_linux and not 'tkinter' in sys.modules:
if is_linux and "tkinter" not in sys.modules:
# prefer native dialog
kdialog = shutil.which('kdialog')
from shutil import which
kdialog = which("kdialog")
if kdialog:
return run(kdialog, f'--title={title}', '--error' if error else '--msgbox', text)
zenity = shutil.which('zenity')
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
zenity = which("zenity")
if zenity:
return run(zenity, f'--title={title}', f'--text={text}', '--error' if error else '--info')
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
# fall back to tk
try:
@@ -613,3 +612,14 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
root.withdraw()
showerror(title, text) if error else showinfo(title, text)
root.update()
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: str) -> str:
parts = element.split(maxsplit=1)
if parts[0].lower() in ignore:
return parts[1].lower()
else:
return element.lower()
return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i))

View File

@@ -12,9 +12,9 @@ ModuleUpdate.update()
# in case app gets imported by something like gunicorn
import Utils
Utils.local_path.cached_path = os.path.dirname(__file__)
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
from WebHostLib import app as raw_app
from WebHostLib import register, app as raw_app
from waitress import serve
from WebHostLib.models import db
@@ -22,14 +22,13 @@ from WebHostLib.autolauncher import autohost, autogen
from WebHostLib.lttpsprites import update_sprites_lttp
from WebHostLib.options import create as create_options_files
from worlds.AutoWorld import AutoWorldRegister
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():
register()
app = raw_app
if os.path.exists(configpath):
import yaml
@@ -43,19 +42,39 @@ def get_app():
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
import json
import shutil
import zipfile
zfile: zipfile.ZipInfo
from worlds.AutoWorld import AutoWorldRegister
worlds = {}
data = []
for game, world in AutoWorldRegister.world_types.items():
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
worlds[game] = world
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
for game, world in worlds.items():
# copy files from world's docs folder to the generated folder
source_path = Utils.local_path(os.path.dirname(sys.modules[world.__module__].__file__), 'docs')
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", game)
files = os.listdir(source_path)
for file in files:
os.makedirs(os.path.dirname(Utils.local_path(target_path, file)), exist_ok=True)
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
target_path = os.path.join(base_target_path, game)
os.makedirs(target_path, exist_ok=True)
if world.zip_path:
zipfile_path = world.zip_path
assert os.path.isfile(zipfile_path), f"{zipfile_path} is not a valid file(path)."
assert zipfile.is_zipfile(zipfile_path), f"{zipfile_path} is not a valid zipfile."
with zipfile.ZipFile(zipfile_path) as zf:
for zfile in zf.infolist():
if not zfile.is_dir() and "/docs/" in zfile.filename:
zf.extract(zfile, target_path)
else:
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
files = os.listdir(source_path)
for file in files:
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
# build a json tutorial dict per game
game_data = {'gameTitle': game, 'tutorials': []}
for tutorial in world.web.tutorials:
@@ -85,7 +104,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
for games in data:
if 'Archipelago' in games['gameTitle']:
generic_data = data.pop(data.index(games))
sorted_data = [generic_data] + sorted(data, key=lambda entry: entry["gameTitle"].lower())
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
return sorted_data

46
WebHostLib/README.md Normal file
View File

@@ -0,0 +1,46 @@
# WebHost
## Contribution Guidelines
**Thank you for your interest in contributing to the Archipelago website!**
Much of the content on the website is generated automatically, but there are some things
that need a personal touch. For those things, we rely on contributions from both the core
team and the community. The current primary maintainer of the website is Farrak Kilhn.
He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`.
### Small Changes
Little changes like adding a button or a couple new select elements are perfectly fine.
Tweaks to style specific to a PR's content are also probably not a problem. For example, if
you build a new page which needs two side by side tables, and you need to write a CSS file
specific to your page, that is perfectly reasonable.
### Content Additions
Once you develop a new feature or add new content the website, make a pull request. It will
be reviewed by the community and there will probably be some discussion around it. Depending
on the size of the feature, and if new styles are required, there may be an additional step
before the PR is accepted wherein Farrak works with the designer to implement styles.
### Restrictions on Style Changes
A professional designer is paid to develop the styles and assets for the Archipelago website.
In an effort to maintain a consistent look and feel, pull requests which *exclusively*
change site styles are rejected. Please note this applies to code which changes the overall
look and feel of the site, not to small tweaks to CSS for your custom page. The intention
behind these restrictions is to maintain a curated feel for the design of the site. If
any PR affects the overall feel of the site but includes additive changes, there will
likely be a conversation about how to implement those changes without compromising the
curated site style. It is therefore worth noting there are a couple files which, if
changed in your pull request, will cause it to draw additional scrutiny.
These closely guarded files are:
- `globalStyles.css`
- `islandFooter.css`
- `landing.css`
- `markdown.css`
- `tooltip.css`
### Site Themes
There are several themes available for game pages. It is possible to request a new theme in
the `#art-and-design` channel on Discord. Because themes are created by the designer, they
are not free, and take some time to create. Farrak works closely with the designer to implement
these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year
are added. If a proposed theme seems like a cool idea and the community likes it, there is a
good chance it will become a reality.

View File

@@ -3,13 +3,13 @@ import uuid
import base64
import socket
import jinja2.exceptions
from pony.flask import Pony
from flask import Flask, request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from flask import Flask
from flask_caching import Cache
from flask_compress import Compress
from worlds.AutoWorld import AutoWorldRegister
from werkzeug.routing import BaseConverter
from Utils import title_sorted
from .models import *
UPLOAD_FOLDER = os.path.relpath('uploads')
@@ -53,8 +53,6 @@ app.config["PATCH_TARGET"] = "archipelago.gg"
cache = Cache(app)
Compress(app)
from werkzeug.routing import BaseConverter
class B64UUIDConverter(BaseConverter):
@@ -68,174 +66,18 @@ class B64UUIDConverter(BaseConverter):
# short UUID
app.url_map.converters["suuid"] = B64UUIDConverter
app.jinja_env.filters['suuid'] = lambda value: base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii')
# has automatic patch integration
import Patch
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
app.jinja_env.filters["title_sorted"] = title_sorted
def get_world_theme(game_name: str):
if game_name in AutoWorldRegister.world_types:
return AutoWorldRegister.world_types[game_name].web.theme
return 'grass'
def register():
"""Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem."""
# has automatic patch integration
import Patch
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: game_name in Patch.AutoPatchRegister.patch_types
from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc
@app.before_request
def register_session():
session.permanent = True # technically 31 days after the last visit
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):
return render_template('404.html'), 404
# Start Playing Page
@app.route('/start-playing')
def start_playing():
return render_template(f"startPlaying.html")
@app.route('/weighted-settings')
def weighted_settings():
return render_template(f"weighted-settings.html")
# Player settings pages
@app.route('/games/<string:game>/player-settings')
def player_settings(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, theme=get_world_theme(game))
# List of supported games
@app.route('/games')
def games():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
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, theme=get_world_theme(game))
@app.route('/tutorial/')
def tutorial_landing():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("tutorialLanding.html")
@app.route('/faq/<string:lang>/')
def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/')
def terms(lang):
return render_template("glossary.html", lang=lang)
@app.route('/seed/<suuid:seed>')
def view_seed(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
@app.route('/new_room/<suuid:seed>')
def new_room(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
commit()
return redirect(url_for("host_room", room=room.id))
def _read_log(path: str):
if os.path.exists(path):
with open(path, encoding="utf-8-sig") as log:
yield from log
else:
yield f"Logfile {path} does not exist. " \
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<suuid:room>')
def display_log(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
return "Access Denied", 403
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def host_room(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if request.method == "POST":
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
with db_session:
room.last_activity = datetime.utcnow() # will trigger a spinup, if it's not already running
return render_template("hostRoom.html", room=room)
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static/static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
@app.route('/discord')
def discord():
return redirect("https://discord.gg/archipelago")
@app.route('/datapackage')
@cache.cached()
def get_datapackge():
"""A pretty print version of /api/datapackage"""
from worlds import network_data_package
import json
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
@app.route('/index')
@app.route('/sitemap')
def get_sitemap():
available_games = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
available_games.append(game)
return render_template("siteMap.html", games=available_games)
from WebHostLib.customserver import run_server_process
from . import tracker, upload, landing, check, generate, downloads, api, stats # to trigger app routing picking up on it
app.register_blueprint(api.api_endpoints)
app.register_blueprint(api.api_endpoints)

View File

@@ -32,14 +32,14 @@ def room_info(room: UUID):
@api_endpoints.route('/datapackage')
@cache.cached()
def get_datapackge():
def get_datapackage():
from worlds import network_data_package
return network_data_package
@api_endpoints.route('/datapackage_version')
@cache.cached()
def get_datapackge_versions():
def get_datapackage_versions():
from worlds import network_data_package, AutoWorldRegister
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
version_package["version"] = network_data_package["version"]

View File

@@ -184,7 +184,7 @@ class MultiworldInstance():
logging.info(f"Spinning up {self.room_id}")
process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.room_id, self.ponyconfig),
args=(self.room_id, self.ponyconfig, get_static_server_data()),
name="MultiHost")
process.start()
# bind after start to prevent thread sync issues with guardian.
@@ -238,5 +238,5 @@ def run_guardian():
from .models import Room, Generation, STATE_QUEUED, STATE_STARTED, STATE_ERROR, db, Seed
from .customserver import run_server_process
from .customserver import run_server_process, get_static_server_data
from .generate import gen_game

View File

@@ -9,12 +9,13 @@ import time
import random
import pickle
import logging
import datetime
import Utils
from .models import *
from .models import db_session, Room, select, commit, Command, db
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
class CustomClientMessageProcessor(ClientMessageProcessor):
@@ -39,7 +40,7 @@ class CustomClientMessageProcessor(ClientMessageProcessor):
import MultiServer
MultiServer.client_message_processor = CustomClientMessageProcessor
del (MultiServer)
del MultiServer
class DBCommandProcessor(ServerCommandProcessor):
@@ -48,12 +49,20 @@ class DBCommandProcessor(ServerCommandProcessor):
class WebHostContext(Context):
def __init__(self):
def __init__(self, static_server_data: dict):
# static server data is used during _load_game_data to load required data,
# without needing to import worlds system, which takes quite a bit of memory
self.static_server_data = static_server_data
super(WebHostContext, self).__init__("", 0, "", "", 1, 40, True, "enabled", "enabled", "enabled", 0, 2)
del self.static_server_data
self.main_loop = asyncio.get_running_loop()
self.video = {}
self.tags = ["AP", "WebHost"]
def _load_game_data(self):
for key, value in self.static_server_data.items():
setattr(self, key, value)
def listen_to_db_commands(self):
cmdprocessor = DBCommandProcessor(self)
@@ -94,7 +103,7 @@ class WebHostContext(Context):
room.multisave = pickle.dumps(self.get_save())
# saving only occurs on activity, so we can "abuse" this information to mark this as last_activity
if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again
room.last_activity = datetime.utcnow()
room.last_activity = datetime.datetime.utcnow()
return True
def get_save(self) -> dict:
@@ -107,14 +116,32 @@ def get_random_port():
return random.randint(49152, 65535)
def run_server_process(room_id, ponyconfig: dict):
@cache_argsless
def get_static_server_data() -> dict:
import worlds
data = {
"forced_auto_forfeits": {},
"non_hintable_names": {},
"gamespackage": worlds.network_data_package["games"],
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
data["forced_auto_forfeits"][world_name] = world.forced_auto_forfeit
data["non_hintable_names"][world_name] = world.hint_blacklist
return data
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
# establish DB connection for multidata and multisave
db.bind(**ponyconfig)
db.generate_mapping(check_tables=False)
async def main():
Utils.init_logging(str(room_id), write_mode="a")
ctx = WebHostContext()
ctx = WebHostContext(static_server_data)
ctx.load(room_id)
ctx.init_save()

View File

@@ -32,18 +32,21 @@ def download_patch(room_id, patch_id):
new_zip.writestr("archipelago.json", json.dumps(manifest))
else:
new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9)
if "patch_file_ending" in manifest:
patch_file_ending = manifest["patch_file_ending"]
else:
patch_file_ending = AutoPatchRegister.patch_types[patch.game].patch_file_ending
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}"
f"{patch_file_ending}"
new_file.seek(0)
return send_file(new_file, as_attachment=True, attachment_filename=fname)
return send_file(new_file, as_attachment=True, download_name=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]}"
return send_file(patch_data, as_attachment=True, attachment_filename=fname)
return send_file(patch_data, as_attachment=True, download_name=fname)
@app.route("/dl_spoiler/<suuid:seed_id>")
@@ -66,7 +69,7 @@ def download_slot_file(room_id, player_id: int):
from worlds.minecraft import mc_update_output
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port)
return send_file(io.BytesIO(data), as_attachment=True, attachment_filename=fname)
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
elif slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist():
@@ -82,7 +85,7 @@ def download_slot_file(room_id, player_id: int):
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
else:
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, attachment_filename=fname)
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)
@app.route("/templates")

173
WebHostLib/misc.py Normal file
View File

@@ -0,0 +1,173 @@
import datetime
import os
import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
from .models import count, Seed, commit, Room, db_session, Command, UUID, uuid4
from worlds.AutoWorld import AutoWorldRegister
from . import app, cache
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
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):
return render_template('404.html'), 404
# Start Playing Page
@app.route('/start-playing')
def start_playing():
return render_template(f"startPlaying.html")
@app.route('/weighted-settings')
def weighted_settings():
return render_template(f"weighted-settings.html")
# Player settings pages
@app.route('/games/<string:game>/player-settings')
def player_settings(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, theme=get_world_theme(game))
# List of supported games
@app.route('/games')
def games():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
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, theme=get_world_theme(game))
@app.route('/tutorial/')
def tutorial_landing():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("tutorialLanding.html")
@app.route('/faq/<string:lang>/')
def faq(lang):
return render_template("faq.html", lang=lang)
@app.route('/glossary/<string:lang>/')
def terms(lang):
return render_template("glossary.html", lang=lang)
@app.route('/seed/<suuid:seed>')
def view_seed(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
return render_template("viewSeed.html", seed=seed, slot_count=count(seed.slots))
@app.route('/new_room/<suuid:seed>')
def new_room(seed: UUID):
seed = Seed.get(id=seed)
if not seed:
abort(404)
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
commit()
return redirect(url_for("host_room", room=room.id))
def _read_log(path: str):
if os.path.exists(path):
with open(path, encoding="utf-8-sig") as log:
yield from log
else:
yield f"Logfile {path} does not exist. " \
f"Likely a crash during spinup of multiworld instance or it is still spinning up."
@app.route('/log/<suuid:room>')
def display_log(room: UUID):
room = Room.get(id=room)
if room is None:
return abort(404)
if room.owner == session["_id"]:
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
return "Access Denied", 403
@app.route('/room/<suuid:room>', methods=['GET', 'POST'])
def host_room(room: UUID):
room: Room = Room.get(id=room)
if room is None:
return abort(404)
if request.method == "POST":
if room.owner == session["_id"]:
cmd = request.form["cmd"]
if cmd:
Command(room=room, commandtext=cmd)
commit()
now = datetime.datetime.utcnow()
# indicate that the page should reload to get the assigned port
should_refresh = not room.last_port and now - room.creation_time < datetime.timedelta(seconds=3)
with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running
return render_template("hostRoom.html", room=room, should_refresh=should_refresh)
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static/static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
@app.route('/discord')
def discord():
return redirect("https://discord.gg/archipelago")
@app.route('/datapackage')
@cache.cached()
def get_datapackage():
"""A pretty print version of /api/datapackage"""
from worlds import network_data_package
import json
return Response(json.dumps(network_data_package, indent=4), mimetype="text/plain")
@app.route('/index')
@app.route('/sitemap')
def get_sitemap():
available_games = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
available_games.append(game)
return render_template("siteMap.html", games=available_games)

View File

@@ -1,6 +1,6 @@
import logging
import os
from Utils import __version__
from Utils import __version__, local_path
from jinja2 import Template
import yaml
import json
@@ -9,14 +9,13 @@ import typing
from worlds.AutoWorld import AutoWorldRegister
import Options
target_folder = os.path.join("WebHostLib", "static", "generated")
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
"exclude_locations"}
def create():
os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True)
target_folder = local_path("WebHostLib", "static", "generated")
os.makedirs(os.path.join(target_folder, "configs"), exist_ok=True)
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
data = {}
@@ -49,6 +48,11 @@ def create():
return list(default_value)
return default_value
def get_html_doc(option_type: type(Options.Option)) -> str:
if not option_type.__doc__:
return "Please document me!"
return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip()
weighted_settings = {
"baseOptions": {
"description": "Generated by https://archipelago.gg/",
@@ -60,13 +64,17 @@ def create():
for game_name, world in AutoWorldRegister.world_types.items():
all_options = {**Options.per_game_common_options, **world.options}
res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render(
all_options = {**Options.per_game_common_options, **world.option_definitions}
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
file_data = f.read()
res = Template(file_data).render(
options=all_options,
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
dictify_range=dictify_range, default_converter=default_converter,
)
del file_data
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
f.write(res)
@@ -88,7 +96,7 @@ def create():
game_options[option_name] = this_option = {
"type": "select",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
"description": get_html_doc(option),
"defaultValue": None,
"options": []
}
@@ -110,18 +118,18 @@ def create():
if option.default == "random":
this_option["defaultValue"] = "random"
elif hasattr(option, "range_start") and hasattr(option, "range_end"):
elif issubclass(option, Options.Range):
game_options[option_name] = {
"type": "range",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
"description": get_html_doc(option),
"defaultValue": option.default if hasattr(
option, "default") and option.default != "random" else option.range_start,
"min": option.range_start,
"max": option.range_end,
}
if hasattr(option, "special_range_names"):
if issubclass(option, Options.SpecialRange):
game_options[option_name]["type"] = 'special_range'
game_options[option_name]["value_names"] = {}
for key, val in option.special_range_names.items():
@@ -131,22 +139,22 @@ def create():
game_options[option_name] = {
"type": "items-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
"description": get_html_doc(option),
}
elif getattr(option, "verify_location_name", False):
game_options[option_name] = {
"type": "locations-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
"description": get_html_doc(option),
}
elif hasattr(option, "valid_keys"):
elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet):
if option.valid_keys:
game_options[option_name] = {
"type": "custom-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": option.__doc__ if option.__doc__ else "Please document me!",
"description": get_html_doc(option),
"options": list(option.valid_keys),
}

View File

@@ -1,7 +1,7 @@
flask>=2.1.3
flask>=2.2.2
pony>=0.7.16
waitress>=2.1.1
waitress>=2.1.2
Flask-Caching>=2.0.1
Flask-Compress>=1.12
Flask-Limiter>=2.5.0
Flask-Limiter>=2.6.2
bokeh>=2.4.3

View File

@@ -102,9 +102,15 @@ const buildOptionsTable = (settings, romOpts = false) => {
// td Left
const tdl = document.createElement('td');
const label = document.createElement('label');
label.textContent = `${settings[setting].displayName}: `;
label.setAttribute('for', setting);
label.setAttribute('data-tooltip', settings[setting].description);
label.innerText = `${settings[setting].displayName}:`;
const questionSpan = document.createElement('span');
questionSpan.classList.add('interactive');
questionSpan.setAttribute('data-tooltip', settings[setting].description);
questionSpan.innerText = '(?)';
label.appendChild(questionSpan);
tdl.appendChild(label);
tr.appendChild(tdl);

View File

@@ -56,7 +56,3 @@
#file-input{
display: none;
}
.interactive{
color: #ffef00;
}

View File

@@ -105,3 +105,7 @@ h5, h6{
margin-bottom: 20px;
background-color: #ffff00;
}
.interactive{
color: #ffef00;
}

View File

@@ -14,7 +14,6 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
/* Base styles for the element that has a tooltip */
[data-tooltip], .tooltip {
position: relative;
cursor: pointer;
}
/* Base styles for the entire tooltip */
@@ -55,14 +54,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
/** Content styles */
.tooltip:after, [data-tooltip]:after {
width: 260px;
z-index: 10000;
padding: 8px;
width: 160px;
border-radius: 4px;
background-color: #000;
background-color: hsla(0, 0%, 20%, 0.9);
color: #fff;
content: attr(data-tooltip);
white-space: pre-wrap;
font-size: 14px;
line-height: 1.2;
}

View File

@@ -18,15 +18,16 @@ from .models import Room
PLOT_WIDTH = 600
def get_db_data() -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]:
def get_db_data(known_games: str) -> typing.Tuple[typing.Dict[str, int], typing.Dict[datetime.date, typing.Dict[str, int]]]:
games_played = defaultdict(Counter)
total_games = Counter()
cutoff = date.today()-timedelta(days=30)
room: Room
for room in select(room for room in Room if room.creation_time >= cutoff):
for slot in room.seed.slots:
total_games[slot.game] += 1
games_played[room.creation_time.date()][slot.game] += 1
if slot.game in known_games:
total_games[slot.game] += 1
games_played[room.creation_time.date()][slot.game] += 1
return total_games, games_played
@@ -73,10 +74,12 @@ def create_game_played_figure(all_games_data: typing.Dict[datetime.date, typing.
@app.route('/stats')
@cache.memoize(timeout=60 * 60) # regen once per hour should be plenty
def stats():
from worlds import network_data_package
known_games = set(network_data_package["games"])
plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date",
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500)
total_games, games_played = get_db_data()
total_games, games_played = get_db_data(known_games)
days = sorted(games_played)
color_palette = get_color_palette(len(total_games))

View File

@@ -41,12 +41,11 @@
<tbody>
<tr>
<td>
<label for="forfeit_mode">Forfeit Permission:</label>
<span
class="interactive"
data-tooltip="A forfeit releases all remaining items from the locations
in your world.">(?)
</span>
<label for="forfeit_mode">Forfeit Permission:
<span class="interactive" data-tooltip="A forfeit releases all remaining items from the locations in your world.">
(?)
</span>
</label>
</td>
<td>
<select name="forfeit_mode" id="forfeit_mode">
@@ -63,12 +62,11 @@
<tr>
<td>
<label for="collect_mode">Collect Permission:</label>
<span
class="interactive"
data-tooltip="A collect releases all of your remaining items to you
from across the multiworld.">(?)
</span>
<label for="collect_mode">Collect Permission:
<span class="interactive" data-tooltip="A collect releases all of your remaining items to you from across the multiworld.">
(?)
</span>
</label>
</td>
<td>
<select name="collect_mode" id="collect_mode">
@@ -85,12 +83,11 @@
<tr>
<td>
<label for="remaining_mode">Remaining Permission:</label>
<span
class="interactive"
data-tooltip="Remaining lists all items still in your world by name only."
>(?)
</span>
<label for="remaining_mode">Remaining Permission:
<span class="interactive" data-tooltip="Remaining lists all items still in your world by name only.">
(?)
</span>
</label>
</td>
<td>
<select name="remaining_mode" id="remaining_mode">
@@ -106,11 +103,11 @@
</tr>
<tr>
<td>
<label for="item_cheat">Item Cheat:</label>
<span
class="interactive"
data-tooltip="Allows players to use the !getitem command.">(?)
</span>
<label for="item_cheat">Item Cheat:
<span class="interactive" data-tooltip="Allows players to use the !getitem command.">
(?)
</span>
</label>
</td>
<td>
<select name="item_cheat" id="item_cheat">
@@ -131,12 +128,11 @@
<tbody>
<tr>
<td>
<label for="hint_cost"> Hint Cost:</label>
<span
class="interactive"
data-tooltip="After gathering this many checks, players can !hint <itemname>
to get the location of that hint item.">(?)
</span>
<label for="hint_cost"> Hint Cost:
<span class="interactive" data-tooltip="After gathering this many checks, players can !hint <itemname> to get the location of that hint item.">
(?)
</span>
</label>
</td>
<td>
<select name="hint_cost" id="hint_cost">
@@ -150,11 +146,11 @@
</tr>
<tr>
<td>
<label for="server_password">Server Password:</label>
<span
class="interactive"
data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command.">(?)
</span>
<label for="server_password">Server Password:
<span class="interactive" data-tooltip="Allows for issuing of server console commands from any text client or in-game client using the !admin command.">
(?)
</span>
</label>
</td>
<td>
<input id="server_password" name="server_password">
@@ -162,23 +158,22 @@
</tr>
<tr>
<td>
<label for="plando_options">Plando Options:</label>
<span
class="interactive"
data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information.">(?)
Plando Options:
<span class="interactive" data-tooltip="Allows players to plan some of the randomization. See the 'Archipelago Plando Guide' in 'Setup Guides' for more information.">
(?)
</span>
</td>
<td>
<input type="checkbox" name="plando_bosses" value="bosses" checked>
<input type="checkbox" id="plando_bosses" name="plando_bosses" value="bosses" checked>
<label for="plando_bosses">Bosses</label><br>
<input type="checkbox" name="plando_items" value="items" checked>
<input type="checkbox" id="plando_items" name="plando_items" value="items" checked>
<label for="plando_items">Items</label><br>
<input type="checkbox" name="plando_connections" value="connections" checked>
<input type="checkbox" id="plando_connections" name="plando_connections" value="connections" checked>
<label for="plando_connections">Connections</label><br>
<input type="checkbox" name="plando_texts" value="texts" checked>
<input type="checkbox" id="plando_texts" name="plando_texts" value="texts" checked>
<label for="plando_texts">Text</label>
</td>
</tr>

View File

@@ -2,6 +2,7 @@
{% import "macros.html" as macros %}
{% block head %}
<title>Multiworld {{ room.id|suuid }}</title>
{% if should_refresh %}<meta http-equiv="refresh" content="2">{% endif %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/hostRoom.css") }}"/>
{% endblock %}

View File

@@ -6,8 +6,6 @@
-
<a href="https://github.com/ArchipelagoMW/Archipelago">Source Code</a>
-
<a href="https://github.com/ArchipelagoMW/Archipelago/wiki">Wiki</a>
-
<a href="https://github.com/ArchipelagoMW/Archipelago/graphs/contributors">Contributors</a>
-
<a href="https://github.com/ArchipelagoMW/Archipelago/issues">Bug Report</a>

View File

@@ -1,7 +1,7 @@
{% extends 'pageWrapper.html' %}
{% block head %}
<title>Player Settings</title>
<title>Supported Games</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/supportedGames.css") }}" />
{% endblock %}
@@ -10,7 +10,8 @@
{% include 'header/oceanHeader.html' %}
<div id="games" class="markdown">
<h1>Currently Supported Games</h1>
{% for game_name, world in worlds.items() | sort(attribute=0) %}
{% for game_name in worlds | title_sorted %}
{% set world = worlds[game_name] %}
<h2>{{ game_name }}</h2>
<p>
{{ world.__doc__ | default("No description provided.", true) }}<br />

View File

@@ -11,7 +11,7 @@ from worlds.alttp import Items
from WebHostLib import app, cache, Room
from Utils import restricted_loads
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
from MultiServer import get_item_name_from_id, Context
from MultiServer import Context
from NetUtils import SlotType
alttp_icons = {
@@ -987,10 +987,10 @@ def getTracker(tracker: UUID):
if game_state == 30:
inventory[team][player][106] = 1 # Triforce
player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1) if playernumber not in groups}
player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1) if playernumber not in groups}
player_big_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
player_small_key_locations = {playernumber: set() for playernumber in range(1, len(names[0]) + 1)}
for loc_data in locations.values():
for values in loc_data.values():
for values in loc_data.values():
item_id, item_player, flags = values
if item_id in ids_big_key:
@@ -1021,7 +1021,7 @@ def getTracker(tracker: UUID):
for (team, player), data in multisave.get("video", []):
video[(team, player)] = data
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=get_item_name_from_id,
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name,
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons,
multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas,

View File

@@ -97,6 +97,11 @@ local extensionConsumableLookup = {
[443] = 0x3F
}
local noOverworldItemsLookup = {
[499] = 0x2B,
[500] = 0x12,
}
local itemMessages = {}
local consumableStacks = nil
local prevstate = ""
@@ -341,7 +346,7 @@ function processBlock(block)
-- This is a key item
memoryLocation = memoryLocation - 0x0E0
wU8(memoryLocation, 0x01)
elseif v >= 0x1E0 then
elseif v >= 0x1E0 and v <= 0x1F2 then
-- This is a movement item
-- Minus Offset (0x100) - movement offset (0xE0)
memoryLocation = memoryLocation - 0x1E0
@@ -351,7 +356,10 @@ function processBlock(block)
else
wU8(memoryLocation, 0x01)
end
elseif v >= 0x1F3 and v <= 0x1F4 then
-- NoOverworld special items
memoryLocation = noOverworldItemsLookup[v]
wU8(memoryLocation, 0x01)
elseif v >= 0x16C and v <= 0x1AF then
-- This is a gold item
amountToAdd = goldLookup[v]

View File

@@ -0,0 +1,25 @@
# apworld Specification
Archipelago depends on worlds to provide game-specific details like items, locations and output generation.
Those are located in the `worlds/` folder (source) or `<insall dir>/lib/worlds/` (when installed).
See [world api.md](world api.md) for details.
apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld`
file into the worlds folder.
## File Format
apworld files are zip archives with the case-sensitive file ending `.apworld`.
The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in
the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`.
## Metadata
No metadata is specified yet.
## Extra Data
The zip can contain arbitrary files in addition what was specified above.

11
docs/code_of_conduct.md Normal file
View File

@@ -0,0 +1,11 @@
# 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:
* Be welcoming and inclusive in tone and language.
* Be respectful of others and their abilities.
* Show empathy when speaking with others.
* Be gracious and accept feedback and constructive criticism.
These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private ones, such as private messaging or emails.
Any incidents of abuse may be reported directly to ijwu at hmfarran@gmail.com.

12
docs/contributing.md Normal file
View File

@@ -0,0 +1,12 @@
# Contributing
Contributions are welcome. We have a few requests of any new contributors.
* Ensure that all changes which affect logic are covered by unit tests.
* Do not introduce any unit test failures/regressions.
* Follow styling as designated in our [styling documentation](/docs/style.md).
Otherwise, we tend to judge code on a case to case basis.
For adding a new game to Archipelago and other documentation on how Archipelago functions, please see
[the docs folder](docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev
channel in our [Discord](https://archipelago.gg/discord).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 KiB

View File

@@ -69,6 +69,12 @@ flowchart LR
end
SNI <-- Various, depending on SNES device --> SMZ
%% Donkey Kong Country 3
subgraph Donkey Kong Country 3
DK3[SNES]
end
SNI <-- Various, depending on SNES device --> DK3
%% Native Clients or Games
%% Games or clients which compile to native or which the client is integrated in the game.
subgraph "Native"
@@ -82,10 +88,12 @@ flowchart LR
MT[Meritous]
TW[The Witness]
SA2B[Sonic Adventure 2: Battle]
DS3[Dark Souls 3]
APCLIENTPP <--> SOE
APCLIENTPP <--> MT
APCLIENTPP <-- The Witness Randomizer --> TW
APCLIENTPP <--> DS3
APCPP <--> SM64
APCPP <--> V6
APCPP <--> SA2B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -13,9 +13,18 @@ These steps should be followed in order to establish a gameplay connection with
In the case that the client does not authenticate properly and receives a [ConnectionRefused](#ConnectionRefused) then the server will maintain the connection and allow for follow-up [Connect](#Connect) packet.
There are libraries available that implement this network protocol in [Python](https://github.com/ArchipelagoMW/Archipelago/blob/main/CommonClient.py), [Java](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Java), [.Net](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Net) and [C++](https://github.com/black-sliver/apclientpp)
There are also a number of community-supported libraries available that implement this network protocol to make integrating with Archipelago easier.
For Super Nintendo games there are clients available in either [Node](https://github.com/ArchipelagoMW/SuperNintendoClient) or [Python](https://github.com/ArchipelagoMW/Archipelago/blob/main/SNIClient.py), There are also game specific clients available for [The Legend of Zelda: Ocarina of Time](https://github.com/ArchipelagoMW/Z5Client) or [Final Fantasy 1](https://github.com/ArchipelagoMW/Archipelago/blob/main/FF1Client.py)
| Language/Runtime | Project | Remarks |
|-------------------------------|----------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------|
| Python | [Archipelago CommonClient](https://github.com/ArchipelagoMW/Archipelago/blob/main/CommonClient.py) | |
| | [Archipelago SNIClient](https://github.com/ArchipelagoMW/Archipelago/blob/main/SNIClient.py) | For Super Nintendo Game Support; Utilizes [SNI](https://github.com/alttpo/sni). |
| JVM (Java / Kotlin) | [Archipelago.MultiClient.Java](https://github.com/ArchipelagoMW/Archipelago.MultiClient.Java) | |
| .NET (C# / C++ / F# / VB.NET) | [Archipelago.MultiClient.Net](https://www.nuget.org/packages/Archipelago.MultiClient.Net) | |
| C++ | [apclientpp](https://github.com/black-sliver/apclientpp) | almost-header-only |
| | [APCpp](https://github.com/N00byKing/APCpp) | CMake |
| JavaScript / TypeScript | [archipelago.js](https://www.npmjs.com/package/archipelago.js) | Browser and Node.js Supported |
| Haxe | [hxArchipelago](https://lib.haxe.org/p/hxArchipelago) | |
## Synchronizing Items
When the client receives a [ReceivedItems](#ReceivedItems) packet, if the `index` argument does not match the next index that the client expects then it is expected that the client will re-sync items with the server. This can be accomplished by sending the server a [Sync](#Sync) packet and then a [LocationChecks](#LocationChecks) packet.
@@ -152,7 +161,8 @@ The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring:
All arguments for this packet are optional, only changes are sent.
### Print
Sent to clients purely to display a message to the player.
Sent to clients purely to display a message to the player.
* *Deprecation warning: clients that connect with version 0.3.5 or higher will nolonger recieve Print packets, instead all messsages are send as [PrintJSON](#PrintJSON)*
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
@@ -164,10 +174,21 @@ Sent to clients purely to display a message to the player. This packet differs f
| Name | Type | Notes |
| ---- | ---- | ----- |
| data | list\[[JSONMessagePart](#JSONMessagePart)\] | Type of this part of the message. |
| type | str | May be present to indicate the nature of this message. Known types are Hint and ItemSend. |
| type | str | May be present to indicate the [PrintJsonType](#PrintJsonType) of this message. |
| receiving | int | Is present if type is Hint or ItemSend and marks the destination player's ID. |
| item | [NetworkItem](#NetworkItem) | Is present if type is Hint or ItemSend and marks the source player id, location id, item id and item flags. |
| found | bool | Is present if type is Hint, denotes whether the location hinted for was checked. |
| countdown | int | Is present if type is `Countdown`, denotes the amount of seconds remaining on the countdown. |
##### PrintJsonType
PrintJsonType indicates the type of [PrintJson](#PrintJson) packet, different types can be handled differently by the client and can also contain additional arguments. When receiving an unknown type the data's list\[[JSONMessagePart](#JSONMessagePart)\] should still be printed as normal.
Currently defined types are:
| Type | Notes |
| ---- | ----- |
| ItemSend | The message is in response to a player receiving an item. |
| Hint | The message is in response to a player hinting. |
| Countdown | The message contains information about the current server Countdown. |
### DataPackage
Sent to clients to provide what is known as a 'data package' which contains information to enable a client to most easily communicate with the Archipelago server. Contents include things like location id to name mappings, among others; see [Data Package Contents](#Data-Package-Contents) for more info.
@@ -501,7 +522,7 @@ Color options:
* green_bg
* yellow_bg
* blue_bg
* purple_bg
* magenta_bg
* cyan_bg
* white_bg

View File

@@ -56,3 +56,8 @@ SNI is required to use SNIClient. If not integrated into the project, it has to
You can get the latest SNI release at [SNI Github releases](https://github.com/alttpo/sni/releases).
It should be dropped as "SNI" into the root folder of the project. Alternatively, you can point the sni setting in
host.yaml at your SNI folder.
## Running tests
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.

View File

@@ -86,7 +86,7 @@ inside a World object.
Players provide customized settings for their World in the form of yamls.
Those are accessible through `self.world.<option_name>[self.player]`. A dict
of valid options has to be provided in `self.options`. Options are automatically
of valid options has to be provided in `self.option_definitions`. Options are automatically
added to the `World` object for easy access.
### World Options
@@ -252,7 +252,7 @@ to describe it and a `display_name` property for display on the website and in
spoiler logs.
The actual name as used in the yaml is defined in a `dict[str, Option]`, that is
assigned to the world under `self.options`.
assigned to the world under `self.option_definitions`.
Common option types are `Toggle`, `DefaultOnToggle`, `Choice`, `Range`.
For more see `Options.py` in AP's base directory.
@@ -328,7 +328,7 @@ from .Options import mygame_options # import the options dict
class MyGameWorld(World):
#...
options = mygame_options # assign the options dict to the world
option_definitions = mygame_options # assign the options dict to the world
#...
```
@@ -365,7 +365,7 @@ class MyGameLocation(Location): # or from Locations import MyGameLocation
class MyGameWorld(World):
"""Insert description of the world/game here."""
game: str = "My Game" # name of the game/world
options = mygame_options # options the player can set
option_definitions = mygame_options # options the player can set
topology_present: bool = True # show path to required location checks in spoiler
remote_items: bool = False # True if all items come from the server
remote_start_inventory: bool = False # True if start inventory comes from the server

View File

@@ -3,6 +3,6 @@ websockets>=10.3
PyYAML>=6.0
jellyfish>=0.9.0
jinja2>=3.1.2
schema>=0.7.4
schema>=0.7.5
kivy>=2.1.0
bsdiff4>=1.2.2

View File

@@ -16,7 +16,7 @@ class TestDungeon(unittest.TestCase):
def setUp(self):
self.world = MultiWorld(1)
args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args)
self.world.set_default_common_options()

View File

@@ -49,8 +49,7 @@ class PlayerDefinition(object):
region_name = "player" + str(self.id) + region_tag
region = Region("player" + str(self.id) + region_tag, RegionType.Generic,
"Region Hint", self.id, self.world)
self.locations += generate_locations(size,
self.id, None, region, region_tag)
self.locations += generate_locations(size, self.id, None, region, region_tag)
entrance = Entrance(self.id, region_name + "_entrance", parent)
parent.exits.append(entrance)

View File

@@ -52,3 +52,13 @@ class TestIDs(unittest.TestCase):
else:
for location_id in world_type.location_id_to_name:
self.assertGreater(location_id, 0)
def testDuplicateItemIDs(self):
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):
self.assertEqual(len(world_type.item_id_to_name), len(world_type.item_name_to_id))
def testDuplicateLocationIDs(self):
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):
self.assertEqual(len(world_type.location_id_to_name), len(world_type.location_name_to_id))

View File

@@ -1,5 +1,6 @@
import unittest
from worlds.AutoWorld import AutoWorldRegister
from . import setup_default_world
class TestBase(unittest.TestCase):
@@ -29,3 +30,17 @@ class TestBase(unittest.TestCase):
with self.subTest(group_name, group_name=group_name):
for item in items:
self.assertIn(item, world_type.item_name_to_id)
def testItemCountGreaterEqualLocations(self):
for game_name, world_type in AutoWorldRegister.world_types.items():
if game_name in {"Final Fantasy"}:
continue
with self.subTest("Game", game=game_name):
world = setup_default_world(world_type)
location_count = sum(0 if location.event or location.item else 1 for location in world.get_locations())
self.assertGreaterEqual(
len(world.itempool),
location_count,
f"{game_name} Item count MUST meet or exceede the number of locations",
)

View File

@@ -12,7 +12,7 @@ def setup_default_world(world_type) -> MultiWorld:
world.player_name = {1: "Tester"}
world.set_seed()
args = Namespace()
for name, option in world_type.options.items():
for name, option in world_type.option_definitions.items():
setattr(args, name, {1: option.from_any(option.default)})
world.set_options(args)
world.set_default_common_options()

View File

@@ -16,7 +16,7 @@ class TestInverted(TestBase):
def setUp(self):
self.world = MultiWorld(1)
args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args)
self.world.set_default_common_options()

View File

@@ -17,7 +17,7 @@ class TestInvertedBombRules(unittest.TestCase):
self.world = MultiWorld(1)
self.world.mode[1] = "inverted"
args = Namespace
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args)
self.world.set_default_common_options()

View File

@@ -17,7 +17,7 @@ class TestInvertedMinor(TestBase):
def setUp(self):
self.world = MultiWorld(1)
args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args)
self.world.set_default_common_options()

View File

@@ -18,7 +18,7 @@ class TestInvertedOWG(TestBase):
def setUp(self):
self.world = MultiWorld(1)
args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args)
self.world.set_default_common_options()

View File

@@ -17,7 +17,7 @@ class TestMinor(TestBase):
def setUp(self):
self.world = MultiWorld(1)
args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args)
self.world.set_default_common_options()

View File

@@ -18,7 +18,7 @@ class TestVanillaOWG(TestBase):
def setUp(self):
self.world = MultiWorld(1)
args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args)
self.world.set_default_common_options()

View File

@@ -0,0 +1,44 @@
# Tests for SI prefix in Utils.py
import unittest
from decimal import Decimal
from Utils import format_SI_prefix
class TestGenerateMain(unittest.TestCase):
"""This tests SI prefix formatting in Utils.py"""
def assertEqual(self, first, second, msg=None):
# we strip spaces everywhere because that is an undefined implementation detail
super().assertEqual(first.replace(" ", ""), second.replace(" ", ""), msg)
def test_rounding(self):
# we don't care if float(999.995) would fail due to error in precision
self.assertEqual(format_SI_prefix(999.999), "1.00k")
self.assertEqual(format_SI_prefix(1000.001), "1.00k")
self.assertEqual(format_SI_prefix(Decimal("999.995")), "1.00k")
self.assertEqual(format_SI_prefix(Decimal("1000.004")), "1.00k")
def test_letters(self):
self.assertEqual(format_SI_prefix(0e0), "0.00")
self.assertEqual(format_SI_prefix(1e3), "1.00k")
self.assertEqual(format_SI_prefix(2e6), "2.00M")
self.assertEqual(format_SI_prefix(3e9), "3.00G")
self.assertEqual(format_SI_prefix(4e12), "4.00T")
self.assertEqual(format_SI_prefix(5e15), "5.00P")
self.assertEqual(format_SI_prefix(6e18), "6.00E")
self.assertEqual(format_SI_prefix(7e21), "7.00Z")
self.assertEqual(format_SI_prefix(8e24), "8.00Y")
def test_multiple_letters(self):
self.assertEqual(format_SI_prefix(9e27), "9.00kY")
def test_custom_power(self):
self.assertEqual(format_SI_prefix(1023.99, 1024), "1023.99")
self.assertEqual(format_SI_prefix(1034.24, 1024), "1.01k")
def test_custom_labels(self):
labels = ("E", "da", "h", "k")
self.assertEqual(format_SI_prefix(1, 10, labels), "1.00E")
self.assertEqual(format_SI_prefix(10, 10, labels), "1.00da")
self.assertEqual(format_SI_prefix(100, 10, labels), "1.00h")
self.assertEqual(format_SI_prefix(1000, 10, labels), "1.00k")

0
test/utils/__init__.py Normal file
View File

View File

@@ -16,7 +16,7 @@ class TestVanilla(TestBase):
def setUp(self):
self.world = MultiWorld(1)
args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].options.items():
for name, option in AutoWorld.AutoWorldRegister.world_types["A Link to the Past"].option_definitions.items():
setattr(args, name, {1: option.from_any(option.default)})
self.world.set_options(args)
self.world.set_default_common_options()

View File

@@ -0,0 +1,23 @@
"""Tests for successful generation of WebHost cached files. Can catch some other deeper errors."""
import os
import unittest
import WebHost
class TestFileGeneration(unittest.TestCase):
def setUp(self) -> None:
self.correct_path = os.path.join(os.path.dirname(WebHost.__file__), "WebHostLib")
# should not create the folder *here*
self.incorrect_path = os.path.join(os.path.split(os.path.dirname(__file__))[0], "WebHostLib")
def testOptions(self):
WebHost.create_options_files()
self.assertTrue(os.path.exists(os.path.join(self.correct_path, "static", "generated", "configs")))
self.assertFalse(os.path.exists(os.path.join(self.incorrect_path, "static", "generated", "configs")))
def testTutorial(self):
WebHost.create_ordered_tutorials_file()
self.assertTrue(os.path.exists(os.path.join(self.correct_path, "static", "generated", "tutorials.json")))
self.assertFalse(os.path.exists(os.path.join(self.incorrect_path, "static", "generated", "tutorials.json")))

View File

@@ -2,10 +2,14 @@ from __future__ import annotations
import logging
import sys
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Union, NamedTuple
import pathlib
from typing import Dict, FrozenSet, Set, Tuple, List, Optional, TextIO, Any, Callable, Union, TYPE_CHECKING
from BaseClasses import MultiWorld, Item, CollectionState, Location, Tutorial
from Options import Option
from BaseClasses import CollectionState
if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial
class AutoWorldRegister(type):
@@ -23,7 +27,8 @@ class AutoWorldRegister(type):
# build rest
dct["item_names"] = frozenset(dct["item_name_to_id"])
dct["item_name_groups"] = dct.get("item_name_groups", {})
dct["item_name_groups"] = {group_name: frozenset(group_set) for group_name, group_set
in dct.get("item_name_groups", {}).items()}
dct["item_name_groups"]["Everything"] = dct["item_names"]
dct["location_names"] = frozenset(dct["location_name_to_id"])
dct["all_item_and_group_names"] = frozenset(dct["item_names"] | set(dct.get("item_name_groups", {})))
@@ -41,14 +46,18 @@ class AutoWorldRegister(type):
# construct class
new_class = super().__new__(mcs, name, bases, dct)
if "game" in dct:
if dct["game"] in AutoWorldRegister.world_types:
raise RuntimeError(f"""Game {dct["game"]} already registered.""")
AutoWorldRegister.world_types[dct["game"]] = new_class
new_class.__file__ = sys.modules[new_class.__module__].__file__
if ".apworld" in new_class.__file__:
new_class.zip_path = pathlib.Path(new_class.__file__).parents[1]
return new_class
class AutoLogicRegister(type):
def __new__(cls, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoLogicRegister:
new_class = super().__new__(cls, name, bases, dct)
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoLogicRegister:
new_class = super().__new__(mcs, name, bases, dct)
function: Callable[..., Any]
for item_name, function in dct.items():
if item_name == "copy_mixin":
@@ -62,12 +71,12 @@ class AutoLogicRegister(type):
return new_class
def call_single(world: MultiWorld, method_name: str, player: int, *args: Any) -> Any:
def call_single(world: "MultiWorld", method_name: str, player: int, *args: Any) -> Any:
method = getattr(world.worlds[player], method_name)
return method(*args)
def call_all(world: MultiWorld, method_name: str, *args: Any) -> None:
def call_all(world: "MultiWorld", method_name: str, *args: Any) -> None:
world_types: Set[AutoWorldRegister] = set()
for player in world.player_ids:
world_types.add(world.worlds[player].__class__)
@@ -79,7 +88,7 @@ def call_all(world: MultiWorld, method_name: str, *args: Any) -> None:
stage_callable(world, *args)
def call_stage(world: MultiWorld, method_name: str, *args: Any) -> None:
def call_stage(world: "MultiWorld", method_name: str, *args: Any) -> None:
world_types = {world.worlds[player].__class__ for player in world.player_ids}
for world_type in world_types:
stage_callable = getattr(world_type, f"stage_{method_name}", None)
@@ -89,29 +98,29 @@ def call_stage(world: MultiWorld, method_name: str, *args: Any) -> None:
class WebWorld:
"""Webhost integration"""
# display a settings page. Can be a link to an out-of-ap settings tool too.
settings_page: Union[bool, str] = True
# docs folder will be scanned for game info pages using this list in the format '{language}_{game_name}.md'
"""display a settings page. Can be a link to a specific page or external tool."""
game_info_languages: List[str] = ['en']
"""docs folder will be scanned for game info pages using this list in the format '{language}_{game_name}.md'"""
# docs folder will also be scanned for tutorial guides given the relevant information in this list. Each Tutorial
# class is to be used for one guide.
tutorials: List[Tutorial]
tutorials: List["Tutorial"]
"""docs folder will also be scanned for tutorial guides. Each Tutorial class is to be used for one guide."""
# Choose a theme for your /game/* pages
# Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone
theme = "grass"
"""Choose a theme for you /game/* pages.
Available: dirt, grass, grassFlowers, ice, jungle, ocean, partyTime, stone"""
# display a link to a bug report page, most likely a link to a GitHub issue page.
bug_report_page: Optional[str]
"""display a link to a bug report page, most likely a link to a GitHub issue page."""
class World(metaclass=AutoWorldRegister):
"""A World object encompasses a game's Items, Locations, Rules and additional data or functionality required.
A Game should have its own subclass of World in which it defines the required data structures."""
options: Dict[str, Option[Any]] = {} # link your Options mapping
option_definitions: Dict[str, Option[Any]] = {} # link your Options mapping
game: str # name the game
topology_present: bool = False # indicate if world type has any meaningful layout/pathing
@@ -159,8 +168,11 @@ class World(metaclass=AutoWorldRegister):
# Hide World Type from various views. Does not remove functionality.
hidden: bool = False
# see WebWorld for options
web: WebWorld = WebWorld()
# autoset on creation:
world: MultiWorld
world: "MultiWorld"
player: int
# automatically generated
@@ -170,9 +182,10 @@ class World(metaclass=AutoWorldRegister):
item_names: Set[str] # set of all potential item names
location_names: Set[str] # set of all potential location names
web: WebWorld = WebWorld()
zip_path: Optional[pathlib.Path] = None # If loaded from a .apworld, this is the Path to it.
__file__: str # path it was loaded from
def __init__(self, world: MultiWorld, player: int):
def __init__(self, world: "MultiWorld", player: int):
self.world = world
self.player = player
@@ -207,12 +220,12 @@ class World(metaclass=AutoWorldRegister):
@classmethod
def fill_hook(cls,
progitempool: List[Item],
nonexcludeditempool: List[Item],
localrestitempool: Dict[int, List[Item]],
nonlocalrestitempool: Dict[int, List[Item]],
restitempool: List[Item],
fill_locations: List[Location]) -> None:
progitempool: List["Item"],
nonexcludeditempool: List["Item"],
localrestitempool: Dict[int, List["Item"]],
nonlocalrestitempool: Dict[int, List["Item"]],
restitempool: List["Item"],
fill_locations: List["Location"]) -> None:
"""Special method that gets called as part of distribute_items_restrictive (main fill).
This gets called once per present world type."""
pass
@@ -250,7 +263,7 @@ class World(metaclass=AutoWorldRegister):
# end of ordered Main.py calls
def create_item(self, name: str) -> Item:
def create_item(self, name: str) -> "Item":
"""Create an item for this world type and player.
Warning: this may be called with self.world = None, for example by MultiServer"""
raise NotImplementedError
@@ -261,7 +274,7 @@ class World(metaclass=AutoWorldRegister):
return self.world.random.choice(tuple(self.item_name_to_id.keys()))
# decent place to implement progressive items, in most cases can stay as-is
def collect_item(self, state: CollectionState, item: Item, remove: bool = False) -> Optional[str]:
def collect_item(self, state: "CollectionState", item: "Item", remove: bool = False) -> Optional[str]:
"""Collect an item name into state. For speed reasons items that aren't logically useful get skipped.
Collect None to skip item.
:param state: CollectionState to collect into
@@ -272,18 +285,18 @@ class World(metaclass=AutoWorldRegister):
return None
# called to create all_state, return Items that are created during pre_fill
def get_pre_fill_items(self) -> List[Item]:
def get_pre_fill_items(self) -> List["Item"]:
return []
# following methods should not need to be overridden.
def collect(self, state: CollectionState, item: Item) -> bool:
def collect(self, state: "CollectionState", item: "Item") -> bool:
name = self.collect_item(state, item)
if name:
state.prog_items[name, self.player] += 1
return True
return False
def remove(self, state: CollectionState, item: Item) -> bool:
def remove(self, state: "CollectionState", item: "Item") -> bool:
name = self.collect_item(state, item, True)
if name:
state.prog_items[name, self.player] -= 1
@@ -292,7 +305,7 @@ class World(metaclass=AutoWorldRegister):
return True
return False
def create_filler(self) -> Item:
def create_filler(self) -> "Item":
return self.create_item(self.get_filler_item_name())

View File

@@ -1,29 +1,56 @@
import importlib
import zipimport
import os
import typing
__all__ = {"lookup_any_item_id_to_name",
"lookup_any_location_id_to_name",
"network_data_package",
"AutoWorldRegister"}
folder = os.path.dirname(__file__)
__all__ = {
"lookup_any_item_id_to_name",
"lookup_any_location_id_to_name",
"network_data_package",
"AutoWorldRegister",
"world_sources",
"folder",
}
if typing.TYPE_CHECKING:
from .AutoWorld import World
class WorldSource(typing.NamedTuple):
path: str # typically relative path from this module
is_zip: bool = False
# find potential world containers, currently folders and zip-importable .apworld's
world_sources: typing.List[WorldSource] = []
file: os.DirEntry # for me (Berserker) at least, PyCharm doesn't seem to infer the type correctly
for file in os.scandir(folder):
if not file.name.startswith("_"): # prevent explicitly loading __pycache__ and allow _* names for non-world folders
if file.is_dir():
world_sources.append(WorldSource(file.name))
elif file.is_file() and file.name.endswith(".apworld"):
world_sources.append(WorldSource(file.name, is_zip=True))
# import all submodules to trigger AutoWorldRegister
world_folders = []
for file in os.scandir(os.path.dirname(__file__)):
if file.is_dir():
world_folders.append(file.name)
world_folders.sort()
for world in world_folders:
if not world.startswith("_"): # prevent explicitly loading __pycache__ and allow _* names for non-world folders
importlib.import_module(f".{world}", "worlds")
world_sources.sort()
for world_source in world_sources:
if world_source.is_zip:
importer = zipimport.zipimporter(os.path.join(folder, world_source.path))
importer.load_module(world_source.path.split(".", 1)[0])
else:
importlib.import_module(f".{world_source.path}", "worlds")
from .AutoWorld import AutoWorldRegister
lookup_any_item_id_to_name = {}
lookup_any_location_id_to_name = {}
games = {}
from .AutoWorld import AutoWorldRegister
for world_name, world in AutoWorldRegister.world_types.items():
games[world_name] = {
"item_name_to_id" : world.item_name_to_id,
"item_name_to_id": world.item_name_to_id,
"location_name_to_id": world.location_name_to_id,
"version": world.data_version,
# seems clients don't actually want this. Keeping it here in case someone changes their mind.
@@ -41,5 +68,6 @@ network_data_package = {
if any(not world.data_version for world in AutoWorldRegister.world_types.values()):
network_data_package["version"] = 0
import logging
logging.warning(f"Datapackage is in custom mode. Custom Worlds: "
f"{[world for world in AutoWorldRegister.world_types.values() if not world.data_version]}")

View File

@@ -15,7 +15,6 @@ def create_dungeons(world, player):
dungeon_items, player)
for item in dungeon.all_items:
item.dungeon = dungeon
item.world = world
dungeon.boss = BossFactory(default_boss, player) if default_boss else None
for region in dungeon.regions:
world.get_region(region, player).dungeon = dungeon

View File

@@ -212,9 +212,7 @@ def parse_arguments(argv, no_defaults=False):
Alternatively, can be a ALttP Rom patched with a Link
sprite that will be extracted.
''')
parser.add_argument('--gui', help='Launch the GUI', action='store_true')
parser.add_argument('--enemizercli', default=defval('EnemizerCLI/EnemizerCLI.Core'))
parser.add_argument('--shufflebosses', default=defval('none'), choices=['none', 'basic', 'normal', 'chaos',
"singularity"])

View File

@@ -51,6 +51,11 @@ class ItemData(typing.NamedTuple):
flute_boy_credit: typing.Optional[str]
hint_text: typing.Optional[str]
def as_init_dict(self) -> typing.Dict[str, typing.Any]:
return {key: getattr(self, key) for key in
('classification', 'type', 'item_code', 'pedestal_hint', 'hint_text')}
# Format: Name: (Advancement, Type, ItemCode, Pedestal Hint Text, Pedestal Credit Text, Sick Kid Credit Text, Zora Credit Text, Witch Credit Text, Flute Boy Credit Text, Hint Text)
item_table = {'Bow': ItemData(IC.progression, None, 0x0B, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'the Bow'),
'Progressive Bow': ItemData(IC.progression, None, 0x64, 'You have\nchosen the\narcher class.', 'the stick and twine', 'arrow-slinging kid', 'arrow sling for sale', 'witch and robin hood', 'archer boy shoots again', 'a Bow'),
@@ -218,7 +223,7 @@ item_table = {'Bow': ItemData(IC.progression, None, 0x0B, 'You have\nchosen the\
'Open Floodgate': ItemData(IC.progression, 'Event', None, None, None, None, None, None, None, None),
}
as_dict_item_table = {name: data._asdict() for name, data in item_table.items()}
item_init_table = {name: data.as_init_dict() for name, data in item_table.items()}
progression_mapping = {
"Golden Sword": ("Progressive Sword", 4),

View File

@@ -282,8 +282,8 @@ class ShieldPalette(Palette):
display_name = "Shield Palette"
class LinkPalette(Palette):
display_name = "Link Palette"
# class LinkPalette(Palette):
# display_name = "Link Palette"
class HeartBeep(Choice):
@@ -387,7 +387,7 @@ alttp_options: typing.Dict[str, type(Option)] = {
"hud_palettes": HUDPalette,
"sword_palettes": SwordPalette,
"shield_palettes": ShieldPalette,
"link_palettes": LinkPalette,
# "link_palettes": LinkPalette,
"heartbeep": HeartBeep,
"heartcolor": HeartColor,
"quickswap": QuickSwap,

View File

@@ -34,7 +34,7 @@ from worlds.alttp.Text import KingsReturn_texts, Sanctuary_texts, Kakariko_texts
DeathMountain_texts, \
LostWoods_texts, WishingWell_texts, DesertPalace_texts, MountainTower_texts, LinksHouse_texts, Lumberjacks_texts, \
SickKid_texts, FluteBoy_texts, Zora_texts, MagicShop_texts, Sahasrahla_names
from Utils import local_path, user_path, int16_as_bytes, int32_as_bytes, snes_to_pc, is_frozen
from Utils import local_path, user_path, int16_as_bytes, int32_as_bytes, snes_to_pc, is_frozen, parse_yaml
from worlds.alttp.Items import ItemFactory, item_table, item_name_groups, progression_items
from worlds.alttp.EntranceShuffle import door_addresses
from worlds.alttp.Options import smallkey_shuffle
@@ -551,18 +551,22 @@ class Sprite():
Sprite.base_data = Sprite.sprite + Sprite.palette + Sprite.glove_palette
def from_ap_sprite(self, filedata):
filedata = filedata.decode("utf-8-sig")
import yaml
obj = yaml.safe_load(filedata)
if obj["min_format_version"] > 1:
raise Exception("Sprite file requires an updated reader.")
self.author_name = obj["author"]
self.name = obj["name"]
if obj["data"]: # skip patching for vanilla content
data = bsdiff4.patch(Sprite.base_data, obj["data"])
self.sprite = data[:self.sprite_size]
self.palette = data[self.sprite_size:self.palette_size]
self.glove_palette = data[self.sprite_size + self.palette_size:]
# noinspection PyBroadException
try:
obj = parse_yaml(filedata.decode("utf-8-sig"))
if obj["min_format_version"] > 1:
raise Exception("Sprite file requires an updated reader.")
self.author_name = obj["author"]
self.name = obj["name"]
if obj["data"]: # skip patching for vanilla content
data = bsdiff4.patch(Sprite.base_data, obj["data"])
self.sprite = data[:self.sprite_size]
self.palette = data[self.sprite_size:self.palette_size]
self.glove_palette = data[self.sprite_size + self.palette_size:]
except Exception:
logger = logging.getLogger("apsprite")
logger.exception("Error parsing apsprite file")
self.valid = False
@property
def author_game_display(self) -> str:
@@ -659,7 +663,7 @@ class Sprite():
@staticmethod
def parse_zspr(filedata, expected_kind):
logger = logging.getLogger('ZSPR')
logger = logging.getLogger("ZSPR")
headerstr = "<4xBHHIHIHH6x"
headersize = struct.calcsize(headerstr)
if len(filedata) < headersize:
@@ -667,7 +671,7 @@ class Sprite():
version, csum, icsum, sprite_offset, sprite_size, palette_offset, palette_size, kind = struct.unpack_from(
headerstr, filedata)
if version not in [1]:
logger.error('Error parsing ZSPR file: Version %g not supported', version)
logger.error("Error parsing ZSPR file: Version %g not supported", version)
return None
if kind != expected_kind:
return None
@@ -676,36 +680,42 @@ class Sprite():
stream.seek(headersize)
def read_utf16le(stream):
"Decodes a null-terminated UTF-16_LE string of unknown size from a stream"
"""Decodes a null-terminated UTF-16_LE string of unknown size from a stream"""
raw = bytearray()
while True:
char = stream.read(2)
if char in [b'', b'\x00\x00']:
if char in [b"", b"\x00\x00"]:
break
raw += char
return raw.decode('utf-16_le')
return raw.decode("utf-16_le")
sprite_name = read_utf16le(stream)
author_name = read_utf16le(stream)
author_credits_name = stream.read().split(b"\x00", 1)[0].decode()
# noinspection PyBroadException
try:
sprite_name = read_utf16le(stream)
author_name = read_utf16le(stream)
author_credits_name = stream.read().split(b"\x00", 1)[0].decode()
# Ignoring the Author Rom name for the time being.
# Ignoring the Author Rom name for the time being.
real_csum = sum(filedata) % 0x10000
if real_csum != csum or real_csum ^ 0xFFFF != icsum:
logger.warning('ZSPR file has incorrect checksum. It may be corrupted.')
real_csum = sum(filedata) % 0x10000
if real_csum != csum or real_csum ^ 0xFFFF != icsum:
logger.warning("ZSPR file has incorrect checksum. It may be corrupted.")
sprite = filedata[sprite_offset:sprite_offset + sprite_size]
palette = filedata[palette_offset:palette_offset + palette_size]
sprite = filedata[sprite_offset:sprite_offset + sprite_size]
palette = filedata[palette_offset:palette_offset + palette_size]
if len(sprite) != sprite_size or len(palette) != palette_size:
logger.error('Error parsing ZSPR file: Unexpected end of file')
if len(sprite) != sprite_size or len(palette) != palette_size:
logger.error("Error parsing ZSPR file: Unexpected end of file")
return None
return sprite, palette, sprite_name, author_name, author_credits_name
except Exception:
logger.exception("Error parsing ZSPR file")
return None
return (sprite, palette, sprite_name, author_name, author_credits_name)
def decode_palette(self):
"Returns the palettes as an array of arrays of 15 colors"
"""Returns the palettes as an array of arrays of 15 colors"""
def array_chunk(arr, size):
return list(zip(*[iter(arr)] * size))
@@ -2091,7 +2101,9 @@ def write_string_to_rom(rom, target, string):
def write_strings(rom, world, player):
from . import ALTTPWorld
local_random = world.slot_seeds[player]
w: ALTTPWorld = world.worlds[player]
tt = TextTable()
tt.removeUnwantedText()
@@ -2420,7 +2432,8 @@ def write_strings(rom, world, player):
pedestal_text = 'Some Hot Air' if pedestalitem is None else hint_text(pedestalitem,
True) if pedestalitem.pedestal_hint_text is not None else 'Unknown Item'
tt['mastersword_pedestal_translated'] = pedestal_text
pedestal_credit_text = 'and the Hot Air' if pedestalitem is None else pedestalitem.pedestal_credit_text if pedestalitem.pedestal_credit_text is not None else 'and the Unknown Item'
pedestal_credit_text = 'and the Hot Air' if pedestalitem is None else \
w.pedestal_credit_texts.get(pedestalitem.code, 'and the Unknown Item')
etheritem = world.get_location('Ether Tablet', player).item
ether_text = 'Some Hot Air' if etheritem is None else hint_text(etheritem,
@@ -2448,20 +2461,24 @@ def write_strings(rom, world, player):
credits = Credits()
sickkiditem = world.get_location('Sick Kid', player).item
sickkiditem_text = local_random.choice(
SickKid_texts) if sickkiditem is None or sickkiditem.sickkid_credit_text is None else sickkiditem.sickkid_credit_text
sickkiditem_text = local_random.choice(SickKid_texts) \
if sickkiditem is None or sickkiditem.code not in w.sickkid_credit_texts \
else w.sickkid_credit_texts[sickkiditem.code]
zoraitem = world.get_location('King Zora', player).item
zoraitem_text = local_random.choice(
Zora_texts) if zoraitem is None or zoraitem.zora_credit_text is None else zoraitem.zora_credit_text
zoraitem_text = local_random.choice(Zora_texts) \
if zoraitem is None or zoraitem.code not in w.zora_credit_texts \
else w.zora_credit_texts[zoraitem.code]
magicshopitem = world.get_location('Potion Shop', player).item
magicshopitem_text = local_random.choice(
MagicShop_texts) if magicshopitem is None or magicshopitem.magicshop_credit_text is None else magicshopitem.magicshop_credit_text
magicshopitem_text = local_random.choice(MagicShop_texts) \
if magicshopitem is None or magicshopitem.code not in w.magicshop_credit_texts \
else w.magicshop_credit_texts[magicshopitem.code]
fluteboyitem = world.get_location('Flute Spot', player).item
fluteboyitem_text = local_random.choice(
FluteBoy_texts) if fluteboyitem is None or fluteboyitem.fluteboy_credit_text is None else fluteboyitem.fluteboy_credit_text
fluteboyitem_text = local_random.choice(FluteBoy_texts) \
if fluteboyitem is None or fluteboyitem.code not in w.fluteboy_credit_texts \
else w.fluteboy_credit_texts[fluteboyitem.code]
credits.update_credits_line('castle', 0, local_random.choice(KingsReturn_texts))
credits.update_credits_line('sanctuary', 0, local_random.choice(Sanctuary_texts))

View File

@@ -935,7 +935,6 @@ def set_trock_key_rules(world, player):
else:
# A key is required in the Big Key Chest to prevent a possible softlock. Place an extra key to ensure 100% locations still works
item = ItemFactory('Small Key (Turtle Rock)', player)
item.world = world
location = world.get_location('Turtle Rock - Big Key Chest', player)
location.place_locked_item(item)
location.event = True

View File

@@ -207,10 +207,10 @@ def ShopSlotFill(world):
shops_per_sphere.append(current_shops_slots)
candidates_per_sphere.append(current_candidates)
for location in sphere:
if location.shop_slot is not None:
if isinstance(location, ALttPLocation) and location.shop_slot is not None:
if not location.shop_slot_disabled:
current_shops_slots.append(location)
elif not location.locked and not location.item.name in blacklist_words:
elif not location.locked and location.item.name not in blacklist_words:
current_candidates.append(location)
if cumu_weights:
x = cumu_weights[-1]
@@ -335,7 +335,6 @@ def create_shops(world, player: int):
else:
loc.item = ItemFactory(GetBeemizerItem(world, player, 'Nothing'), player)
loc.shop_slot_disabled = True
loc.item.world = world
shop.region.locations.append(loc)
world.clear_location_cache()

View File

@@ -6,31 +6,33 @@ from BaseClasses import Location, Item, ItemClassification
class ALttPLocation(Location):
game: str = "A Link to the Past"
crystal: bool
player_address: Optional[int]
_hint_text: Optional[str]
shop_slot: Optional[int] = None
"""If given as integer, shop_slot is the shop's inventory index."""
shop_slot_disabled: bool = False
def __init__(self, player: int, name: str = '', address=None, crystal: bool = False,
hint_text: Optional[str] = None, parent=None,
player_address=None):
def __init__(self, player: int, name: str, address: Optional[int] = None, crystal: bool = False,
hint_text: Optional[str] = None, parent=None, player_address: Optional[int] = None):
super(ALttPLocation, self).__init__(player, name, address, parent)
self.crystal = crystal
self.player_address = player_address
self._hint_text: str = hint_text
self._hint_text = hint_text
class ALttPItem(Item):
game: str = "A Link to the Past"
type: Optional[str]
_pedestal_hint_text: Optional[str]
_hint_text: Optional[str]
dungeon = None
def __init__(self, name, player, classification=ItemClassification.filler, type=None, item_code=None, pedestal_hint=None,
pedestal_credit=None, sick_kid_credit=None, zora_credit=None, witch_credit=None,
flute_boy_credit=None, hint_text=None):
def __init__(self, name, player, classification=ItemClassification.filler, type=None, item_code=None,
pedestal_hint=None, hint_text=None):
super(ALttPItem, self).__init__(name, classification, item_code, player)
self.type = type
self._pedestal_hint_text = pedestal_hint
self.pedestal_credit_text = pedestal_credit
self.sickkid_credit_text = sick_kid_credit
self.zora_credit_text = zora_credit
self.magicshop_credit_text = witch_credit
self.fluteboy_credit_text = flute_boy_credit
self._hint_text = hint_text
@property

View File

@@ -1,26 +1,24 @@
import random
import logging
import os
import random
import threading
import typing
import Utils
from BaseClasses import Item, CollectionState, Tutorial
from .Dungeons import create_dungeons
from .EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect
from .InvertedRegions import create_inverted_regions, mark_dark_world_regions
from .ItemPool import generate_itempool, difficulties
from .Items import item_init_table, item_name_groups, item_table, GetBeemizerItem
from .Options import alttp_options, smallkey_shuffle
from .Regions import lookup_name_to_id, create_regions, mark_light_world_regions
from .Rom import LocalRom, patch_rom, patch_race_rom, check_enemizer, patch_enemizer, apply_rom_settings, \
get_hash_string, get_base_rom_path, LttPDeltaPatch
from .Rules import set_rules
from .Shops import create_shops, ShopSlotFill
from .SubClasses import ALttPItem
from ..AutoWorld import World, WebWorld, LogicMixin
from .Options import alttp_options, smallkey_shuffle
from .Items import as_dict_item_table, item_name_groups, item_table, GetBeemizerItem
from .Regions import lookup_name_to_id, create_regions, mark_light_world_regions
from .Rules import set_rules
from .ItemPool import generate_itempool, difficulties
from .Shops import create_shops, ShopSlotFill
from .Dungeons import create_dungeons
from .Rom import LocalRom, patch_rom, patch_race_rom, patch_enemizer, apply_rom_settings, get_hash_string, \
get_base_rom_path, LttPDeltaPatch
import Patch
from itertools import chain
from .InvertedRegions import create_inverted_regions, mark_dark_world_regions
from .EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect
lttp_logger = logging.getLogger("A Link to the Past")
@@ -110,7 +108,7 @@ class ALTTPWorld(World):
Ganon!
"""
game: str = "A Link to the Past"
options = alttp_options
option_definitions = alttp_options
topology_present = True
item_name_groups = item_name_groups
hint_blacklist = {"Triforce"}
@@ -124,10 +122,25 @@ class ALTTPWorld(World):
required_client_version = (0, 3, 2)
web = ALTTPWeb()
pedestal_credit_texts: typing.Dict[int, str] = \
{data.item_code: data.pedestal_credit for data in item_table.values() if data.pedestal_credit}
sickkid_credit_texts: typing.Dict[int, str] = \
{data.item_code: data.sick_kid_credit for data in item_table.values() if data.sick_kid_credit}
zora_credit_texts: typing.Dict[int, str] = \
{data.item_code: data.zora_credit for data in item_table.values() if data.zora_credit}
magicshop_credit_texts: typing.Dict[int, str] = \
{data.item_code: data.witch_credit for data in item_table.values() if data.witch_credit}
fluteboy_credit_texts: typing.Dict[int, str] = \
{data.item_code: data.flute_boy_credit for data in item_table.values() if data.flute_boy_credit}
set_rules = set_rules
create_items = generate_itempool
enemizer_path: str = Utils.get_options()["generator"]["enemizer_path"] \
if os.path.isabs(Utils.get_options()["generator"]["enemizer_path"]) \
else Utils.local_path(Utils.get_options()["generator"]["enemizer_path"])
def __init__(self, *args, **kwargs):
self.dungeon_local_item_names = set()
self.dungeon_specific_item_names = set()
@@ -142,6 +155,9 @@ class ALTTPWorld(World):
raise FileNotFoundError(rom_file)
def generate_early(self):
if self.use_enemizer():
check_enemizer(self.enemizer_path)
player = self.player
world = self.world
@@ -330,21 +346,26 @@ class ALTTPWorld(World):
def stage_post_fill(cls, world):
ShopSlotFill(world)
def use_enemizer(self):
world = self.world
player = self.player
return (world.boss_shuffle[player] != 'none' or world.enemy_shuffle[player]
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
or world.pot_shuffle[player] or world.bush_shuffle[player]
or world.killable_thieves[player])
def generate_output(self, output_directory: str):
world = self.world
player = self.player
try:
use_enemizer = (world.boss_shuffle[player] != 'none' or world.enemy_shuffle[player]
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
or world.pot_shuffle[player] or world.bush_shuffle[player]
or world.killable_thieves[player])
use_enemizer = self.use_enemizer()
rom = LocalRom(get_base_rom_path())
patch_rom(world, rom, player, use_enemizer)
if use_enemizer:
patch_enemizer(world, player, rom, world.enemizer, output_directory)
patch_enemizer(world, player, rom, self.enemizer_path, output_directory)
if world.is_race:
patch_race_rom(rom, world, player)
@@ -357,7 +378,7 @@ class ALTTPWorld(World):
'hud': world.hud_palettes[player],
'sword': world.sword_palettes[player],
'shield': world.shield_palettes[player],
'link': world.link_palettes[player]
# 'link': world.link_palettes[player]
}
palettes_options = {key: option.current_key for key, option in palettes_options.items()}
@@ -400,7 +421,7 @@ class ALTTPWorld(World):
multidata["connect_names"][new_name] = multidata["connect_names"][self.world.player_name[self.player]]
def create_item(self, name: str) -> Item:
return ALttPItem(name, self.player, **as_dict_item_table[name])
return ALttPItem(name, self.player, **item_init_table[name])
@classmethod
def stage_fill_hook(cls, world, progitempool, nonexcludeditempool, localrestitempool, nonlocalrestitempool,

View File

@@ -47,13 +47,12 @@ class ArchipIDLEWorld(World):
item_pool = []
for i in range(100):
item = Item(
item = ArchipIDLEItem(
item_table_copy[i],
ItemClassification.progression if i < 20 else ItemClassification.filler,
self.item_name_to_id[item_table_copy[i]],
self.player
)
item.game = 'ArchipIDLE'
item_pool.append(item)
self.world.itempool += item_pool
@@ -93,6 +92,10 @@ def create_region(world: MultiWorld, player: int, name: str, locations=None, exi
return region
class ArchipIDLEItem(Item):
game = "ArchipIDLE"
class ArchipIDLELocation(Location):
game: str = "ArchipIDLE"

View File

@@ -27,7 +27,7 @@ class ChecksFinderWorld(World):
with the mines! You win when you get all your items and beat the board!
"""
game: str = "ChecksFinder"
options = checksfinder_options
option_definitions = checksfinder_options
topology_present = True
web = ChecksFinderWeb()

View File

@@ -16,14 +16,26 @@ from ..generic.Rules import set_rule
class DarkSouls3Web(WebWorld):
tutorials = [Tutorial(
bug_report_page = "https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/issues"
setup_en = Tutorial(
"Multiworld Setup Tutorial",
"A guide to setting up the Archipelago Dark Souls III randomizer on your computer.",
"English",
"setup_en.md",
"setup/en",
["Marech"]
)]
)
setup_fr = Tutorial(
setup_en.tutorial_name,
setup_en.description,
"Français",
"setup_fr.md",
"setup/fr",
["Marech"]
)
tutorials = [setup_en, setup_fr]
class DarkSouls3World(World):
@@ -34,7 +46,7 @@ class DarkSouls3World(World):
"""
game: str = "Dark Souls III"
options = dark_souls_options
option_definitions = dark_souls_options
topology_present: bool = True
remote_items: bool = False
remote_start_inventory: bool = False
@@ -146,7 +158,7 @@ class DarkSouls3World(World):
# For each region, add the associated locations retrieved from the corresponding location_table
def create_region(self, region_name, location_table) -> Region:
new_region = Region(region_name, RegionType.Generic, region_name, self.player)
new_region = Region(region_name, RegionType.Generic, region_name, self.player, self.world)
if location_table:
for name, address in location_table.items():
location = DarkSouls3Location(self.player, name, self.location_name_to_id[name], new_region)

View File

@@ -10,7 +10,10 @@ config file.
In Dark Souls III, all unique items you can earn from a static corpse, a chest or the death of a Boss/NPC are randomized.
This exclude the upgrade materials such as the titanite shards, the estus shards and the consumables which remain at
the same location. I also added an option available from the settings page to randomize the level of the generated
weapons( from +0 to +10/+5 )
weapons(from +0 to +10/+5)
To beat the game you need to collect the 4 "Cinders of a Lord" randomized in the multiworld
and kill the final boss "Soul of Cinder"
## What Dark Souls III items can appear in other players' worlds?

View File

@@ -3,7 +3,7 @@
## Required Software
- [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/)
- [Dark Souls III AP Client](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client)
- [Dark Souls III AP Client](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases)
## General Concept
@@ -14,22 +14,24 @@ The randomization is performed by the AP.json file, an output file generated by
## Installation Procedures
**This client has only been tested with the Official Steam version of the game (v1.15/1.35) not matter which DLCs are installed**
<span style="color:tomato">
**This mod can ban you permanently from the FromSoftware servers if used online.**
</span>
This client has only been tested with the Official Steam version of the game (v1.15/1.35) not matter which DLCs are installed.
Get the dinput8.dll from the [Dark Souls III AP Client](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client).
Then you need to add the two following files at the root folder of your game
( e.g. "SteamLibrary\steamapps\common\DARK SOULS III\Game" ):
Get the dinput8.dll from the [Dark Souls III AP Client](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases).
Then you need to add the two following files at the root folder of your game (e.g. "SteamLibrary\steamapps\common\DARK SOULS III\Game"):
- **dinput8.dll**
- **AP.json** (renamed from the generated file AP-{ROOM_ID}.json)
- **AP.json** : The .json file downloaded from the multiworld room or provided by the host, named AP-{ROOM_ID}.json, has to be renamed to AP.json.
## Joining a MultiWorld Game
1. Run DarkSoulsIII.exe or run the game through Steam
2. Type in /connect {SERVER_IP}:{SERVER_PORT} in the "Windows Command Prompt" that opened
2. Type in "/connect {SERVER_IP}:{SERVER_PORT}" in the "Windows Command Prompt" that opened
3. Once connected, create a new game, choose a class and wait for the others before starting
4. You can quit and launch at anytime during a game
## Where do I get a config file?
The [Player Settings](/games/Dark%20Souls%20III/player-settings) page on the website allows you to
configure your personal settings and export them into a config file
configure your personal settings and export them into a config file

View File

@@ -0,0 +1,38 @@
# Guide d'installation de Dark Souls III Randomizer
## Logiciels requis
- [Dark Souls III](https://store.steampowered.com/app/374320/DARK_SOULS_III/)
- [Client AP de Dark Souls III](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases)
## Concept général
Le client Archipelago de Dark Souls III est un fichier dinput8.dll. Cette .dll va lancer une invite de commande Windows
permettant de lire des informations de la partie et écrire des commandes pour intéragir avec le serveur Archipelago.
Le mélange des objets est réalisé par le fichier AP.json, un fichier généré par le serveur Archipelago.
## Procédures d'installation
<span style="color:tomato">
**Il y a des risques de bannissement permanent des serveurs FromSoftware si ce mod est utilisé en ligne.**
</span>
Ce client a été testé sur la version Steam officielle du jeu (v1.15/1.35), peu importe les DLCs actuellement installés.
Télécharger le fichier dinput8.dll disponible dans le [Client AP de Dark Souls III](https://github.com/Marechal-L/Dark-Souls-III-Archipelago-client/releases).
Vous devez ensuite ajouter les deux fichiers suivants à la racine du jeu
(ex: "SteamLibrary\steamapps\common\DARK SOULS III\Game"):
- **dinput8.dll**
- **AP.json** : Le fichier .json téléchargé depuis la <em>room</em> ou donné par l'hôte de la partie, nommé AP-{ROOM_ID}.json, doit être renommé en AP.json.
## Rejoindre une partie Multiworld
1. Lancer DarkSoulsIII.exe ou lancer le jeu depuis Steam
2. Ecrire "/connect {SERVER_IP}:{SERVER_PORT}" dans l'invite de commande Windows ouverte au lancement du jeu
3. Une fois connecté, créez une nouvelle partie, choisissez une classe et attendez que les autres soient prêts avant de lancer
4. Vous pouvez quitter et lancer le jeu n'importe quand pendant une partie
## Où trouver le fichier de configuration ?
La [Page de configuration](/games/Dark%20Souls%20III/player-settings) sur le site vous permez de configurer vos
paramètres et de les exporter sous la forme d'un fichier.

View File

@@ -66,7 +66,7 @@ async def dkc3_game_watcher(ctx: Context):
return
new_checks = []
from worlds.dkc3.Rom import location_rom_data, item_rom_data
from worlds.dkc3.Rom import location_rom_data, item_rom_data, boss_location_ids, level_unlock_map
for loc_id, loc_data in location_rom_data.items():
if loc_id not in ctx.locations_checked:
data = await snes_read(ctx, WRAM_START + loc_data[0], 1)
@@ -186,22 +186,40 @@ async def dkc3_game_watcher(ctx: Context):
# DKC3_TODO: This method of collect should work, however it does not unlock the next level correctly when previous is flagged
# Handle Collected Locations
#for loc_id in ctx.checked_locations:
# if loc_id not in ctx.locations_checked:
# loc_data = location_rom_data[loc_id]
# data = await snes_read(ctx, WRAM_START + loc_data[0], 1)
# invert_bit = ((len(loc_data) >= 3) and loc_data[2])
# if not invert_bit:
# masked_data = data[0] | (1 << loc_data[1])
# print("Collected Location: ", hex(loc_data[0]), " | ", loc_data[1])
# snes_buffered_write(ctx, WRAM_START + loc_data[0], bytes([masked_data]))
# await snes_flush_writes(ctx)
# else:
# masked_data = data[0] & ~(1 << loc_data[1])
# print("Collected Inverted Location: ", hex(loc_data[0]), " | ", loc_data[1])
# snes_buffered_write(ctx, WRAM_START + loc_data[0], bytes([masked_data]))
# await snes_flush_writes(ctx)
# ctx.locations_checked.add(loc_id)
for loc_id in ctx.checked_locations:
if loc_id not in ctx.locations_checked and loc_id not in boss_location_ids:
loc_data = location_rom_data[loc_id]
data = await snes_read(ctx, WRAM_START + loc_data[0], 1)
invert_bit = ((len(loc_data) >= 3) and loc_data[2])
if not invert_bit:
masked_data = data[0] | (1 << loc_data[1])
#print("Collected Location: ", hex(loc_data[0]), " | ", loc_data[1])
snes_buffered_write(ctx, WRAM_START + loc_data[0], bytes([masked_data]))
if (loc_data[1] == 1):
# Make the next levels accessible
level_id = loc_data[0] - 0x632
levels_to_tiles = await snes_read(ctx, ROM_START + 0x3FF800, 0x60)
tiles_to_levels = await snes_read(ctx, ROM_START + 0x3FF860, 0x60)
tile_id = levels_to_tiles[level_id] if levels_to_tiles[level_id] != 0xFF else level_id
tile_id = tile_id + 0x632
#print("Tile ID: ", hex(tile_id))
if tile_id in level_unlock_map:
for next_level_address in level_unlock_map[tile_id]:
next_level_id = next_level_address - 0x632
next_tile_id = tiles_to_levels[next_level_id] if tiles_to_levels[next_level_id] != 0xFF else next_level_id
next_tile_id = next_tile_id + 0x632
#print("Next Level ID: ", hex(next_tile_id))
next_data = await snes_read(ctx, WRAM_START + next_tile_id, 1)
snes_buffered_write(ctx, WRAM_START + next_tile_id, bytes([next_data[0] | 0x01]))
await snes_flush_writes(ctx)
else:
masked_data = data[0] & ~(1 << loc_data[1])
print("Collected Inverted Location: ", hex(loc_data[0]), " | ", loc_data[1])
snes_buffered_write(ctx, WRAM_START + loc_data[0], bytes([masked_data]))
await snes_flush_writes(ctx)
ctx.locations_checked.add(loc_id)
# Calculate Boomer Cost Text
boomer_cost_text = await snes_read(ctx, WRAM_START + 0xAAFD, 2)

View File

@@ -221,6 +221,55 @@ level_location_table = {
LocationName.rocket_rush_dk: 0xDC30A0,
}
kong_location_table = {
LocationName.lakeside_limbo_kong: 0xDC3100,
LocationName.doorstop_dash_kong: 0xDC3104,
LocationName.tidal_trouble_kong: 0xDC3108,
LocationName.skiddas_row_kong: 0xDC310C,
LocationName.murky_mill_kong: 0xDC3110,
LocationName.barrel_shield_bust_up_kong: 0xDC3114,
LocationName.riverside_race_kong: 0xDC3118,
LocationName.squeals_on_wheels_kong: 0xDC311C,
LocationName.springin_spiders_kong: 0xDC3120,
LocationName.bobbing_barrel_brawl_kong: 0xDC3124,
LocationName.bazzas_blockade_kong: 0xDC3128,
LocationName.rocket_barrel_ride_kong: 0xDC312C,
LocationName.kreeping_klasps_kong: 0xDC3130,
LocationName.tracker_barrel_trek_kong: 0xDC3134,
LocationName.fish_food_frenzy_kong: 0xDC3138,
LocationName.fire_ball_frenzy_kong: 0xDC313C,
LocationName.demolition_drain_pipe_kong: 0xDC3140,
LocationName.ripsaw_rage_kong: 0xDC3144,
LocationName.blazing_bazookas_kong: 0xDC3148,
LocationName.low_g_labyrinth_kong: 0xDC314C,
LocationName.krevice_kreepers_kong: 0xDC3150,
LocationName.tearaway_toboggan_kong: 0xDC3154,
LocationName.barrel_drop_bounce_kong: 0xDC3158,
LocationName.krack_shot_kroc_kong: 0xDC315C,
LocationName.lemguin_lunge_kong: 0xDC3160,
LocationName.buzzer_barrage_kong: 0xDC3164,
LocationName.kong_fused_cliffs_kong: 0xDC3168,
LocationName.floodlit_fish_kong: 0xDC316C,
LocationName.pothole_panic_kong: 0xDC3170,
LocationName.ropey_rumpus_kong: 0xDC3174,
LocationName.konveyor_rope_clash_kong: 0xDC3178,
LocationName.creepy_caverns_kong: 0xDC317C,
LocationName.lightning_lookout_kong: 0xDC3180,
LocationName.koindozer_klamber_kong: 0xDC3184,
LocationName.poisonous_pipeline_kong: 0xDC3188,
LocationName.stampede_sprint_kong: 0xDC318C,
LocationName.criss_cross_cliffs_kong: 0xDC3191,
LocationName.tyrant_twin_tussle_kong: 0xDC3195,
LocationName.swoopy_salvo_kong: 0xDC319A,
}
boss_location_table = {
LocationName.belchas_barn: 0xDC30A1,
@@ -266,6 +315,7 @@ all_locations = {
**boss_location_table,
**secret_cave_location_table,
**brothers_bear_location_table,
**kong_location_table,
}
location_table = {}
@@ -277,6 +327,9 @@ def setup_locations(world, player: int):
if False:#world.include_trade_sequence[player].value:
location_table.update({**brothers_bear_location_table})
if world.kongsanity[player].value:
location_table.update({**kong_location_table})
return location_table

View File

@@ -1,197 +1,236 @@
# Level Definitions
lakeside_limbo_flag = "Lakeside Limbo - Flag"
lakeside_limbo_kong = "Lakeside Limbo - KONG"
lakeside_limbo_bonus_1 = "Lakeside Limbo - Bonus 1"
lakeside_limbo_bonus_2 = "Lakeside Limbo - Bonus 2"
lakeside_limbo_dk = "Lakeside Limbo - DK Coin"
doorstop_dash_flag = "Doorstop Dash - Flag"
doorstop_dash_kong = "Doorstop Dash - KONG"
doorstop_dash_bonus_1 = "Doorstop Dash - Bonus 1"
doorstop_dash_bonus_2 = "Doorstop Dash - Bonus 2"
doorstop_dash_dk = "Doorstop Dash - DK Coin"
tidal_trouble_flag = "Tidal Trouble - Flag"
tidal_trouble_kong = "Tidal Trouble - KONG"
tidal_trouble_bonus_1 = "Tidal Trouble - Bonus 1"
tidal_trouble_bonus_2 = "Tidal Trouble - Bonus 2"
tidal_trouble_dk = "Tidal Trouble - DK Coin"
skiddas_row_flag = "Skidda's Row - Flag"
skiddas_row_kong = "Skidda's Row - KONG"
skiddas_row_bonus_1 = "Skidda's Row - Bonus 1"
skiddas_row_bonus_2 = "Skidda's Row - Bonus 2"
skiddas_row_dk = "Skidda's Row - DK Coin"
murky_mill_flag = "Murky Mill - Flag"
murky_mill_kong = "Murky Mill - KONG"
murky_mill_bonus_1 = "Murky Mill - Bonus 1"
murky_mill_bonus_2 = "Murky Mill - Bonus 2"
murky_mill_dk = "Murky Mill - DK Coin"
barrel_shield_bust_up_flag = "Barrel Shield Bust-Up - Flag"
barrel_shield_bust_up_kong = "Barrel Shield Bust-Up - KONG"
barrel_shield_bust_up_bonus_1 = "Barrel Shield Bust-Up - Bonus 1"
barrel_shield_bust_up_bonus_2 = "Barrel Shield Bust-Up - Bonus 2"
barrel_shield_bust_up_dk = "Barrel Shield Bust-Up - DK Coin"
riverside_race_flag = "Riverside Race - Flag"
riverside_race_kong = "Riverside Race - KONG"
riverside_race_bonus_1 = "Riverside Race - Bonus 1"
riverside_race_bonus_2 = "Riverside Race - Bonus 2"
riverside_race_dk = "Riverside Race - DK Coin"
squeals_on_wheels_flag = "Squeals On Wheels - Flag"
squeals_on_wheels_kong = "Squeals On Wheels - KONG"
squeals_on_wheels_bonus_1 = "Squeals On Wheels - Bonus 1"
squeals_on_wheels_bonus_2 = "Squeals On Wheels - Bonus 2"
squeals_on_wheels_dk = "Squeals On Wheels - DK Coin"
springin_spiders_flag = "Springin' Spiders - Flag"
springin_spiders_kong = "Springin' Spiders - KONG"
springin_spiders_bonus_1 = "Springin' Spiders - Bonus 1"
springin_spiders_bonus_2 = "Springin' Spiders - Bonus 2"
springin_spiders_dk = "Springin' Spiders - DK Coin"
bobbing_barrel_brawl_flag = "Bobbing Barrel Brawl - Flag"
bobbing_barrel_brawl_kong = "Bobbing Barrel Brawl - KONG"
bobbing_barrel_brawl_bonus_1 = "Bobbing Barrel Brawl - Bonus 1"
bobbing_barrel_brawl_bonus_2 = "Bobbing Barrel Brawl - Bonus 2"
bobbing_barrel_brawl_dk = "Bobbing Barrel Brawl - DK Coin"
bazzas_blockade_flag = "Bazza's Blockade - Flag"
bazzas_blockade_kong = "Bazza's Blockade - KONG"
bazzas_blockade_bonus_1 = "Bazza's Blockade - Bonus 1"
bazzas_blockade_bonus_2 = "Bazza's Blockade - Bonus 2"
bazzas_blockade_dk = "Bazza's Blockade - DK Coin"
rocket_barrel_ride_flag = "Rocket Barrel Ride - Flag"
rocket_barrel_ride_kong = "Rocket Barrel Ride - KONG"
rocket_barrel_ride_bonus_1 = "Rocket Barrel Ride - Bonus 1"
rocket_barrel_ride_bonus_2 = "Rocket Barrel Ride - Bonus 2"
rocket_barrel_ride_dk = "Rocket Barrel Ride - DK Coin"
kreeping_klasps_flag = "Kreeping Klasps - Flag"
kreeping_klasps_kong = "Kreeping Klasps - KONG"
kreeping_klasps_bonus_1 = "Kreeping Klasps - Bonus 1"
kreeping_klasps_bonus_2 = "Kreeping Klasps - Bonus 2"
kreeping_klasps_dk = "Kreeping Klasps - DK Coin"
tracker_barrel_trek_flag = "Tracker Barrel Trek - Flag"
tracker_barrel_trek_kong = "Tracker Barrel Trek - KONG"
tracker_barrel_trek_bonus_1 = "Tracker Barrel Trek - Bonus 1"
tracker_barrel_trek_bonus_2 = "Tracker Barrel Trek - Bonus 2"
tracker_barrel_trek_dk = "Tracker Barrel Trek - DK Coin"
fish_food_frenzy_flag = "Fish Food Frenzy - Flag"
fish_food_frenzy_kong = "Fish Food Frenzy - KONG"
fish_food_frenzy_bonus_1 = "Fish Food Frenzy - Bonus 1"
fish_food_frenzy_bonus_2 = "Fish Food Frenzy - Bonus 2"
fish_food_frenzy_dk = "Fish Food Frenzy - DK Coin"
fire_ball_frenzy_flag = "Fire-Ball Frenzy - Flag"
fire_ball_frenzy_kong = "Fire-Ball Frenzy - KONG"
fire_ball_frenzy_bonus_1 = "Fire-Ball Frenzy - Bonus 1"
fire_ball_frenzy_bonus_2 = "Fire-Ball Frenzy - Bonus 2"
fire_ball_frenzy_dk = "Fire-Ball Frenzy - DK Coin"
demolition_drain_pipe_flag = "Demolition Drain-Pipe - Flag"
demolition_drain_pipe_kong = "Demolition Drain-Pipe - KONG"
demolition_drain_pipe_bonus_1 = "Demolition Drain-Pipe - Bonus 1"
demolition_drain_pipe_bonus_2 = "Demolition Drain-Pipe - Bonus 2"
demolition_drain_pipe_dk = "Demolition Drain-Pipe - DK Coin"
ripsaw_rage_flag = "Ripsaw Rage - Flag"
ripsaw_rage_kong = "Ripsaw Rage - KONG"
ripsaw_rage_bonus_1 = "Ripsaw Rage - Bonus 1"
ripsaw_rage_bonus_2 = "Ripsaw Rage - Bonus 2"
ripsaw_rage_dk = "Ripsaw Rage - DK Coin"
blazing_bazookas_flag = "Blazing Bazookas - Flag"
blazing_bazookas_bonus_1 = "Blazing Bazookas - Bonus 1"
blazing_bazookas_bonus_2 = "Blazing Bazookas - Bonus 2"
blazing_bazookas_dk = "Blazing Bazookas - DK Coin"
blazing_bazookas_flag = "Blazing Bazukas - Flag"
blazing_bazookas_kong = "Blazing Bazukas - KONG"
blazing_bazookas_bonus_1 = "Blazing Bazukas - Bonus 1"
blazing_bazookas_bonus_2 = "Blazing Bazukas - Bonus 2"
blazing_bazookas_dk = "Blazing Bazukas - DK Coin"
low_g_labyrinth_flag = "Low-G Labyrinth - Flag"
low_g_labyrinth_kong = "Low-G Labyrinth - KONG"
low_g_labyrinth_bonus_1 = "Low-G Labyrinth - Bonus 1"
low_g_labyrinth_bonus_2 = "Low-G Labyrinth - Bonus 2"
low_g_labyrinth_dk = "Low-G Labyrinth - DK Coin"
krevice_kreepers_flag = "Krevice Kreepers - Flag"
krevice_kreepers_kong = "Krevice Kreepers - KONG"
krevice_kreepers_bonus_1 = "Krevice Kreepers - Bonus 1"
krevice_kreepers_bonus_2 = "Krevice Kreepers - Bonus 2"
krevice_kreepers_dk = "Krevice Kreepers - DK Coin"
tearaway_toboggan_flag = "Tearaway Toboggan - Flag"
tearaway_toboggan_kong = "Tearaway Toboggan - KONG"
tearaway_toboggan_bonus_1 = "Tearaway Toboggan - Bonus 1"
tearaway_toboggan_bonus_2 = "Tearaway Toboggan - Bonus 2"
tearaway_toboggan_dk = "Tearaway Toboggan - DK Coin"
barrel_drop_bounce_flag = "Barrel Drop Bounce - Flag"
barrel_drop_bounce_kong = "Barrel Drop Bounce - KONG"
barrel_drop_bounce_bonus_1 = "Barrel Drop Bounce - Bonus 1"
barrel_drop_bounce_bonus_2 = "Barrel Drop Bounce - Bonus 2"
barrel_drop_bounce_dk = "Barrel Drop Bounce - DK Coin"
krack_shot_kroc_flag = "Krack-Shot Kroc - Flag"
krack_shot_kroc_kong = "Krack-Shot Kroc - KONG"
krack_shot_kroc_bonus_1 = "Krack-Shot Kroc - Bonus 1"
krack_shot_kroc_bonus_2 = "Krack-Shot Kroc - Bonus 2"
krack_shot_kroc_dk = "Krack-Shot Kroc - DK Coin"
lemguin_lunge_flag = "Lemguin Lunge - Flag"
lemguin_lunge_kong = "Lemguin Lunge - KONG"
lemguin_lunge_bonus_1 = "Lemguin Lunge - Bonus 1"
lemguin_lunge_bonus_2 = "Lemguin Lunge - Bonus 2"
lemguin_lunge_dk = "Lemguin Lunge - DK Coin"
buzzer_barrage_flag = "Buzzer Barrage - Flag"
buzzer_barrage_kong = "Buzzer Barrage - KONG"
buzzer_barrage_bonus_1 = "Buzzer Barrage - Bonus 1"
buzzer_barrage_bonus_2 = "Buzzer Barrage - Bonus 2"
buzzer_barrage_dk = "Buzzer Barrage - DK Coin"
kong_fused_cliffs_flag = "Kong-Fused Cliffs - Flag"
kong_fused_cliffs_kong = "Kong-Fused Cliffs - KONG"
kong_fused_cliffs_bonus_1 = "Kong-Fused Cliffs - Bonus 1"
kong_fused_cliffs_bonus_2 = "Kong-Fused Cliffs - Bonus 2"
kong_fused_cliffs_dk = "Kong-Fused Cliffs - DK Coin"
floodlit_fish_flag = "Floodlit Fish - Flag"
floodlit_fish_kong = "Floodlit Fish - KONG"
floodlit_fish_bonus_1 = "Floodlit Fish - Bonus 1"
floodlit_fish_bonus_2 = "Floodlit Fish - Bonus 2"
floodlit_fish_dk = "Floodlit Fish - DK Coin"
pothole_panic_flag = "Pothole Panic - Flag"
pothole_panic_kong = "Pothole Panic - KONG"
pothole_panic_bonus_1 = "Pothole Panic - Bonus 1"
pothole_panic_bonus_2 = "Pothole Panic - Bonus 2"
pothole_panic_dk = "Pothole Panic - DK Coin"
ropey_rumpus_flag = "Ropey Rumpus - Flag"
ropey_rumpus_kong = "Ropey Rumpus - KONG"
ropey_rumpus_bonus_1 = "Ropey Rumpus - Bonus 1"
ropey_rumpus_bonus_2 = "Ropey Rumpus - Bonus 2"
ropey_rumpus_dk = "Ropey Rumpus - DK Coin"
konveyor_rope_clash_flag = "Konveyor Rope Klash - Flag"
konveyor_rope_clash_kong = "Konveyor Rope Klash - KONG"
konveyor_rope_clash_bonus_1 = "Konveyor Rope Klash - Bonus 1"
konveyor_rope_clash_bonus_2 = "Konveyor Rope Klash - Bonus 2"
konveyor_rope_clash_dk = "Konveyor Rope Klash - DK Coin"
creepy_caverns_flag = "Creepy Caverns - Flag"
creepy_caverns_kong = "Creepy Caverns - KONG"
creepy_caverns_bonus_1 = "Creepy Caverns - Bonus 1"
creepy_caverns_bonus_2 = "Creepy Caverns - Bonus 2"
creepy_caverns_dk = "Creepy Caverns - DK Coin"
lightning_lookout_flag = "Lightning Lookout - Flag"
lightning_lookout_kong = "Lightning Lookout - KONG"
lightning_lookout_bonus_1 = "Lightning Lookout - Bonus 1"
lightning_lookout_bonus_2 = "Lightning Lookout - Bonus 2"
lightning_lookout_dk = "Lightning Lookout - DK Coin"
koindozer_klamber_flag = "Koindozer Klamber - Flag"
koindozer_klamber_kong = "Koindozer Klamber - KONG"
koindozer_klamber_bonus_1 = "Koindozer Klamber - Bonus 1"
koindozer_klamber_bonus_2 = "Koindozer Klamber - Bonus 2"
koindozer_klamber_dk = "Koindozer Klamber - DK Coin"
poisonous_pipeline_flag = "Poisonous Pipeline - Flag"
poisonous_pipeline_kong = "Poisonous Pipeline - KONG"
poisonous_pipeline_bonus_1 = "Poisonous Pipeline - Bonus 1"
poisonous_pipeline_bonus_2 = "Poisonous Pipeline - Bonus 2"
poisonous_pipeline_dk = "Poisonous Pipeline - DK Coin"
stampede_sprint_flag = "Stampede Sprint - Flag"
stampede_sprint_kong = "Stampede Sprint - KONG"
stampede_sprint_bonus_1 = "Stampede Sprint - Bonus 1"
stampede_sprint_bonus_2 = "Stampede Sprint - Bonus 2"
stampede_sprint_bonus_3 = "Stampede Sprint - Bonus 3"
stampede_sprint_dk = "Stampede Sprint - DK Coin"
criss_cross_cliffs_flag = "Criss Kross Cliffs - Flag"
criss_cross_cliffs_kong = "Criss Kross Cliffs - KONG"
criss_cross_cliffs_bonus_1 = "Criss Kross Cliffs - Bonus 1"
criss_cross_cliffs_bonus_2 = "Criss Kross Cliffs - Bonus 2"
criss_cross_cliffs_dk = "Criss Kross Cliffs - DK Coin"
tyrant_twin_tussle_flag = "Tyrant Twin Tussle - Flag"
tyrant_twin_tussle_kong = "Tyrant Twin Tussle - KONG"
tyrant_twin_tussle_bonus_1 = "Tyrant Twin Tussle - Bonus 1"
tyrant_twin_tussle_bonus_2 = "Tyrant Twin Tussle - Bonus 2"
tyrant_twin_tussle_bonus_3 = "Tyrant Twin Tussle - Bonus 3"
tyrant_twin_tussle_dk = "Tyrant Twin Tussle - DK Coin"
swoopy_salvo_flag = "Swoopy Salvo - Flag"
swoopy_salvo_kong = "Swoopy Salvo - KONG"
swoopy_salvo_bonus_1 = "Swoopy Salvo - Bonus 1"
swoopy_salvo_bonus_2 = "Swoopy Salvo - Bonus 2"
swoopy_salvo_bonus_3 = "Swoopy Salvo - Bonus 3"

View File

@@ -6,7 +6,7 @@ from Options import Choice, Range, Option, Toggle, DeathLink, DefaultOnToggle, O
class Goal(Choice):
"""
Determines the goal of the seed
Knautilus: Reach the Knautilus and defeat Baron K. Roolenstein
Knautilus: Scuttle the Knautilus in Krematoa and defeat Baron K. Roolenstein
Banana Bird Hunt: Find a certain number of Banana Birds and rescue their mother
"""
display_name = "Goal"
@@ -75,6 +75,13 @@ class PercentageOfBananaBirds(Range):
default = 100
class KONGsanity(Toggle):
"""
Whether collecting all four KONG letters in each level grants a check
"""
display_name = "KONGsanity"
class LevelShuffle(Toggle):
"""
Whether levels are shuffled
@@ -82,6 +89,41 @@ class LevelShuffle(Toggle):
display_name = "Level Shuffle"
class Difficulty(Choice):
"""
Which Difficulty Level to use
NORML: The Normal Difficulty
HARDR: Many DK Barrels are removed
TUFST: Most DK Barrels and all Midway Barrels are removed
"""
display_name = "Difficulty"
option_norml = 0
option_hardr = 1
option_tufst = 2
default = 0
@classmethod
def get_option_name(cls, value) -> str:
if cls.auto_display_name:
return cls.name_lookup[value].upper()
else:
return cls.name_lookup[value]
class Autosave(DefaultOnToggle):
"""
Whether the game should autosave after each level
"""
display_name = "Autosave"
class MERRY(Toggle):
"""
Whether the Bonus Barrels will be Christmas-themed
"""
display_name = "MERRY"
class MusicShuffle(Toggle):
"""
Whether music is shuffled
@@ -125,7 +167,11 @@ dkc3_options: typing.Dict[str, type(Option)] = {
"percentage_of_extra_bonus_coins": PercentageOfExtraBonusCoins,
"number_of_banana_birds": NumberOfBananaBirds,
"percentage_of_banana_birds": PercentageOfBananaBirds,
"kongsanity": KONGsanity,
"level_shuffle": LevelShuffle,
"difficulty": Difficulty,
"autosave": Autosave,
"merry": MERRY,
"music_shuffle": MusicShuffle,
"kong_palette_swap": KongPaletteSwap,
"starting_life_count": StartingLifeCount,

View File

@@ -44,6 +44,8 @@ def create_regions(world, player: int, active_locations):
LocationName.lakeside_limbo_bonus_2 : [0x657, 3],
LocationName.lakeside_limbo_dk : [0x657, 5],
}
if world.kongsanity[player]:
lakeside_limbo_region_locations[LocationName.lakeside_limbo_kong] = []
lakeside_limbo_region = create_region(world, player, active_locations, LocationName.lakeside_limbo_region,
lakeside_limbo_region_locations, None)
@@ -53,6 +55,8 @@ def create_regions(world, player: int, active_locations):
LocationName.doorstop_dash_bonus_2 : [0x65A, 3],
LocationName.doorstop_dash_dk : [0x65A, 5],
}
if world.kongsanity[player]:
doorstop_dash_region_locations[LocationName.doorstop_dash_kong] = []
doorstop_dash_region = create_region(world, player, active_locations, LocationName.doorstop_dash_region,
doorstop_dash_region_locations, None)
@@ -62,6 +66,8 @@ def create_regions(world, player: int, active_locations):
LocationName.tidal_trouble_bonus_2 : [0x659, 3],
LocationName.tidal_trouble_dk : [0x659, 5],
}
if world.kongsanity[player]:
tidal_trouble_region_locations[LocationName.tidal_trouble_kong] = []
tidal_trouble_region = create_region(world, player, active_locations, LocationName.tidal_trouble_region,
tidal_trouble_region_locations, None)
@@ -71,6 +77,8 @@ def create_regions(world, player: int, active_locations):
LocationName.skiddas_row_bonus_2 : [0x65D, 3],
LocationName.skiddas_row_dk : [0x65D, 5],
}
if world.kongsanity[player]:
skiddas_row_region_locations[LocationName.skiddas_row_kong] = []
skiddas_row_region = create_region(world, player, active_locations, LocationName.skiddas_row_region,
skiddas_row_region_locations, None)
@@ -80,6 +88,8 @@ def create_regions(world, player: int, active_locations):
LocationName.murky_mill_bonus_2 : [0x65C, 3],
LocationName.murky_mill_dk : [0x65C, 5],
}
if world.kongsanity[player]:
murky_mill_region_locations[LocationName.murky_mill_kong] = []
murky_mill_region = create_region(world, player, active_locations, LocationName.murky_mill_region,
murky_mill_region_locations, None)
@@ -89,6 +99,8 @@ def create_regions(world, player: int, active_locations):
LocationName.barrel_shield_bust_up_bonus_2 : [0x662, 3],
LocationName.barrel_shield_bust_up_dk : [0x662, 5],
}
if world.kongsanity[player]:
barrel_shield_bust_up_region_locations[LocationName.barrel_shield_bust_up_kong] = []
barrel_shield_bust_up_region = create_region(world, player, active_locations, LocationName.barrel_shield_bust_up_region,
barrel_shield_bust_up_region_locations, None)
@@ -98,6 +110,8 @@ def create_regions(world, player: int, active_locations):
LocationName.riverside_race_bonus_2 : [0x664, 3],
LocationName.riverside_race_dk : [0x664, 5],
}
if world.kongsanity[player]:
riverside_race_region_locations[LocationName.riverside_race_kong] = []
riverside_race_region = create_region(world, player, active_locations, LocationName.riverside_race_region,
riverside_race_region_locations, None)
@@ -107,6 +121,8 @@ def create_regions(world, player: int, active_locations):
LocationName.squeals_on_wheels_bonus_2 : [0x65B, 3],
LocationName.squeals_on_wheels_dk : [0x65B, 5],
}
if world.kongsanity[player]:
squeals_on_wheels_region_locations[LocationName.squeals_on_wheels_kong] = []
squeals_on_wheels_region = create_region(world, player, active_locations, LocationName.squeals_on_wheels_region,
squeals_on_wheels_region_locations, None)
@@ -116,6 +132,8 @@ def create_regions(world, player: int, active_locations):
LocationName.springin_spiders_bonus_2 : [0x661, 3],
LocationName.springin_spiders_dk : [0x661, 5],
}
if world.kongsanity[player]:
springin_spiders_region_locations[LocationName.springin_spiders_kong] = []
springin_spiders_region = create_region(world, player, active_locations, LocationName.springin_spiders_region,
springin_spiders_region_locations, None)
@@ -125,6 +143,8 @@ def create_regions(world, player: int, active_locations):
LocationName.bobbing_barrel_brawl_bonus_2 : [0x666, 3],
LocationName.bobbing_barrel_brawl_dk : [0x666, 5],
}
if world.kongsanity[player]:
bobbing_barrel_brawl_region_locations[LocationName.bobbing_barrel_brawl_kong] = []
bobbing_barrel_brawl_region = create_region(world, player, active_locations, LocationName.bobbing_barrel_brawl_region,
bobbing_barrel_brawl_region_locations, None)
@@ -134,6 +154,8 @@ def create_regions(world, player: int, active_locations):
LocationName.bazzas_blockade_bonus_2 : [0x667, 3],
LocationName.bazzas_blockade_dk : [0x667, 5],
}
if world.kongsanity[player]:
bazzas_blockade_region_locations[LocationName.bazzas_blockade_kong] = []
bazzas_blockade_region = create_region(world, player, active_locations, LocationName.bazzas_blockade_region,
bazzas_blockade_region_locations, None)
@@ -143,6 +165,8 @@ def create_regions(world, player: int, active_locations):
LocationName.rocket_barrel_ride_bonus_2 : [0x66A, 3],
LocationName.rocket_barrel_ride_dk : [0x66A, 5],
}
if world.kongsanity[player]:
rocket_barrel_ride_region_locations[LocationName.rocket_barrel_ride_kong] = []
rocket_barrel_ride_region = create_region(world, player, active_locations, LocationName.rocket_barrel_ride_region,
rocket_barrel_ride_region_locations, None)
@@ -152,6 +176,8 @@ def create_regions(world, player: int, active_locations):
LocationName.kreeping_klasps_bonus_2 : [0x658, 3],
LocationName.kreeping_klasps_dk : [0x658, 5],
}
if world.kongsanity[player]:
kreeping_klasps_region_locations[LocationName.kreeping_klasps_kong] = []
kreeping_klasps_region = create_region(world, player, active_locations, LocationName.kreeping_klasps_region,
kreeping_klasps_region_locations, None)
@@ -161,6 +187,8 @@ def create_regions(world, player: int, active_locations):
LocationName.tracker_barrel_trek_bonus_2 : [0x66B, 3],
LocationName.tracker_barrel_trek_dk : [0x66B, 5],
}
if world.kongsanity[player]:
tracker_barrel_trek_region_locations[LocationName.tracker_barrel_trek_kong] = []
tracker_barrel_trek_region = create_region(world, player, active_locations, LocationName.tracker_barrel_trek_region,
tracker_barrel_trek_region_locations, None)
@@ -170,6 +198,8 @@ def create_regions(world, player: int, active_locations):
LocationName.fish_food_frenzy_bonus_2 : [0x668, 3],
LocationName.fish_food_frenzy_dk : [0x668, 5],
}
if world.kongsanity[player]:
fish_food_frenzy_region_locations[LocationName.fish_food_frenzy_kong] = []
fish_food_frenzy_region = create_region(world, player, active_locations, LocationName.fish_food_frenzy_region,
fish_food_frenzy_region_locations, None)
@@ -179,6 +209,8 @@ def create_regions(world, player: int, active_locations):
LocationName.fire_ball_frenzy_bonus_2 : [0x66D, 3],
LocationName.fire_ball_frenzy_dk : [0x66D, 5],
}
if world.kongsanity[player]:
fire_ball_frenzy_region_locations[LocationName.fire_ball_frenzy_kong] = []
fire_ball_frenzy_region = create_region(world, player, active_locations, LocationName.fire_ball_frenzy_region,
fire_ball_frenzy_region_locations, None)
@@ -188,6 +220,8 @@ def create_regions(world, player: int, active_locations):
LocationName.demolition_drain_pipe_bonus_2 : [0x672, 3],
LocationName.demolition_drain_pipe_dk : [0x672, 5],
}
if world.kongsanity[player]:
demolition_drain_pipe_region_locations[LocationName.demolition_drain_pipe_kong] = []
demolition_drain_pipe_region = create_region(world, player, active_locations, LocationName.demolition_drain_pipe_region,
demolition_drain_pipe_region_locations, None)
@@ -197,6 +231,8 @@ def create_regions(world, player: int, active_locations):
LocationName.ripsaw_rage_bonus_2 : [0x660, 3],
LocationName.ripsaw_rage_dk : [0x660, 5],
}
if world.kongsanity[player]:
ripsaw_rage_region_locations[LocationName.ripsaw_rage_kong] = []
ripsaw_rage_region = create_region(world, player, active_locations, LocationName.ripsaw_rage_region,
ripsaw_rage_region_locations, None)
@@ -206,6 +242,8 @@ def create_regions(world, player: int, active_locations):
LocationName.blazing_bazookas_bonus_2 : [0x66E, 3],
LocationName.blazing_bazookas_dk : [0x66E, 5],
}
if world.kongsanity[player]:
blazing_bazookas_region_locations[LocationName.blazing_bazookas_kong] = []
blazing_bazookas_region = create_region(world, player, active_locations, LocationName.blazing_bazookas_region,
blazing_bazookas_region_locations, None)
@@ -215,6 +253,8 @@ def create_regions(world, player: int, active_locations):
LocationName.low_g_labyrinth_bonus_2 : [0x670, 3],
LocationName.low_g_labyrinth_dk : [0x670, 5],
}
if world.kongsanity[player]:
low_g_labyrinth_region_locations[LocationName.low_g_labyrinth_kong] = []
low_g_labyrinth_region = create_region(world, player, active_locations, LocationName.low_g_labyrinth_region,
low_g_labyrinth_region_locations, None)
@@ -224,6 +264,8 @@ def create_regions(world, player: int, active_locations):
LocationName.krevice_kreepers_bonus_2 : [0x673, 3],
LocationName.krevice_kreepers_dk : [0x673, 5],
}
if world.kongsanity[player]:
krevice_kreepers_region_locations[LocationName.krevice_kreepers_kong] = []
krevice_kreepers_region = create_region(world, player, active_locations, LocationName.krevice_kreepers_region,
krevice_kreepers_region_locations, None)
@@ -233,6 +275,8 @@ def create_regions(world, player: int, active_locations):
LocationName.tearaway_toboggan_bonus_2 : [0x65F, 3],
LocationName.tearaway_toboggan_dk : [0x65F, 5],
}
if world.kongsanity[player]:
tearaway_toboggan_region_locations[LocationName.tearaway_toboggan_kong] = []
tearaway_toboggan_region = create_region(world, player, active_locations, LocationName.tearaway_toboggan_region,
tearaway_toboggan_region_locations, None)
@@ -242,6 +286,8 @@ def create_regions(world, player: int, active_locations):
LocationName.barrel_drop_bounce_bonus_2 : [0x66C, 3],
LocationName.barrel_drop_bounce_dk : [0x66C, 5],
}
if world.kongsanity[player]:
barrel_drop_bounce_region_locations[LocationName.barrel_drop_bounce_kong] = []
barrel_drop_bounce_region = create_region(world, player, active_locations, LocationName.barrel_drop_bounce_region,
barrel_drop_bounce_region_locations, None)
@@ -251,6 +297,8 @@ def create_regions(world, player: int, active_locations):
LocationName.krack_shot_kroc_bonus_2 : [0x66F, 3],
LocationName.krack_shot_kroc_dk : [0x66F, 5],
}
if world.kongsanity[player]:
krack_shot_kroc_region_locations[LocationName.krack_shot_kroc_kong] = []
krack_shot_kroc_region = create_region(world, player, active_locations, LocationName.krack_shot_kroc_region,
krack_shot_kroc_region_locations, None)
@@ -260,6 +308,8 @@ def create_regions(world, player: int, active_locations):
LocationName.lemguin_lunge_bonus_2 : [0x65E, 3],
LocationName.lemguin_lunge_dk : [0x65E, 5],
}
if world.kongsanity[player]:
lemguin_lunge_region_locations[LocationName.lemguin_lunge_kong] = []
lemguin_lunge_region = create_region(world, player, active_locations, LocationName.lemguin_lunge_region,
lemguin_lunge_region_locations, None)
@@ -269,6 +319,8 @@ def create_regions(world, player: int, active_locations):
LocationName.buzzer_barrage_bonus_2 : [0x676, 3],
LocationName.buzzer_barrage_dk : [0x676, 5],
}
if world.kongsanity[player]:
buzzer_barrage_region_locations[LocationName.buzzer_barrage_kong] = []
buzzer_barrage_region = create_region(world, player, active_locations, LocationName.buzzer_barrage_region,
buzzer_barrage_region_locations, None)
@@ -278,6 +330,8 @@ def create_regions(world, player: int, active_locations):
LocationName.kong_fused_cliffs_bonus_2 : [0x674, 3],
LocationName.kong_fused_cliffs_dk : [0x674, 5],
}
if world.kongsanity[player]:
kong_fused_cliffs_region_locations[LocationName.kong_fused_cliffs_kong] = []
kong_fused_cliffs_region = create_region(world, player, active_locations, LocationName.kong_fused_cliffs_region,
kong_fused_cliffs_region_locations, None)
@@ -287,6 +341,8 @@ def create_regions(world, player: int, active_locations):
LocationName.floodlit_fish_bonus_2 : [0x669, 3],
LocationName.floodlit_fish_dk : [0x669, 5],
}
if world.kongsanity[player]:
floodlit_fish_region_locations[LocationName.floodlit_fish_kong] = []
floodlit_fish_region = create_region(world, player, active_locations, LocationName.floodlit_fish_region,
floodlit_fish_region_locations, None)
@@ -296,6 +352,8 @@ def create_regions(world, player: int, active_locations):
LocationName.pothole_panic_bonus_2 : [0x677, 3],
LocationName.pothole_panic_dk : [0x677, 5],
}
if world.kongsanity[player]:
pothole_panic_region_locations[LocationName.pothole_panic_kong] = []
pothole_panic_region = create_region(world, player, active_locations, LocationName.pothole_panic_region,
pothole_panic_region_locations, None)
@@ -305,6 +363,8 @@ def create_regions(world, player: int, active_locations):
LocationName.ropey_rumpus_bonus_2 : [0x675, 3],
LocationName.ropey_rumpus_dk : [0x675, 5],
}
if world.kongsanity[player]:
ropey_rumpus_region_locations[LocationName.ropey_rumpus_kong] = []
ropey_rumpus_region = create_region(world, player, active_locations, LocationName.ropey_rumpus_region,
ropey_rumpus_region_locations, None)
@@ -314,6 +374,8 @@ def create_regions(world, player: int, active_locations):
LocationName.konveyor_rope_clash_bonus_2 : [0x657, 3],
LocationName.konveyor_rope_clash_dk : [0x657, 5],
}
if world.kongsanity[player]:
konveyor_rope_clash_region_locations[LocationName.konveyor_rope_clash_kong] = []
konveyor_rope_clash_region = create_region(world, player, active_locations, LocationName.konveyor_rope_clash_region,
konveyor_rope_clash_region_locations, None)
@@ -323,6 +385,8 @@ def create_regions(world, player: int, active_locations):
LocationName.creepy_caverns_bonus_2 : [0x678, 3],
LocationName.creepy_caverns_dk : [0x678, 5],
}
if world.kongsanity[player]:
creepy_caverns_region_locations[LocationName.creepy_caverns_kong] = []
creepy_caverns_region = create_region(world, player, active_locations, LocationName.creepy_caverns_region,
creepy_caverns_region_locations, None)
@@ -332,6 +396,8 @@ def create_regions(world, player: int, active_locations):
LocationName.lightning_lookout_bonus_2 : [0x665, 3],
LocationName.lightning_lookout_dk : [0x665, 5],
}
if world.kongsanity[player]:
lightning_lookout_region_locations[LocationName.lightning_lookout_kong] = []
lightning_lookout_region = create_region(world, player, active_locations, LocationName.lightning_lookout_region,
lightning_lookout_region_locations, None)
@@ -341,6 +407,8 @@ def create_regions(world, player: int, active_locations):
LocationName.koindozer_klamber_bonus_2 : [0x679, 3],
LocationName.koindozer_klamber_dk : [0x679, 5],
}
if world.kongsanity[player]:
koindozer_klamber_region_locations[LocationName.koindozer_klamber_kong] = []
koindozer_klamber_region = create_region(world, player, active_locations, LocationName.koindozer_klamber_region,
koindozer_klamber_region_locations, None)
@@ -350,6 +418,8 @@ def create_regions(world, player: int, active_locations):
LocationName.poisonous_pipeline_bonus_2 : [0x671, 3],
LocationName.poisonous_pipeline_dk : [0x671, 5],
}
if world.kongsanity[player]:
poisonous_pipeline_region_locations[LocationName.poisonous_pipeline_kong] = []
poisonous_pipeline_region = create_region(world, player, active_locations, LocationName.poisonous_pipeline_region,
poisonous_pipeline_region_locations, None)
@@ -360,6 +430,8 @@ def create_regions(world, player: int, active_locations):
LocationName.stampede_sprint_bonus_3 : [0x67B, 4],
LocationName.stampede_sprint_dk : [0x67B, 5],
}
if world.kongsanity[player]:
stampede_sprint_region_locations[LocationName.stampede_sprint_kong] = []
stampede_sprint_region = create_region(world, player, active_locations, LocationName.stampede_sprint_region,
stampede_sprint_region_locations, None)
@@ -369,6 +441,8 @@ def create_regions(world, player: int, active_locations):
LocationName.criss_cross_cliffs_bonus_2 : [0x67C, 3],
LocationName.criss_cross_cliffs_dk : [0x67C, 5],
}
if world.kongsanity[player]:
criss_cross_cliffs_region_locations[LocationName.criss_cross_cliffs_kong] = []
criss_cross_cliffs_region = create_region(world, player, active_locations, LocationName.criss_cross_cliffs_region,
criss_cross_cliffs_region_locations, None)
@@ -379,6 +453,8 @@ def create_regions(world, player: int, active_locations):
LocationName.tyrant_twin_tussle_bonus_3 : [0x67D, 4],
LocationName.tyrant_twin_tussle_dk : [0x67D, 5],
}
if world.kongsanity[player]:
tyrant_twin_tussle_region_locations[LocationName.tyrant_twin_tussle_kong] = []
tyrant_twin_tussle_region = create_region(world, player, active_locations, LocationName.tyrant_twin_tussle_region,
tyrant_twin_tussle_region_locations, None)
@@ -389,6 +465,8 @@ def create_regions(world, player: int, active_locations):
LocationName.swoopy_salvo_bonus_3 : [0x663, 4],
LocationName.swoopy_salvo_dk : [0x663, 5],
}
if world.kongsanity[player]:
swoopy_salvo_region_locations[LocationName.swoopy_salvo_kong] = []
swoopy_salvo_region = create_region(world, player, active_locations, LocationName.swoopy_salvo_region,
swoopy_salvo_region_locations, None)
@@ -503,9 +581,7 @@ def create_regions(world, player: int, active_locations):
sky_high_secret_region_locations = {}
if False:#world.include_trade_sequence[player]:
sky_high_secret_region_locations.update({
LocationName.sky_high_secret: [0x64B, 1],
})
sky_high_secret_region_locations[LocationName.sky_high_secret] = [0x64B, 1]
sky_high_secret_region = create_region(world, player, active_locations, LocationName.sky_high_secret_region,
sky_high_secret_region_locations, None)
@@ -517,9 +593,7 @@ def create_regions(world, player: int, active_locations):
cifftop_cache_region_locations = {}
if False:#world.include_trade_sequence[player]:
cifftop_cache_region_locations.update({
LocationName.cifftop_cache: [0x64D, 1],
})
cifftop_cache_region_locations[LocationName.cifftop_cache] = [0x64D, 1]
cifftop_cache_region = create_region(world, player, active_locations, LocationName.cifftop_cache_region,
cifftop_cache_region_locations, None)
@@ -622,29 +696,19 @@ def create_regions(world, player: int, active_locations):
LocationName.bazaars_general_store_2: [0x615, 3, True],
})
bramble_region_locations.update({
LocationName.brambles_bungalow: [0x619, 2],
})
bramble_region_locations[LocationName.brambles_bungalow] = [0x619, 2]
#flower_spot_region_locations.update({
# LocationName.flower_spot: [0x615, 3, True],
#})
barter_region_locations.update({
LocationName.barters_swap_shop: [0x61B, 3],
})
barter_region_locations[LocationName.barters_swap_shop] = [0x61B, 3]
barnacle_region_locations.update({
LocationName.barnacles_island: [0x61D, 2],
})
barnacle_region_locations[LocationName.barnacles_island] = [0x61D, 2]
blue_region_locations.update({
LocationName.blues_beach_hut: [0x621, 4],
})
blue_region_locations[LocationName.blues_beach_hut] = [0x621, 4]
blizzard_region_locations.update({
LocationName.blizzards_basecamp: [0x625, 4, True],
})
blizzard_region_locations[LocationName.blizzards_basecamp] = [0x625, 4, True]
bazaar_region = create_region(world, player, active_locations, LocationName.bazaar_region,
bazaar_region_locations, None)
@@ -817,7 +881,6 @@ def connect_regions(world, player, level_list):
level_list[32],
level_list[33],
level_list[34],
LocationName.kastle_kaos_region,
LocationName.sewer_stockpile_region,
]
@@ -835,10 +898,16 @@ def connect_regions(world, player, level_list):
for i in range(0, len(krematoa_levels)):
connect(world, player, names, LocationName.krematoa_region, krematoa_levels[i],
lambda state: (state.has(ItemName.bonus_coin, player, world.krematoa_bonus_coin_cost[player].value * (i+1))))
connect(world, player, names, LocationName.krematoa_region, LocationName.knautilus_region,
lambda state: (state.has(ItemName.krematoa_cog, player, 5)))
lambda state, i=i: (state.has(ItemName.bonus_coin, player, world.krematoa_bonus_coin_cost[player].value * (i+1))))
if world.goal[player] == "knautilus":
connect(world, player, names, LocationName.kaos_kore_region, LocationName.knautilus_region)
connect(world, player, names, LocationName.krematoa_region, LocationName.kastle_kaos_region,
lambda state: (state.has(ItemName.krematoa_cog, player, 5)))
else:
connect(world, player, names, LocationName.kaos_kore_region, LocationName.kastle_kaos_region)
connect(world, player, names, LocationName.krematoa_region, LocationName.knautilus_region,
lambda state: (state.has(ItemName.krematoa_cog, player, 5)))
def create_region(world: MultiWorld, player: int, active_locations, name: str, locations=None, exits=None):

View File

@@ -11,187 +11,270 @@ import os
import math
level_unlock_map = {
0x657: [0x65A],
0x65A: [0x680, 0x639, 0x659],
0x659: [0x65D],
0x65D: [0x65C],
0x65C: [0x688, 0x64F],
0x662: [0x681, 0x664],
0x664: [0x65B],
0x65B: [0x689, 0x661],
0x661: [0x63A, 0x666],
0x666: [0x650, 0x649],
0x667: [0x66A],
0x66A: [0x682, 0x658],
0x658: [0x68A, 0x66B],
0x66B: [0x668],
0x668: [0x651],
0x66D: [0x63C, 0x672],
0x672: [0x68B, 0x660],
0x660: [0x683, 0x66E],
0x66E: [0x670],
0x670: [0x652],
0x673: [0x684, 0x65F],
0x65F: [0x66C],
0x66C: [0x66F],
0x66F: [0x65E],
0x65E: [0x63D, 0x653, 0x68C, 0x64C],
0x676: [0x63E, 0x674, 0x685],
0x674: [0x63F, 0x669],
0x669: [0x677],
0x677: [0x68D, 0x675],
0x675: [0x654],
0x67A: [0x640, 0x678],
0x678: [0x665],
0x665: [0x686, 0x679],
0x679: [0x68E, 0x671],
0x67B: [0x67C],
0x67C: [0x67D],
0x67D: [0x663],
0x663: [0x67E],
}
location_rom_data = {
0xDC3000: [0x657, 1], # Lakeside Limbo
0xDC3001: [0x657, 2],
0xDC3002: [0x657, 3],
0xDC3003: [0x657, 5],
0xDC3100: [0x657, 7],
0xDC3004: [0x65A, 1], # Doorstop Dash
0xDC3005: [0x65A, 2],
0xDC3006: [0x65A, 3],
0xDC3007: [0x65A, 5],
0xDC3104: [0x65A, 7],
0xDC3008: [0x659, 1], # Tidal Trouble
0xDC3009: [0x659, 2],
0xDC300A: [0x659, 3],
0xDC300B: [0x659, 5],
0xDC3108: [0x659, 7],
0xDC300C: [0x65D, 1], # Skidda's Row
0xDC300D: [0x65D, 2],
0xDC300E: [0x65D, 3],
0xDC300F: [0x65D, 5],
0xDC310C: [0x65D, 7],
0xDC3010: [0x65C, 1], # Murky Mill
0xDC3011: [0x65C, 2],
0xDC3012: [0x65C, 3],
0xDC3013: [0x65C, 5],
0xDC3110: [0x65C, 7],
0xDC3014: [0x662, 1], # Barrel Shield Bust-Up
0xDC3015: [0x662, 2],
0xDC3016: [0x662, 3],
0xDC3017: [0x662, 5],
0xDC3114: [0x662, 7],
0xDC3018: [0x664, 1], # Riverside Race
0xDC3019: [0x664, 2],
0xDC301A: [0x664, 3],
0xDC301B: [0x664, 5],
0xDC3118: [0x664, 7],
0xDC301C: [0x65B, 1], # Squeals on Wheels
0xDC301D: [0x65B, 2],
0xDC301E: [0x65B, 3],
0xDC301F: [0x65B, 5],
0xDC311C: [0x65B, 7],
0xDC3020: [0x661, 1], # Springin' Spiders
0xDC3021: [0x661, 2],
0xDC3022: [0x661, 3],
0xDC3023: [0x661, 5],
0xDC3120: [0x661, 7],
0xDC3024: [0x666, 1], # Bobbing Barrel Brawl
0xDC3025: [0x666, 2],
0xDC3026: [0x666, 3],
0xDC3027: [0x666, 5],
0xDC3124: [0x666, 7],
0xDC3028: [0x667, 1], # Bazza's Blockade
0xDC3029: [0x667, 2],
0xDC302A: [0x667, 3],
0xDC302B: [0x667, 5],
0xDC3128: [0x667, 7],
0xDC302C: [0x66A, 1], # Rocket Barrel Ride
0xDC302D: [0x66A, 2],
0xDC302E: [0x66A, 3],
0xDC302F: [0x66A, 5],
0xDC312C: [0x66A, 7],
0xDC3030: [0x658, 1], # Kreeping Klasps
0xDC3031: [0x658, 2],
0xDC3032: [0x658, 3],
0xDC3033: [0x658, 5],
0xDC3130: [0x658, 7],
0xDC3034: [0x66B, 1], # Tracker Barrel Trek
0xDC3035: [0x66B, 2],
0xDC3036: [0x66B, 3],
0xDC3037: [0x66B, 5],
0xDC3134: [0x66B, 7],
0xDC3038: [0x668, 1], # Fish Food Frenzy
0xDC3039: [0x668, 2],
0xDC303A: [0x668, 3],
0xDC303B: [0x668, 5],
0xDC3138: [0x668, 7],
0xDC303C: [0x66D, 1], # Fire-ball Frenzy
0xDC303D: [0x66D, 2],
0xDC303E: [0x66D, 3],
0xDC303F: [0x66D, 5],
0xDC313C: [0x66D, 7],
0xDC3040: [0x672, 1], # Demolition Drainpipe
0xDC3041: [0x672, 2],
0xDC3042: [0x672, 3],
0xDC3043: [0x672, 5],
0xDC3140: [0x672, 7],
0xDC3044: [0x660, 1], # Ripsaw Rage
0xDC3045: [0x660, 2],
0xDC3046: [0x660, 3],
0xDC3047: [0x660, 5],
0xDC3144: [0x660, 7],
0xDC3048: [0x66E, 1], # Blazing Bazukas
0xDC3049: [0x66E, 2],
0xDC304A: [0x66E, 3],
0xDC304B: [0x66E, 5],
0xDC3148: [0x66E, 7],
0xDC304C: [0x670, 1], # Low-G Labyrinth
0xDC304D: [0x670, 2],
0xDC304E: [0x670, 3],
0xDC304F: [0x670, 5],
0xDC314C: [0x670, 7],
0xDC3050: [0x673, 1], # Krevice Kreepers
0xDC3051: [0x673, 2],
0xDC3052: [0x673, 3],
0xDC3053: [0x673, 5],
0xDC3150: [0x673, 7],
0xDC3054: [0x65F, 1], # Tearaway Toboggan
0xDC3055: [0x65F, 2],
0xDC3056: [0x65F, 3],
0xDC3057: [0x65F, 5],
0xDC3154: [0x65F, 7],
0xDC3058: [0x66C, 1], # Barrel Drop Bounce
0xDC3059: [0x66C, 2],
0xDC305A: [0x66C, 3],
0xDC305B: [0x66C, 5],
0xDC3158: [0x66C, 7],
0xDC305C: [0x66F, 1], # Krack-Shot Kroc
0xDC305D: [0x66F, 2],
0xDC305E: [0x66F, 3],
0xDC305F: [0x66F, 5],
0xDC315C: [0x66F, 7],
0xDC3060: [0x65E, 1], # Lemguin Lunge
0xDC3061: [0x65E, 2],
0xDC3062: [0x65E, 3],
0xDC3063: [0x65E, 5],
0xDC3160: [0x65E, 7],
0xDC3064: [0x676, 1], # Buzzer Barrage
0xDC3065: [0x676, 2],
0xDC3066: [0x676, 3],
0xDC3067: [0x676, 5],
0xDC3164: [0x676, 7],
0xDC3068: [0x674, 1], # Kong-Fused Cliffs
0xDC3069: [0x674, 2],
0xDC306A: [0x674, 3],
0xDC306B: [0x674, 5],
0xDC3168: [0x674, 7],
0xDC306C: [0x669, 1], # Floodlit Fish
0xDC306D: [0x669, 2],
0xDC306E: [0x669, 3],
0xDC306F: [0x669, 5],
0xDC316C: [0x669, 7],
0xDC3070: [0x677, 1], # Pothole Panic
0xDC3071: [0x677, 2],
0xDC3072: [0x677, 3],
0xDC3073: [0x677, 5],
0xDC3170: [0x677, 7],
0xDC3074: [0x675, 1], # Ropey Rumpus
0xDC3075: [0x675, 2],
0xDC3076: [0x675, 3],
0xDC3077: [0x675, 5],
0xDC3174: [0x675, 7],
0xDC3078: [0x67A, 1], # Konveyor Rope Klash
0xDC3079: [0x67A, 2],
0xDC307A: [0x67A, 3],
0xDC307B: [0x67A, 5],
0xDC3178: [0x67A, 7],
0xDC307C: [0x678, 1], # Creepy Caverns
0xDC307D: [0x678, 2],
0xDC307E: [0x678, 3],
0xDC307F: [0x678, 5],
0xDC317C: [0x678, 7],
0xDC3080: [0x665, 1], # Lightning Lookout
0xDC3081: [0x665, 2],
0xDC3082: [0x665, 3],
0xDC3083: [0x665, 5],
0xDC3180: [0x665, 7],
0xDC3084: [0x679, 1], # Koindozer Klamber
0xDC3085: [0x679, 2],
0xDC3086: [0x679, 3],
0xDC3087: [0x679, 5],
0xDC3184: [0x679, 7],
0xDC3088: [0x671, 1], # Poisonous Pipeline
0xDC3089: [0x671, 2],
0xDC308A: [0x671, 3],
0xDC308B: [0x671, 5],
0xDC3188: [0x671, 7],
0xDC308C: [0x67B, 1], # Stampede Sprint
@@ -199,23 +282,27 @@ location_rom_data = {
0xDC308E: [0x67B, 3],
0xDC308F: [0x67B, 4],
0xDC3090: [0x67B, 5],
0xDC318C: [0x67B, 7],
0xDC3091: [0x67C, 1], # Criss Kross Cliffs
0xDC3092: [0x67C, 2],
0xDC3093: [0x67C, 3],
0xDC3094: [0x67C, 5],
0xDC3191: [0x67C, 7],
0xDC3095: [0x67D, 1], # Tyrant Twin Tussle
0xDC3096: [0x67D, 2],
0xDC3097: [0x67D, 3],
0xDC3098: [0x67D, 4],
0xDC3099: [0x67D, 5],
0xDC3195: [0x67D, 7],
0xDC309A: [0x663, 1], # Swoopy Salvo
0xDC309B: [0x663, 2],
0xDC309C: [0x663, 3],
0xDC309D: [0x663, 4],
0xDC309E: [0x663, 5],
0xDC319A: [0x663, 7],
0xDC309F: [0x67E, 1], # Rocket Rush
0xDC30A0: [0x67E, 5],
@@ -243,7 +330,7 @@ location_rom_data = {
#0xDC30B4: [0x64D, 1], # Disabled until Trade Sequence
0xDC30B5: [0x64E, 1],
0xDC30B6: [0x5FD, 4], # Banana Bird Mother
0xDC30B6: [0x5FE, 4], # Banana Bird Mother
# DKC3_TODO: Disabled until Trade Sequence
#0xDC30B7: [0x615, 2, True],
@@ -256,6 +343,18 @@ location_rom_data = {
#0xDC30BE: [0x625, 4, True],
}
boss_location_ids = [
0xDC30A1,
0xDC30A2,
0xDC30A3,
0xDC30A4,
0xDC30A5,
0xDC30A6,
0xDC30A7,
0xDC30A8,
0xDC30B6,
]
item_rom_data = {
0xDC3001: [0x5D5], # 1-Up Balloon
@@ -400,10 +499,13 @@ def patch_rom(world, rom, player, active_level_list):
rom.write_byte(0x3484DE, 0xEA)
rom.write_byte(0x348528, 0x80) # Prevent Single-Ski Lock
# Make Swanky free
rom.write_byte(0x348C48, 0x00)
rom.write_bytes(0x34AB70, bytearray([0xEA, 0xEA]))
rom.write_bytes(0x34ABF7, bytearray([0xEA, 0xEA]))
rom.write_bytes(0x34ACD0, bytearray([0xEA, 0xEA]))
# Banana Bird Costs
if world.goal[player] == "banana_bird_hunt":
banana_bird_cost = math.floor(world.number_of_banana_birds[player] * world.percentage_of_banana_birds[player] / 100.0)
@@ -462,6 +564,25 @@ def patch_rom(world, rom, player, active_level_list):
rom.write_byte(0x9130, world.starting_life_count[player].value)
rom.write_byte(0x913B, world.starting_life_count[player].value)
# Cheat options
cheat_bytes = [0x00, 0x00]
if world.merry[player]:
cheat_bytes[0] |= 0x01
if world.autosave[player]:
cheat_bytes[0] |= 0x02
if world.difficulty[player] == "tufst":
cheat_bytes[0] |= 0x80
cheat_bytes[1] |= 0x80
elif world.difficulty[player] == "hardr":
cheat_bytes[0] |= 0x00
cheat_bytes[1] |= 0x00
elif world.difficulty[player] == "norml":
cheat_bytes[1] |= 0x40
rom.write_bytes(0x8303, bytearray(cheat_bytes))
# Handle Level Shuffle Here
if world.level_shuffle[player]:
@@ -469,6 +590,9 @@ def patch_rom(world, rom, player, active_level_list):
rom.write_byte(level_dict[level_list[i]].nameIDAddress, level_dict[active_level_list[i]].nameID)
rom.write_byte(level_dict[level_list[i]].levelIDAddress, level_dict[active_level_list[i]].levelID)
rom.write_byte(0x3FF800 + level_dict[active_level_list[i]].levelID, level_dict[level_list[i]].levelID)
rom.write_byte(0x3FF860 + level_dict[level_list[i]].levelID, level_dict[active_level_list[i]].levelID)
# First levels of each world
rom.write_byte(0x34BC3E, (0x32 + level_dict[active_level_list[0]].levelID))
rom.write_byte(0x34BC47, (0x32 + level_dict[active_level_list[5]].levelID))
@@ -495,6 +619,52 @@ def patch_rom(world, rom, player, active_level_list):
rom.write_byte(0x32F339, 0x55)
# Handle KONGsanity Here
if world.kongsanity[player]:
# Arich's Hoard KONGsanity fix
rom.write_bytes(0x34BA8C, bytearray([0xEA, 0xEA]))
# Don't hide the level flag if the 0x80 bit is set
rom.write_bytes(0x34CE92, bytearray([0x80]))
# Use the `!` next to level name for indicating KONG letters
rom.write_bytes(0x34B8F0, bytearray([0x80]))
rom.write_bytes(0x34B8F3, bytearray([0x80]))
# Hijack to code to set the 0x80 flag for the level when you complete KONG
rom.write_bytes(0x3BCD4B, bytearray([0x22, 0x80, 0xFA, 0XB8])) # JSL $B8FA80
rom.write_bytes(0x38FA80, bytearray([0xDA])) # PHX
rom.write_bytes(0x38FA81, bytearray([0x48])) # PHA
rom.write_bytes(0x38FA82, bytearray([0x08])) # PHP
rom.write_bytes(0x38FA83, bytearray([0xE2, 0x20])) # SEP #20
rom.write_bytes(0x38FA85, bytearray([0x48])) # PHA
rom.write_bytes(0x38FA86, bytearray([0x18])) # CLC
rom.write_bytes(0x38FA87, bytearray([0x6D, 0xD3, 0x18])) # ADC $18D3
rom.write_bytes(0x38FA8A, bytearray([0x8D, 0xD3, 0x18])) # STA $18D3
rom.write_bytes(0x38FA8D, bytearray([0x68])) # PLA
rom.write_bytes(0x38FA8E, bytearray([0xC2, 0x20])) # REP 20
rom.write_bytes(0x38FA90, bytearray([0X18])) # CLC
rom.write_bytes(0x38FA91, bytearray([0x6D, 0xD5, 0x05])) # ADC $05D5
rom.write_bytes(0x38FA94, bytearray([0x8D, 0xD5, 0x05])) # STA $05D5
rom.write_bytes(0x38FA97, bytearray([0xAE, 0xB9, 0x05])) # LDX $05B9
rom.write_bytes(0x38FA9A, bytearray([0xBD, 0x32, 0x06])) # LDA $0632, X
rom.write_bytes(0x38FA9D, bytearray([0x09, 0x80, 0x00])) # ORA #8000
rom.write_bytes(0x38FAA0, bytearray([0x9D, 0x32, 0x06])) # STA $0632, X
rom.write_bytes(0x38FAA3, bytearray([0xAD, 0xD5, 0x18])) # LDA $18D5
rom.write_bytes(0x38FAA6, bytearray([0xD0, 0x03])) # BNE $80EA
rom.write_bytes(0x38FAA8, bytearray([0x9C, 0xD9, 0x18])) # STZ $18D9
rom.write_bytes(0x38FAAB, bytearray([0xA9, 0x78, 0x00])) # LDA #0078
rom.write_bytes(0x38FAAE, bytearray([0x8D, 0xD5, 0x18])) # STA $18D5
rom.write_bytes(0x38FAB1, bytearray([0x28])) # PLP
rom.write_bytes(0x38FAB2, bytearray([0x68])) # PLA
rom.write_bytes(0x38FAB3, bytearray([0xFA])) # PLX
rom.write_bytes(0x38FAB4, bytearray([0x6B])) # RTL
# End Handle KONGsanity
# Handle Credits
rom.write_bytes(0x32A5DF, bytearray([0x41, 0x52, 0x43, 0x48, 0x49, 0x50, 0x45, 0x4C, 0x41, 0x47, 0x4F, 0x20, 0x4D, 0x4F, 0xC4])) # "ARCHIPELAGO MOD"
rom.write_bytes(0x32A5EE, bytearray([0x00, 0x03, 0x50, 0x4F, 0x52, 0x59, 0x47, 0x4F, 0x4E, 0xC5])) # "PORYGONE"
from Main import __version__
rom.name = bytearray(f'D3{__version__.replace(".", "")[0:3]}_{player}_{world.seed:11}\0', 'utf8')[:21]
@@ -516,6 +686,17 @@ def patch_rom(world, rom, player, active_level_list):
rom.write_byte(0x32DD63, 0xEA)
rom.write_byte(0x32DD64, 0xEA)
# Don't grant Banana Birds at Bears
rom.write_byte(0x3492DB, 0xEA)
rom.write_byte(0x3492DC, 0xEA)
rom.write_byte(0x3492DD, 0xEA)
rom.write_byte(0x3493F4, 0xEA)
rom.write_byte(0x3493F5, 0xEA)
rom.write_byte(0x3493F6, 0xEA)
# Don't grant present at Blizzard
rom.write_byte(0x8454, 0x00)
# Don't grant Patch and Skis from their bosses
rom.write_byte(0x3F3762, 0x00)
rom.write_byte(0x3F377B, 0x00)

View File

@@ -4,7 +4,7 @@ import math
import threading
from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification
from .Items import DKC3Item, ItemData, item_table, inventory_table
from .Items import DKC3Item, ItemData, item_table, inventory_table, junk_table
from .Locations import DKC3Location, all_locations, setup_locations
from .Options import dkc3_options
from .Regions import create_regions, connect_regions
@@ -38,9 +38,9 @@ class DKC3World(World):
mystery of why Donkey Kong and Diddy disappeared while on vacation.
"""
game: str = "Donkey Kong Country 3"
options = dkc3_options
option_definitions = dkc3_options
topology_present = False
data_version = 1
data_version = 2
#hint_blacklist = {LocationName.rocket_rush_flag}
item_name_to_id = {name: data.code for name, data in item_table.items()}
@@ -99,10 +99,13 @@ class DKC3World(World):
# Bosses
total_required_locations += number_of_bosses
# Secret Caves
total_required_locations += 13
if self.world.kongsanity[self.player]:
total_required_locations += 39
## Brothers Bear
if False:#self.world.include_trade_sequence[self.player]:
total_required_locations += 10
@@ -118,7 +121,11 @@ class DKC3World(World):
total_junk_count = total_required_locations - len(itempool)
itempool += [self.create_item(ItemName.bear_coin)] * total_junk_count
junk_pool = []
for item_name in self.world.random.choices(list(junk_table.keys()), k=total_junk_count):
junk_pool += [self.create_item(item_name)]
itempool += junk_pool
self.active_level_list = level_list.copy()

View File

@@ -15,6 +15,11 @@
compatible hardware
- Your legally obtained Donkey Kong Country 3 ROM file, probably named `Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc`
## Optional Software
- Donkey Kong Country 3 Tracker
- PopTracker from: [PopTracker Releases Page](https://github.com/black-sliver/PopTracker/releases/)
- Donkey Kong Country 3 Archipelago PopTracker pack from: [DKC3 AP Tracker Releases Page](https://github.com/PoryGone/DKC3_AP_Tracker/releases/)
## Installation Procedures
### Windows Setup
@@ -57,7 +62,6 @@ validator page: [YAML Validation page](/mysterycheck)
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 Donkey Kong Country 3 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
@@ -65,7 +69,7 @@ validator page: [YAML Validation page](/mysterycheck)
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 `.apsm` extension.
files. Your patch file should have a `.apdkc3` 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.

View File

@@ -78,9 +78,14 @@ def generate_mod(world, output_directory: str):
global data_final_template, locale_template, control_template, data_template, settings_template
with template_load_lock:
if not data_final_template:
mod_template_folder = os.path.join(os.path.dirname(__file__), "data", "mod_template")
def load_template(name: str):
import pkgutil
data = pkgutil.get_data(__name__, "data/mod_template/" + name).decode()
return data, name, lambda: False
template_env: Optional[jinja2.Environment] = \
jinja2.Environment(loader=jinja2.FileSystemLoader([mod_template_folder]))
jinja2.Environment(loader=jinja2.FunctionLoader(load_template))
data_template = template_env.get_template("data.lua")
data_final_template = template_env.get_template("data-final-fixes.lua")
locale_template = template_env.get_template(r"locale/en/locale.cfg")
@@ -102,7 +107,7 @@ def generate_mod(world, output_directory: str):
random = multiworld.slot_seeds[player]
def flop_random(low, high, base=None):
"""Guarentees 50% below base and 50% above base, uniform distribution in each direction."""
"""Guarantees 50% below base and 50% above base, uniform distribution in each direction."""
if base:
distance = random.random()
if random.randint(0, 1):
@@ -158,7 +163,21 @@ def generate_mod(world, output_directory: str):
mod_dir = os.path.join(output_directory, mod_name + "_" + Utils.__version__)
en_locale_dir = os.path.join(mod_dir, "locale", "en")
os.makedirs(en_locale_dir, exist_ok=True)
shutil.copytree(os.path.join(os.path.dirname(__file__), "data", "mod"), mod_dir, dirs_exist_ok=True)
if world.zip_path:
# Maybe investigate read from zip, write to zip, without temp file?
with zipfile.ZipFile(world.zip_path) as zf:
for file in zf.infolist():
if not file.is_dir() and "/data/mod/" in file.filename:
path_part = Utils.get_text_after(file.filename, "/data/mod/")
target = os.path.join(mod_dir, path_part)
os.makedirs(os.path.split(target)[0], exist_ok=True)
with open(target, "wb") as f:
f.write(zf.read(file))
else:
shutil.copytree(os.path.join(os.path.dirname(__file__), "data", "mod"), mod_dir, dirs_exist_ok=True)
with open(os.path.join(mod_dir, "data.lua"), "wt") as f:
f.write(data_template_code)
with open(os.path.join(mod_dir, "data-final-fixes.lua"), "wt") as f:

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