Compare commits

...

136 Commits

Author SHA1 Message Date
NewSoupVi
b350521893 Update docs/apworld_dev_faq.md
Co-authored-by: qwint <qwint.42@gmail.com>
2024-09-05 22:21:51 +02:00
NewSoupVi
a2ba2f3dbf Update docs/apworld_dev_faq.md
Co-authored-by: qwint <qwint.42@gmail.com>
2024-09-05 21:30:30 +02:00
NewSoupVi
ce8d254912 Update apworld_dev_faq.md 2024-09-05 21:23:33 +02:00
NewSoupVi
153f454fb8 Update docs/apworld_dev_faq.md
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-09-05 21:22:36 +02:00
NewSoupVi
df1f3dc730 Update docs/apworld_dev_faq.md
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-09-05 21:21:24 +02:00
NewSoupVi
8cefe85630 Update docs/apworld_dev_faq.md
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-09-05 21:20:51 +02:00
NewSoupVi
a1779c1924 Update apworld_dev_faq.md 2024-09-05 21:20:35 +02:00
NewSoupVi
663b5a28a5 Update docs/apworld_dev_faq.md
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-09-05 21:20:10 +02:00
NewSoupVi
cf3d4ff837 Update apworld_dev_faq.md 2024-09-05 19:04:07 +02:00
NewSoupVi
1d8d04ea03 Update docs/apworld_dev_faq.md
Co-authored-by: qwint <qwint.42@gmail.com>
2024-09-05 18:56:33 +02:00
NewSoupVi
49394bebda Update docs/apworld_dev_faq.md
Co-authored-by: qwint <qwint.42@gmail.com>
2024-09-05 18:55:58 +02:00
NewSoupVi
9ec1f8de6e Update docs/apworld_dev_faq.md
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2024-08-13 18:19:21 +02:00
NewSoupVi
e74e472e3f Update docs/apworld_dev_faq.md
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2024-08-13 18:18:55 +02:00
NewSoupVi
850033b311 Update docs/apworld_dev_faq.md
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-07-31 22:34:10 +02:00
NewSoupVi
3a70bf9f4f Update docs/apworld_dev_faq.md
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-07-31 22:33:57 +02:00
NewSoupVi
205fa71cc7 Update docs/apworld_dev_faq.md
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-07-31 22:33:50 +02:00
NewSoupVi
8420c72ec4 Update docs/apworld_dev_faq.md
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-07-31 22:33:44 +02:00
NewSoupVi
f9b07a5b9a Update docs/apworld_dev_faq.md
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-07-31 22:33:36 +02:00
NewSoupVi
295e719ef3 Actually copy in the text 2024-07-31 19:34:37 +02:00
NewSoupVi
db5be6a411 Docs: Dev FAQ - About indirect conditions
I wrote up a big effortpost about indirect conditions for nex on the [DS3 3.0 PR](https://github.com/ArchipelagoMW/Archipelago/pull/3128#discussion_r1693843193).

The version I'm [PRing to the world API document](https://github.com/ArchipelagoMW/Archipelago/pull/3552) is very brief and unnuanced, because I'd rather people use too many indirect conditions than too few.
But that might leave some devs wanting to know more.

I think that comment on nex's DS3 PR is probably the best detailed explanation for indirect conditions that exists currently.

So I think it's good if it exists somewhere. And the FAQ doc seems like the best place right now, because I don't want to write an entirely new doc at the moment.
2024-07-27 08:18:00 +02:00
Exempt-Medic
6994f863e5 Core: Make excluded locations and priority locations excluded and remove unreachable code (#3424)
* Make excluded and priority locations excluded

* Only pass on KeyError

* Alternative/Clearer format
2024-07-26 17:51:55 +02:00
Jérémie Bolduc
9d36ad0df2 Stardew Valley: Properly support Universal Tracker (#3630)
* save the seed in slot data to reuse it in UT

* add logging when seed is missing

* add UT test and fix bundle test

* self review

* run UT test on allsanity+mod so it's more meaningfull
2024-07-26 11:33:14 +02:00
Star Rauchenberger
cc22161644 Lingo: Add panels mode door shuffle (#3163)
* Created panels mode door shuffle

* Added some panel door item names

* Remove RUNT TURN panel door

Not really useful.

* Fix logic with First SIX related stuff

* Add group_doors to slot data

* Fix LEVEL 2 behavior with panels mode

* Fixed unit tests

* Fixed duplicate IDs from merge

* Just regenerated new IDs

* Fixed duplication of color and door group items

* Removed unnecessary unit test option

* Fix The Seeker being achievable without entrance door

* Fix The Observant being achievable without locked panels

* Added some more panel doors

* Added Progressive Suits Area

* Lingo: Fix Basement access with THE MASTER

* Added indirect conditions for MASTER-blocked entrances

* Fixed Incomparable achievement access

* Fix STAIRS panel logic

* Fix merge error with good items

* Is this clearer?

* DREAD and TURN LEARN

* Allow a weird edge case for reduced locations

Panels mode door shuffle + grouped doors + color shuffle + pilgrimage enabled is exactly the right number of items for reduced locations. Removing color shuffle also allows for disabling pilgrimage, adding sunwarp locking, or both, with a couple of locations left over.

* Prevent small sphere one on panels mode

* Added shuffle_doors aliases for old options

* Fixed a unit test

* Updated datafile

* Tweaked requirements for reduced locations

* Added player name to OptionError messages

* Update generated.dat
2024-07-26 10:53:11 +02:00
Star Rauchenberger
d030a698a6 Lingo: Changed minimum progression requirement (#3672) 2024-07-25 23:09:37 +02:00
Exempt-Medic
b6e5223aa2 Docs: Expanding on the answers in the FAQ (#3690)
* Expand on some existing answers

* Oops

* Sphere "one"

* Removing while

* Update docs/apworld_dev_faq.md

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2024-07-25 23:02:25 +02:00
qwint
79843803cf Docs: Add header to FAQ doc referencing other relevant docs (#3692)
* Add header to FAQ doc referencing other relevant docs

* Update docs/apworld_dev_faq.md

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Update docs/apworld_dev_faq.md

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2024-07-25 23:01:22 +02:00
Tsukino
5fb1ebdcfd Docs: Add Swedish Guide for Pokemon Emerald (#3252)
* Docs: Add Swedish Guide for Pokemon Emerald

Swedish Translation

* v2

some proof reading & clarification changes

* v3

* v4

* v5

typo

* v6

* Update worlds/pokemon_emerald/docs/setup_sv.md

Co-authored-by: Bryce Wilson <gyroscope15@gmail.com>

* Update worlds/pokemon_emerald/docs/setup_sv.md

Co-authored-by: Bryce Wilson <gyroscope15@gmail.com>

* v7

Tried to reduce the length of lines, this should still convey the same message/meaning

* typo

* v8

Removed Leading/Trailing Spaces

* typo v2

* Added a couple of full stops.

* lowercase typos

* Update setup_sv.md

* Apply suggestions from code review

Co-authored-by: Bryce Wilson <gyroscope15@gmail.com>

---------

Co-authored-by: Bryce Wilson <gyroscope15@gmail.com>
Co-authored-by: bittersweetrin <chandraherbozo@gmail.com>
2024-07-25 09:30:23 +02:00
CookieCat
b019485944 AHIT: Update Setup Guide (#3647) 2024-07-25 09:27:22 +02:00
Witchybun
205ca7fa37 Stardew Valley: Fix Daggerfish, Cropsanity; Move Some Rules to Content Packs; Add Missing Shipsanity Location (#3626)
* Fix logic bug on daggerfish

* Make new region for pond.

* Fix SVE logic for crops

* Fix Distant Lands Cropsanity

* Fix failing tests.

* Reverting removing these for now.

* Fix bugs, add combat requirement

* convert str into tuple directly

* add ginger island to mod tests

* Move a lot of mod item logic to content pack

* Gut the rules from DL while we're at it.

* Import nuke

* Fix alecto

* Move back some rules for now.

* Move archaeology rules

* Add some comments why its done.

* Clean up archaeology and fix sve

* Moved dulse to water item class

* Remove digging like worms for now

* fix

* Add missing shipsanity location

* Move background names around or something idk

* Revert ArchaeologyTrash for now

---------

Co-authored-by: Jouramie <jouramie@hotmail.com>
2024-07-25 09:22:46 +02:00
black-sliver
8949e21565 settings: safer writing (#3644)
* settings: clean up imports

* settings: try to use atomic rename

* settings: flush, sync and validate new yaml

before replacing the old one

* settings: add test for Settings.save
2024-07-25 09:10:36 +02:00
qwint
deae524e9b Docs: add a living faq document for sharing dev solutions (#3156)
* adding one faq :)

* adding another faq that links to the relevant file

* add lined line breaks between questions and lower the heading size of the question so sub-divisions can be added later

* missed some newlines

* updating best practice filler method

* add note about get_filler_item_name()

* updates to wording from review

* add section to CODEOWNERS for maintainers of this doc

* use underscores to reference the file easier in CODEOWNERS

* update link to be direct and filter to function name
2024-07-25 09:05:04 +02:00
qwint
496f0e09af CommonClient: forget password when disconnecting (#3641)
* makes the kivy connect button do the same username forgetting that /connect does to fix an issue where losing connection would make you unable to connect to a different server

* extract duplicate code

* per request, adds handling on any disconnect to forget the saved password as to not leak it to other servers

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-07-25 08:21:51 +02:00
agilbert1412
f34da74012 Stardew Valley: Make Fairy Dust a Ginger Island only item and location (#3650) 2024-07-25 06:13:16 +02:00
Alchav
94e6e978f3 Pokémon R/B: Also fix Rt 4 Hidden Item (#3668)
Co-authored-by: alchav <alchav@jalchavware.com>
2024-07-25 06:07:20 +02:00
Silent
697f749518 TUNIC: Missing slot data bugfix (#3628)
* Fix certain items not being added to slot data

* Change where items get added to slot data
2024-07-25 06:06:45 +02:00
qwint
2307694012 HK: fix remove issues failing collect/remove test (#3667) 2024-07-25 03:08:58 +02:00
Exempt-Medic
b23c120258 Subnautica: Fix deprecated option getting (#3685) 2024-07-24 22:17:43 +02:00
Silent
ea1bb8d927 TUNIC: Missing slot data bugfix (#3628)
* Fix certain items not being added to slot data

* Change where items get added to slot data
2024-07-24 14:37:18 +02:00
Star Rauchenberger
e714d2e129 Lingo: Add option to prevent shuffling postgame (#3350)
* Lingo: Add option to prevent shuffling postgame

* Allow roof access on door shuffle

* Fix broken unit test

* Simplified THE END edge case

* Revert unnecessary change

* Review comments

* Fix mastery unit test

* Update generated.dat

* Added player's name to error message
2024-07-24 14:34:51 +02:00
JKLeckr
878d5141ce Project: Add .code-workspace wildcard to gitignore 2024-07-24 14:08:16 +02:00
Ladybunne
1852287c91 LADX: Add an item group for instruments (#3666)
* Add an item group for LADX instruments

* Update worlds/ladx/__init__.py

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* Fix indent depth

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-07-24 14:07:07 +02:00
t3hf1gm3nt
8756f48e46 [TLOZ]: Fix determinism / Add Location Name Groups / Remove Level 9 Junk Fill (#3670)
* [TLOZ]: Fix determinism / Add Location Name Groups / Remove Level 9 Junk Fill

Axing the final uses of world.multiworld.random that were missed before, hopefully fixing the determinism issue brought up in Issue #3664 (at least on TLOZ's end, leaving SMZ3 alone). Also adding location name groups finally, as well as axing the Level 9 Junk Fill because with the new location name groups players can choose to exclude Level 9 with exclude locations instead.

* location name groups

* add take any item and sword cave location name groups

* use sets like you're supposed to, silly
2024-07-24 14:00:16 +02:00
agilbert1412
ff680b26cc DLC Quest: Add options presets to DLC Quest (#3676)
* - Add options presets to DLC Quest

* - Removed unused import

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-07-24 13:49:28 +02:00
JaredWeakStrike
29a0b013cb KH2: Hotfix update for game verison 1.0.0.9 (#3534)
* update the addresses hopefully

* todo

* update address for steam and epic

* oops

* leftover hard address

* made auto tracking say which version of the game

* not needed anymore since they were updated
2024-07-24 13:47:19 +02:00
Alchav
e7dbfa7fcd FFMQ: Efficiency Improvement and Use New Options Methods (#2767)
* FFMQ Efficiency improvement and use new options methods

* Hard check for 0x01 game status

* Fixes

* Why were Mac's Ship entrance hints excluded?

* Two remaining per_slot_randoms purged

* reformat generate_early

* Utils.parse_yaml
2024-07-24 13:46:14 +02:00
agilbert1412
ad5089b5a3 DLC Quest - Add option groups to DLC Quest (#3677)
* - Add option groups to DLC Quest

* - Slight reorganisation

* - Add type hint
2024-07-24 13:36:41 +02:00
NewSoupVi
dc50444edd The Witness: Small naming inconsistencies (#3618) 2024-07-24 13:13:41 +02:00
Silent
ed4ad386e8 TUNIC: Add setting to disable local spoiler to host yaml (#3661)
* Add TunicSettings class for host yaml options

* Update __init__.py

* Update worlds/tunic/__init__.py

Co-authored-by: Scipio Wright <scipiowright@gmail.com>

* Use self.settings

* Remove unused import

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2024-07-23 09:04:24 +02:00
Star Rauchenberger
5188375736 Lingo: Add pilgrimage logic through Starting Room (#3654)
* Lingo: Add pilgrimage logic through Starting Room

* Added unit test

* Reverse order of two doors in unit test

* Remove print statements from TestPilgrimage

* Update generated.dat
2024-07-23 08:34:47 +02:00
Star Rauchenberger
9c2933f803 Lingo: Fix Early Color Hallways painting in pilgrimages (#3645) 2024-07-23 00:45:49 +02:00
Scipio Wright
b840c3fe1a TUNIC: Move 3 locations to Quarry Back (#3649)
* Move 3 locations to Quarry Back

* Change the non-er region too
2024-07-23 00:43:41 +02:00
agilbert1412
c12d3dd6ad Stardew valley: Fix Queen of Sauce Cookbook conditions (#3651)
* - Extracted walnut logic to a Mixin so it can be used in content pack requirements

* - Add 100 walnut requirements to the Queen of Sauce Cookbook

* - Woops a file wasn't added to previous commits

* - Make the queen of sauce cookbook a ginger island only thing, due to the walnut requirement

* - Moved the book in the correct content pack

* - Removed an empty class that I'm not sure where it came from
2024-07-23 00:36:42 +02:00
Trevor L
f7989780fa Bomb Rush Cyberfunk: Fix final graffiti location being unobtainable (#3669) 2024-07-22 09:17:34 +02:00
agilbert1412
e59bec36ec Stardew Valley: Add gourmand frog rules for completing his tasks sequentially (#3652) 2024-07-22 08:32:40 +02:00
agilbert1412
48a0fb05a2 Stardew Valley: Removed Stardrop Tea from Full Shipment (#3655) 2024-07-22 01:52:44 +02:00
chandler05
12f1ef873c A Short Hike: Fix Boat Rental purchase being incorrectly calculated (#3639) 2024-07-22 01:47:46 +02:00
Rensen3
d7d4565429 YGO06: fixes non-deterministic bug by changing sets to lists (#3674) 2024-07-22 01:27:10 +02:00
qwint
7039b17bf6 CommonClient: fix bug when using Connect button without a disconnect (#3609)
* makes the kivy connect button do the same username forgetting that /connect does to fix an issue where losing connection would make you unable to connect to a different server

* extract duplicate code
2024-07-22 01:12:11 +02:00
Jérémie Bolduc
34e7748f23 Stardew Valley: Make sure number of month in time logic is a int to improve performance by ~20% (#3665)
* make sure number of month is actually a int

* improve rule explain like in pr

* remove redundant if in can_complete_bundle

* assert number is int so cache is not bloated
2024-07-20 21:24:24 +02:00
gurglemurgle5
e33a9991ef CommonClient: Escape markup sent in chat messages (#3659)
* escape markup in uncolored text

* Fix comment to allign with style guide

Fixes the comment so it follows the style guide, along with making it
better explain the code.

* Make more concise
2024-07-19 08:37:59 +02:00
black-sliver
4d1507cd0e Core: Update cx_freeze to 7.2.0 and freeze it (#3648)
supersedes ArchipelagoMW/Archipelago#3405
2024-07-18 00:49:59 +02:00
Fabian Dill
7b39b23f73 Subnautica: increase minimum client version (#3657) 2024-07-17 22:33:51 +02:00
Sunny Bat
925e02dca7 Raft: Move to new Options API (#3587) 2024-07-15 15:09:02 +02:00
CookieCat
e76d32e908 AHIT: Fix act shuffle test fail (#3522) 2024-07-14 14:17:05 +02:00
dennisw100
08a36ec223 Undertale: Fixed output location of the patched game in UndertaleClient.py (#3418)
* Update UndertaleClient.py Fixed output location of the patched game

Fixed the error that when the client is opened outside of the archipelago folder, the patched folder would be created in there which on windows ends up trying to create it in the system32 folder

Bug Report: https://discord.com/channels/731205301247803413/1148330675452264499/1237412436382973962

* Undertale: removed unnecessary wrapping in UndertaleClient.py

I did not know os.path.join was unnecessary in this case the more you know.
2024-07-14 14:11:52 +02:00
Bryce Wilson
48dc14421e Pokemon Emerald: Fix logic for coin case location (#3631) 2024-07-14 14:05:50 +02:00
black-sliver
948f50f35d customserver: fix minor memory leak (#3636)
Old code keeps ref to last started room's task and thus never fully cleans it up.
2024-07-14 13:56:56 +02:00
black-sliver
187f9dac94 customserver: preemtively run GC before starting room (#3637)
GC seems to be lazy.
2024-07-14 13:56:27 +02:00
Scipio Wright
eaec41d885 TUNIC: Fix event region for Quarry fuse (#3635) 2024-07-11 22:44:29 +02:00
Doug Hoskisson
1e3a4b6db5 Zillion: more rooms added to map_gen option (#3634) 2024-07-10 23:11:47 -07:00
Alchav
8c86139066 ALTTP: Bombable Wall to Crystaroller Room Logic (#3627) 2024-07-10 17:15:29 +02:00
black-sliver
c96c554dfa Tests, WebHost: add tests for host_room and minor cleanup (#3619)
* Tests, WebHost: move out setUp and fix typing in api_generate

Also fixes a typo
and changes client to be per-test rather than a ClassVar

* Tests, WebHost: add tests for display_log endpoint

* Tests, WebHost: add tests for host_room endpoint

* Tests, WebHost: enable Flask DEBUG mode for tests

This provides the actual error if a test raised an exception on the server.

* Tests, WebHost: use user_path for logs

This is what custom_server does now.

* Tests, WebHost: avoid triggering security scans
2024-07-07 16:51:10 +02:00
agilbert1412
9b22458f44 Stardew Valley 6.x.x: The Content Update (#3478)
Focus of the Update: Compatibility with Stardew Valley 1.6 Released on March 19th 2024
This includes randomization for pretty much all of the new content, including but not limited to
- Raccoon Bundles
- Booksanity
- Skill Masteries
- New Recipes, Craftables, Fish, Maps, Farm Type, Festivals and Quests

This also includes a significant reorganisation of the code into "Content Packs", to allow for easier modularity of various game mechanics between the settings and the supported mods. This improves maintainability quite a bit.

In addition to that, a few **very** requested new features have been introduced, although they weren't the focus of this update
- Walnutsanity
- Player Buffs
- More customizability in settings, such as shorter special orders, ER without farmhouse
- New Remixed Bundles
2024-07-07 15:04:25 +02:00
NewSoupVi
f99ee77325 The Witness: Add some unit tests (#3328)
* Add hidden early symbol item option, make some unit tests

* Add early symbol item false to the arrows test

* I guess it's not an issue

* more tests

* assertEqual

* cleanup

* add minimum symbols test for all 3 modes

* Formatting

* Add more minimal beatability tests

* one more for the road

* I HATE THIS AAAAAAAAAAAHHHHHHHHHHH WHY DID WE GO WITH OPTIONS

* loiaqeäsdhgalikSDGHjasDÖKHGASKLDÖGHJASKLJGHJSAÖkfaöslifjasöfASGJÖASDLFGJ'sklgösLGIKsdhJLGÖsdfjälghklDASFJghjladshfgjasdfälkjghasdöLfghasd-kjgjASDLÖGHAESKDLJGJÖsdaLGJHsadöKGjFDSLAkgjölSÄDghbASDFKGjasdLJGhjLÖSDGHLJASKDkgjldafjghjÖLADSFghäasdökgjäsadjlgkjsadkLHGsaDÖLGSADGÖLwSdlgkJLwDSFÄLHBJsaöfdkHweaFGIoeWjvlkdösmVJÄlsafdJKhvjdsJHFGLsdaövhWDsköLV-ksdFJHGVöSEKD

* fix imports (within apworld needs to be relative)

* Update worlds/witness/options.py

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Sure

* good suggestion

* subtest

* Add some EP shuffle unit tests, also an explicit event-checking unit test

* add more tests yay

* oops

* mypy

* Update worlds/witness/options.py

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

* Collapse into one test :(

* More efficiency

* line length

* More collapsing

* Cleanup and docstrings

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2024-07-06 13:40:55 +02:00
jamesbrq
bfac100567 MLSS: Fix for missing cutscene trigger 2024-07-05 22:54:35 +02:00
Scipio Wright
e7a8e195e6 TUNIC: Use fewer parameters in helper functions (#3356)
* Clean these functions up, get the hell out of here 5 parameter function

* Clean up a bunch of rules that no longer need to be multi-lined since the functions are shorter

* Clean up some range functions

* Update to use world instead of player like Vi recommended

* Fix merge conflict

* Fix after merge
2024-07-05 22:50:12 +02:00
Louis M
4054a9f15f Aquaria: Renaming some locations for consistency (#3533)
* Change 'The Body main area' by 'The Body center area' for consistency

* Renaming some locations for consistency

* Adding a line for standard

* Replacing Cathedral by Mithalas Cathedral and addin Blind goal option

* Client option renaming for consistency

* Fix death link not working

* Removing death link from the option to put it client side

* Changing Left to Right
2024-07-05 22:40:26 +02:00
Phaneros
ca76628813 sc2: Fixing typo in itemgroups.py causing spurious item groups with 2 letters chopped off (#3612) 2024-07-05 22:37:32 +02:00
Scipio Wright
d4d0a3e945 TUNIC: Make the shop checks require a sword 2024-07-05 22:36:55 +02:00
Scipio Wright
315e0c89e2 Docs: Lastest -> Latest (#3616) 2024-07-03 18:13:16 +02:00
Remy Jette
f6735745b6 Core: Fix !remaining (#3611) 2024-07-03 15:39:08 +02:00
Doug Hoskisson
50f7a79ea7 Zillion: new map generation feature (#3604) 2024-07-02 19:32:01 -07:00
NewSoupVi
95110c4787 The Witness: Fix door shuffle being completely broken 2024-07-03 00:34:17 +02:00
NewSoupVi
93617fa546 The Witness: mypy compliance (#3112)
* Make witness apworld mostly pass mypy

* Fix all remaining mypy errors except the core ones

* I'm a goofy stupid poopoo head

* Two more fixes

* ruff after merge

* Mypy for new stuff

* Oops

* Stricter ruff rules (that I already comply with :3)

* Deprecated ruff thing

* wait no i lied

* lol super nevermind

* I can actually be slightly more specific

* lint
2024-07-02 23:59:26 +02:00
black-sliver
b6925c593e WebHost: Log: handle FileNotFoundError (#3603) 2024-07-02 01:03:55 +02:00
Emily
401606e8e3 Docs: Clarify docs for create_items stage (#3600)
* Clarify docs re: `create_items` stage

* adjust wording after feedback

* adjust wording after more feedback
2024-07-01 23:34:06 +02:00
black-sliver
e95bb5ea56 WebHost: Better host room (#3496)
* add Range= to log, making responses a lot smaller for massive rooms
* switch xhr to fetch
* post the form using fetch if possible
  * also refresh log faster while waiting for command echo / response
  * do not follow redirect, saving a request
  * do not post empty body
* smooth-scroll the log view
* paste the log into the div when loading the HTML (up to 1MB, rest will be `fetch`ed)
* fix duplicate charset in display_log response
2024-07-01 21:47:49 +02:00
Silvris
52a13d38e9 Tests: fix error reporting in test_default_all_state_can_reach_everything (#3601) 2024-07-01 20:47:40 +02:00
Scipio Wright
31bd5e3ebc OOT: Add keys item_name_group (#3218)
* Add keys item_name_group

* Pep8ify

* Capitalizing Keys cause Bottles is capitalized, also putting it in the clearly marked hint groups area
2024-06-30 01:19:36 +02:00
Sunny Bat
192f1b3fae Update Raft option text, setup guide text (#3272)
* Update Raft option text, setup guide

* Address comments

* Address PR comments

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-06-30 01:18:09 +02:00
Silent
55cb81d487 TUNIC: Update victory condition (#3579)
* Add hero relics to victory condition

* Update __init__.py

* Remove unneeded local variables for options

* Use has_group_unique

* fix spacing
2024-06-30 01:17:00 +02:00
Justus Lind
2424fb0c5b Muse Dash: 6th Anniversary Song update (#3593)
* 6th Anniversary Update songs.

* Forgot to fix the name of Heartbeat.
2024-06-30 01:15:13 +02:00
Mrks
6191ff4b47 LADX: Fixed Display Names In Options Page (#3584)
* Fixed option group display names.

* Fixed display names for -at the moment- unused options.
2024-06-30 01:14:39 +02:00
Justus Lind
1c817e1eb7 Muse Dash: Update installation guides to recommend installing v0.6.1. (#3594)
* Update installation guides to recommend installing v0.6.1.

* Fix spanish spacing.

* Apply spanish changes.
2024-06-30 01:13:00 +02:00
Fabian Dill
d4c00ed267 CommonClient: fix /received with items from Server (#3597) 2024-06-29 03:00:32 +02:00
Ziktofel
e07a2667ae SC2 Tracker: Migrate icons away from sc2legacy (#3595) 2024-06-27 14:02:03 +02:00
Scipio Wright
b8f78af506 TUNIC: Fix minor logic bug in upper Zig (#3576)
* Add note about bushes to logic section of readme

* Fix missing logic on bridge switch chest in upper zig

* Revise upper zig rule change to account for ER
2024-06-27 14:01:35 +02:00
Scipio Wright
77304a8743 TUNIC: Update game info page with more tips (#3591)
* More minor updates to game info page

* Fix grammar
2024-06-27 13:00:20 +02:00
black-sliver
5882ce7380 Various worlds: Fix more absolute world imports (#3510)
* Adventure: remove absolute imports

* Alttp: remove absolute imports (all but tests)

* Aquaria: remove absolute imports in tests

running tests from apworld may fail (on 3.8 and maybe in the future) otherwise

* DKC3: remove absolute imports

* LADX: remove absolute imports

* Overcooked 2: remove absolute imports in tests

running tests from apworld may fail otherwise

* Rogue Legacy: remove absolute imports in tests

running tests from apworld may fail otherwise

* SC2: remove absolute imports

* SMW: remove absolute imports

* Subnautica: remove absolute imports in tests

running tests from apworld may fail otherwise

* Zillion: remove absolute imports in tests

running tests from apworld may fail otherwise
2024-06-27 08:51:27 +02:00
PinkSwitch
6c54b3596b Yoshi's Island: Fix client giving victory randomly (#3586)
* Create d

* Create d

* Delete worlds/mariomissing/d

* Delete mariomissing directory

* Create d

* Add files via upload

* Delete worlds/mariomissing/d

* Delete worlds/mariomissing directory

* Add files via upload

* Delete worlds/sai2 directory

* fix dumb client bug
2024-06-26 13:19:16 +02:00
Alchav
07dd8f0671 LTTP: Add Missing Blind's Cell rule (#3589) 2024-06-25 20:15:51 +02:00
Remy Jette
935c94dc80 Installer: Fix .apworld registration (#3588) 2024-06-25 20:15:12 +02:00
Fabian Dill
1ab1aeff15 Core: update required_server_version to 0.5.0 (#3580) 2024-06-23 07:50:00 +02:00
Silvris
5ca31533dc Tests: give seed on default tests and fix execnet error (#3520)
* output seed of default tests

* test execnet fix

* try failing with interpolated string

* Update bases.py

* try without tryexcept

* Update bases.py

* Update bases.py

* remove fake exception

* fix indent

* actually fix the execnet issue

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-06-22 21:00:15 +02:00
Mrks
60a26920e1 LADX: Probably fix generation error that palex had 2024-06-22 19:32:10 +02:00
StripesOO7
d00abe7b8e OOT: Adds Options to slot_data for poptracker-pack (#3570)
* Add imo all needed options to fill_slot_data that are worth tracking in the poptracker pack. This is aimed at providing information for the oot poptracker-pack for autofilling of settings within this pack.

* cap line length at 120 and reorganize list

---------

Co-authored-by: StripesOO7 <54711792+StripeesOO7@users.noreply.github.com>
2024-06-22 13:50:20 +02:00
Mewlif
40c9dfd3bf Undertale: Fixes a major logic bug, and updates Undertale to use the new Options API (#3528)
* Updated the options definitions to the new api

* Fixed the wrong base class being used for UndertaleOptions

* Undertale: Added get_filler_item_name to Undertale, changed multiworld.per_slot_randoms to self.random, removed some unused imports in options.py, and fixed rules.py still using state.multiworld instead of world.options, and simplified the set_completion_rules function in rules.py

* Undertale: Fixed it trying to add strings to the finished item pool

* fixed 1000g item not being in the key items pool for Undertale

* Removed ".copy()" for the junk_weights, reformatted the requested lines to have less new lines, and changed "itempool += [self.create_filler()]" to "itempool.append(self.create_filler())"
2024-06-21 18:21:46 +02:00
Fabian Dill
ce37bed7c6 WebHost: fix accidental robots.txt capture (#3502) 2024-06-21 14:54:19 +02:00
Justus Lind
4f514e5944 Muse Dash: Song name change (#3572)
* Change the song name of the removed song to the one replacing it.

* Make it not part of Streamable songs for now.
2024-06-20 13:54:38 +02:00
Aaron Wagener
f515a085db The Messenger: Fix missing rules for Double Swing Saws (#3562)
* The Messenger: Fix missing rules for Double Swing Saws

* i put it in the wrong dictionary

* remove unnecessary call
2024-06-19 16:20:47 +02:00
eudaimonistic
903a0bab1a Docs: Change setup_en.md to use Latest releases page (#3543)
* Change setup_en.md to use Latest releases page

Really simple change to point users to the Latest release page instead of the Releases page.  Saw a user accidentally download 0.3.6 because it was the last item on the page (they're accustomed to scrolling down to the bottom of the page in GitHub for the Assets section), and this change prevents that outright.

* Update setup_en.md

Rewrite text and link to restore semantic compatibility.
2024-06-19 16:12:25 +02:00
Kaito Sinclaire
9bb3947d7e Doom 2, Heretic: fix missing items (Doom2 Megasphere, Heretic Torch) (#3561)
for doom 2, some of the armor and health weights were nudged down
to compensate for the addition of the megasphere

for heretic, the torch was just added without changing anything else,
as I felt doing so would negatively impact the distribution of
artifacts (and personally I already feel there's too few in a game)
2024-06-19 12:59:10 +02:00
Mrks
240d1a3bbf LADX: Adding 'Option Groups' to the player options page. (#3560)
* Adding 'Option Groups' to the LADX player options page.

* Moved 'Miscellaneous' group to the logic effecting groups.
2024-06-19 08:40:10 +02:00
Kory Dondzila
b6191ff7ca Shivers: Adds missing indirect conditions. (#3558)
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-06-18 05:10:54 +02:00
Scipio Wright
19d00547c2 TUNIC: Add note about bushes to logic section of game info page (#3555)
* Add note about bushes to logic section of readme

* Update worlds/tunic/docs/en_TUNIC.md

Co-authored-by: Silent <110704408+silent-destroyer@users.noreply.github.com>

---------

Co-authored-by: Silent <110704408+silent-destroyer@users.noreply.github.com>
2024-06-18 04:51:54 +02:00
chesslogic
67a0a04917 Tests: minor: update tests base for Options API (#2516)
* update tests for Options API

* The actual "bug"

* resolve qwint's comment from 3 months ago
2024-06-18 04:49:26 +02:00
Star Rauchenberger
af213c9e5d LADX: Converted to new options API (+other small refactors) (#3542)
* Refactored various things

* Renamed hidden variable in dungeon item shuffle block

* Fixed LADXRSettings initialization

* Rename ladxr_options -> ladxr_settings

* Remove unnecessary int cast

* Update worlds/ladx/LADXR/generator.py

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-06-18 04:48:15 +02:00
Zach Parks
898509e7ee CODEOWNERS: Remove @zig-for as world maintainer for LADX. (#3525)
Per request: https://discord.com/channels/731205301247803413/1214608557077700720/1250714693136547920
2024-06-16 05:38:08 -05:00
Zach Parks
1f685b4272 CommonClient: Use lookup_in_game instead of lookup_in_slot in case of own-game name lookup when disconnected from server. (#3514) 2024-06-16 05:37:05 -05:00
Scipio Wright
c622240730 Tunc: Update plando connections description (#3545) 2024-06-16 05:02:48 +02:00
Mrks
1d314374d7 LADX: Moved ROM requirement from generate_output to stage_assert_generate. (#3540)
Co-authored-by: Mrks <markus.burmeister@mburm.de>
2024-06-16 04:31:32 +02:00
palex00
753eb8683f Pokemon Red/Blue: Replaces link to R&B Poptracker with a new one (#3516)
* Update setup_en.md

* Update setup_es.md
2024-06-16 04:10:50 +02:00
Fabian Dill
e8542b8acd Generate: split ERmain out of main (#3515) 2024-06-16 03:27:06 +02:00
NewSoupVi
2a11d610b6 The Witness: Fix Shuffle Postgame always thinking it's Challenge Victory (#3504)
* Fix postgame thinking it's the wrong panel

* Also don't have a default value for it so it doesn't happen again
2024-06-16 01:56:20 +02:00
coveleski
92023a2cb5 Pokemon RB: Add new options to slot_data (#3538)
Added require_pokedex, blind_trainers, and area_1_to_1 mapping, which would be helpful to the poptracker packs to accurately reflect the checks available to players.
2024-06-16 01:55:52 +02:00
Fabian Dill
df94271d30 LttP: fix single-player no-logic generation (#3454) 2024-06-15 19:18:26 +02:00
Bryce Wilson
0354315c22 Pokemon Emerald: Remove README (#3532) 2024-06-15 04:52:01 +02:00
Star Rauchenberger
e796f0ae64 Core: Expose option aliases (#3512) 2024-06-15 04:50:26 +02:00
Natalie Weizenbaum
c61505baf6 WebHost/Core/Lingo: Render option documentation as reStructuredText in the WebView (#3511)
* Render option documentation as reStructuredText in the WebView

This means that options can use the standard Python documentation
format, while producing much nicer-looking documentation in the
WebView with things like emphasis, lists, and so on.

* Opt existing worlds out of rich option docs

This avoids breaking the rendering of existing option docs which were
written with the old plain text rendering in mind, while also allowing
new options to default to the rich text rendering instead.

* Use reStructuredText formatting for Lingo Options docstrings

* Disable raw and file insertion RST directives

* Update doc comments per code review

* Make rich text docs opt-in

* Put rich_text_options_doc on WebWorld

* Document rich text API

* Code review

* Update docs/options api.md

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>

* Update Options.py

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>

---------

Co-authored-by: Chris Wilson <chris@legendserver.info>
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2024-06-14 18:53:42 -04:00
Fabian Dill
3972b1b257 Options: fix yaml export corner case (#3529) 2024-06-15 00:48:49 +02:00
black-sliver
1fe3d842c8 CI: Install specific inno version (#3526)
* CI: Install specific inno version

* great mobile dev experience

* maybe this

* really don't enjoy PS

* Anothet attempt

* maybe fix log

* slowly going mad

* fml

* allow downgrade
2024-06-14 08:47:47 +02:00
Fabian Dill
e9ad7cb797 WebHost: fix option doc indent (#3513)
* WebHost: fix option doc indent

* Update macros.html
2024-06-13 17:37:52 -04:00
NewSoupVi
533395d336 WebHost: Fix Named Range displays on Player Options page (#3521)
* Player Options: Fix Named Range displays

* Also add validation to the NamedRange class itself

* Don't break Stardew

* Comment

* Do replace first so title works correctly

* Bring change to Weighted Options as well
2024-06-13 17:29:39 -04:00
NewSoupVi
2ae51364d9 WebHost: Fix default values that are 2 or more words in Weighted Options (#3519)
* WeightedOptions: Fix default values that are 2 or more words

* So much simpler
2024-06-13 12:24:56 -04:00
NewSoupVi
f6e3113af6 WebHost: Fix "Add" button for custom option values causing a weird redirect (#3518)
* WebHost: Fix "Add" button for Progression Balancing causing a weird redirect

This "add" button is part of a form, which causes it to submit the form, because the default type for a button is "submit".

This PR changes the type of the button to "button", which causes it to not submit the form and just execute its normal effect.

(An alternative would be `event.preventDefault()` but that seems less clean to me, but also I'm not a HTML/JS dev)

* There's also multiple.
2024-06-13 04:39:16 -04:00
JoshuaEagles
da34800f43 Fix Incorrect Link Syntax in SA2B Linux Setup (#3524) 2024-06-13 06:53:01 +02:00
421 changed files with 15675 additions and 13100 deletions

View File

@@ -36,6 +36,7 @@ jobs:
run: | run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/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 Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.2.2 --allow-downgrade
- name: Build - name: Build
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip

2
.gitignore vendored
View File

@@ -150,7 +150,7 @@ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
.code-workspace *.code-workspace
shell.nix shell.nix
# Spyder project settings # Spyder project settings

View File

@@ -112,7 +112,7 @@ class AdventureContext(CommonContext):
if ': !' not in msg: if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID) self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems": elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names.lookup_in_slot(item.item) for item in args['items']])}" msg = f"Received {', '.join([self.item_names.lookup_in_game(item.item) for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID) self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "Retrieved": elif cmd == "Retrieved":
if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]: if f"adventure_{self.auth}_freeincarnates_used" in args["keys"]:

View File

@@ -23,7 +23,7 @@ if __name__ == "__main__":
from MultiServer import CommandProcessor 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, add_json_text, add_json_location, add_json_item, JSONTypes) RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, SlotType)
from Utils import Version, stream_input, async_start from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister from worlds import network_data_package, AutoWorldRegister
import os import os
@@ -61,6 +61,7 @@ class ClientCommandProcessor(CommandProcessor):
if address: if address:
self.ctx.server_address = None self.ctx.server_address = None
self.ctx.username = None self.ctx.username = None
self.ctx.password = None
elif not self.ctx.server_address: elif not self.ctx.server_address:
self.output("Please specify an address.") self.output("Please specify an address.")
return False return False
@@ -225,6 +226,9 @@ class CommonContext:
def lookup_in_slot(self, code: int, slot: typing.Optional[int] = None) -> str: def lookup_in_slot(self, code: int, slot: typing.Optional[int] = None) -> str:
"""Returns the name for an item/location id in the context of a specific slot or own slot if `slot` is """Returns the name for an item/location id in the context of a specific slot or own slot if `slot` is
omitted. omitted.
Use of `lookup_in_slot` should not be used when not connected to a server. If looking in own game, set
`ctx.game` and use `lookup_in_game` method instead.
""" """
if slot is None: if slot is None:
slot = self.ctx.slot slot = self.ctx.slot
@@ -511,6 +515,7 @@ class CommonContext:
async def shutdown(self): async def shutdown(self):
self.server_address = "" self.server_address = ""
self.username = None self.username = None
self.password = None
self.cancel_autoreconnect() self.cancel_autoreconnect()
if self.server and not self.server.socket.closed: if self.server and not self.server.socket.closed:
await self.server.socket.close() await self.server.socket.close()
@@ -859,7 +864,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.team = args["team"] ctx.team = args["team"]
ctx.slot = args["slot"] ctx.slot = args["slot"]
# int keys get lost in JSON transfer # int keys get lost in JSON transfer
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()} ctx.slot_info = {0: NetworkSlot("Archipelago", "Archipelago", SlotType.player)}
ctx.slot_info.update({int(pid): data for pid, data in args["slot_info"].items()})
ctx.hint_points = args.get("hint_points", 0) ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"]) ctx.consume_players_package(args["players"])
ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}") ctx.stored_data_notification_keys.add(f"_read_hints_{ctx.team}_{ctx.slot}")

View File

@@ -65,7 +65,7 @@ def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits) return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None): def main(args=None) -> Tuple[argparse.Namespace, int]:
# __name__ == "__main__" check so unittests that already imported worlds don't trip this. # __name__ == "__main__" check so unittests that already imported worlds don't trip this.
if __name__ == "__main__" and "worlds" in sys.modules: if __name__ == "__main__" and "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded before logging init.") raise Exception("Worlds system should not be loaded before logging init.")
@@ -237,8 +237,7 @@ def main(args=None):
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f: with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
yaml.dump(important, f) yaml.dump(important, f)
from Main import main as ERmain return erargs, seed
return ERmain(erargs, seed)
def read_weights_yamls(path) -> Tuple[Any, ...]: def read_weights_yamls(path) -> Tuple[Any, ...]:
@@ -547,7 +546,9 @@ def roll_alttp_settings(ret: argparse.Namespace, weights):
if __name__ == '__main__': if __name__ == '__main__':
import atexit import atexit
confirmation = atexit.register(input, "Press enter to close.") confirmation = atexit.register(input, "Press enter to close.")
multiworld = main() erargs, seed = main()
from Main import main as ERmain
multiworld = ERmain(erargs, seed)
if __debug__: if __debug__:
import gc import gc
import sys import sys

13
Main.py
View File

@@ -124,14 +124,19 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in multiworld.player_ids: for player in multiworld.player_ids:
exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value) exclusion_rules(multiworld, player, multiworld.worlds[player].options.exclude_locations.value)
multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value multiworld.worlds[player].options.priority_locations.value -= multiworld.worlds[player].options.exclude_locations.value
world_excluded_locations = set()
for location_name in multiworld.worlds[player].options.priority_locations.value: for location_name in multiworld.worlds[player].options.priority_locations.value:
try: try:
location = multiworld.get_location(location_name, player) location = multiworld.get_location(location_name, player)
except KeyError as e: # failed to find the given location. Check if it's a legitimate location except KeyError:
if location_name not in multiworld.worlds[player].location_name_to_id: continue
raise Exception(f"Unable to prioritize location {location_name} in player {player}'s world.") from e
else: if location.progress_type != LocationProgressType.EXCLUDED:
location.progress_type = LocationProgressType.PRIORITY location.progress_type = LocationProgressType.PRIORITY
else:
logger.warning(f"Unable to prioritize location \"{location_name}\" in player {player}'s world because the world excluded it.")
world_excluded_locations.add(location_name)
multiworld.worlds[player].options.priority_locations.value -= world_excluded_locations
# Set local and non-local item rules. # Set local and non-local item rules.
if multiworld.players > 1: if multiworld.players > 1:

View File

@@ -1352,7 +1352,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.remaining_mode == "enabled": if self.ctx.remaining_mode == "enabled":
remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id] self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
for item_id in remaining_item_ids)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")
@@ -1365,7 +1365,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL: 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) remaining_item_ids = get_remaining(self.ctx, self.client.team, self.client.slot)
if remaining_item_ids: if remaining_item_ids:
self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.client.slot.game][item_id] self.output("Remaining items: " + ", ".join(self.ctx.item_names[self.ctx.games[self.client.slot]][item_id]
for item_id in remaining_item_ids)) for item_id in remaining_item_ids))
else: else:
self.output("No remaining items found.") self.output("No remaining items found.")

View File

@@ -53,8 +53,8 @@ class AssembleOptions(abc.ABCMeta):
attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()}) attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()})
options.update(new_options) options.update(new_options)
# apply aliases, without name_lookup # apply aliases, without name_lookup
aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if aliases = attrs["aliases"] = {name[6:].lower(): option_id for name, option_id in attrs.items() if
name.startswith("alias_")} name.startswith("alias_")}
assert ( assert (
name in {"Option", "VerifyKeys"} or # base abstract classes don't need default name in {"Option", "VerifyKeys"} or # base abstract classes don't need default
@@ -126,10 +126,28 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
# can be weighted between selections # can be weighted between selections
supports_weighting = True supports_weighting = True
rich_text_doc: typing.Optional[bool] = None
"""Whether the WebHost should render the Option's docstring as rich text.
If this is True, the Option's docstring is interpreted as reStructuredText_,
the standard Python markup format. In the WebHost, it's rendered to HTML so
that lists, emphasis, and other rich text features are displayed properly.
If this is False, the docstring is instead interpreted as plain text, and
displayed as-is on the WebHost with whitespace preserved.
If this is None, it inherits the value of `World.rich_text_options_doc`. For
backwards compatibility, this defaults to False, but worlds are encouraged to
set it to True and use reStructuredText for their Option documentation.
.. _reStructuredText: https://docutils.sourceforge.io/rst.html
"""
# filled by AssembleOptions: # filled by AssembleOptions:
name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore
# https://github.com/python/typing/discussions/1460 the reason for this type: ignore # https://github.com/python/typing/discussions/1460 the reason for this type: ignore
options: typing.ClassVar[typing.Dict[str, int]] options: typing.ClassVar[typing.Dict[str, int]]
aliases: typing.ClassVar[typing.Dict[str, int]]
def __repr__(self) -> str: def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.current_option_name})" return f"{self.__class__.__name__}({self.current_option_name})"
@@ -735,6 +753,12 @@ class NamedRange(Range):
elif value > self.range_end and value not in self.special_range_names.values(): elif value > self.range_end and value not in self.special_range_names.values():
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " + raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}") f"and is also not one of the supported named special values: {self.special_range_names}")
# See docstring
for key in self.special_range_names:
if key != key.lower():
raise Exception(f"{self.__class__.__name__} has an invalid special_range_names key: {key}. "
f"NamedRange keys must use only lowercase letters, and ideally should be snake_case.")
self.value = value self.value = value
@classmethod @classmethod
@@ -1121,10 +1145,13 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
class Accessibility(Choice): class Accessibility(Choice):
"""Set rules for reachability of your items/locations. """Set rules for reachability of your items/locations.
Locations: ensure everything can be reached and acquired.
Items: ensure all logically relevant items can be acquired. - **Locations:** ensure everything can be reached and acquired.
Minimal: ensure what is needed to reach your goal can be acquired.""" - **Items:** ensure all logically relevant items can be acquired.
- **Minimal:** ensure what is needed to reach your goal can be acquired.
"""
display_name = "Accessibility" display_name = "Accessibility"
rich_text_doc = True
option_locations = 0 option_locations = 0
option_items = 1 option_items = 1
option_minimal = 2 option_minimal = 2
@@ -1133,14 +1160,15 @@ class Accessibility(Choice):
class ProgressionBalancing(NamedRange): class ProgressionBalancing(NamedRange):
""" """A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
A system that can move progression earlier, to try and prevent the player from getting stuck and bored early.
A lower setting means more getting stuck. A higher setting means less getting stuck. A lower setting means more getting stuck. A higher setting means less getting stuck.
""" """
default = 50 default = 50
range_start = 0 range_start = 0
range_end = 99 range_end = 99
display_name = "Progression Balancing" display_name = "Progression Balancing"
rich_text_doc = True
special_range_names = { special_range_names = {
"disabled": 0, "disabled": 0,
"normal": 50, "normal": 50,
@@ -1205,29 +1233,36 @@ class CommonOptions(metaclass=OptionsMetaProperty):
class LocalItems(ItemSet): class LocalItems(ItemSet):
"""Forces these items to be in their native world.""" """Forces these items to be in their native world."""
display_name = "Local Items" display_name = "Local Items"
rich_text_doc = True
class NonLocalItems(ItemSet): class NonLocalItems(ItemSet):
"""Forces these items to be outside their native world.""" """Forces these items to be outside their native world."""
display_name = "Non-local Items" display_name = "Non-local Items"
rich_text_doc = True
class StartInventory(ItemDict): class StartInventory(ItemDict):
"""Start with these items.""" """Start with these items."""
verify_item_name = True verify_item_name = True
display_name = "Start Inventory" display_name = "Start Inventory"
rich_text_doc = True
class StartInventoryPool(StartInventory): class StartInventoryPool(StartInventory):
"""Start with these items and don't place them in the world. """Start with these items and don't place them in the world.
The game decides what the replacement items will be."""
The game decides what the replacement items will be.
"""
verify_item_name = True verify_item_name = True
display_name = "Start Inventory from Pool" display_name = "Start Inventory from Pool"
rich_text_doc = True
class StartHints(ItemSet): class StartHints(ItemSet):
"""Start with these item's locations prefilled into the !hint command.""" """Start with these item's locations prefilled into the ``!hint`` command."""
display_name = "Start Hints" display_name = "Start Hints"
rich_text_doc = True
class LocationSet(OptionSet): class LocationSet(OptionSet):
@@ -1236,28 +1271,33 @@ class LocationSet(OptionSet):
class StartLocationHints(LocationSet): class StartLocationHints(LocationSet):
"""Start with these locations and their item prefilled into the !hint command""" """Start with these locations and their item prefilled into the ``!hint`` command."""
display_name = "Start Location Hints" display_name = "Start Location Hints"
rich_text_doc = True
class ExcludeLocations(LocationSet): class ExcludeLocations(LocationSet):
"""Prevent these locations from having an important item""" """Prevent these locations from having an important item."""
display_name = "Excluded Locations" display_name = "Excluded Locations"
rich_text_doc = True
class PriorityLocations(LocationSet): class PriorityLocations(LocationSet):
"""Prevent these locations from having an unimportant item""" """Prevent these locations from having an unimportant item."""
display_name = "Priority Locations" display_name = "Priority Locations"
rich_text_doc = True
class DeathLink(Toggle): class DeathLink(Toggle):
"""When you die, everyone dies. Of course the reverse is true too.""" """When you die, everyone dies. Of course the reverse is true too."""
display_name = "Death Link" display_name = "Death Link"
rich_text_doc = True
class ItemLinks(OptionList): class ItemLinks(OptionList):
"""Share part of your item pool with other players.""" """Share part of your item pool with other players."""
display_name = "Item Links" display_name = "Item Links"
rich_text_doc = True
default = [] default = []
schema = Schema([ schema = Schema([
{ {
@@ -1324,6 +1364,7 @@ class ItemLinks(OptionList):
class Removed(FreeText): class Removed(FreeText):
"""This Option has been Removed.""" """This Option has been Removed."""
rich_text_doc = True
default = "" default = ""
visibility = Visibility.none visibility = Visibility.none
@@ -1426,14 +1467,18 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
return data, notes return data, notes
def yaml_dump_scalar(scalar) -> str:
# yaml dump may add end of document marker and newlines.
return yaml.dump(scalar).replace("...\n", "").strip()
for game_name, world in AutoWorldRegister.world_types.items(): for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden: if not world.hidden or generate_hidden:
grouped_options = get_option_groups(world) option_groups = get_option_groups(world)
with open(local_path("data", "options.yaml")) as f: with open(local_path("data", "options.yaml")) as f:
file_data = f.read() file_data = f.read()
res = Template(file_data).render( res = Template(file_data).render(
option_groups=grouped_options, option_groups=option_groups,
__version__=__version__, game=game_name, yaml_dump=yaml.dump, __version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range, dictify_range=dictify_range,
) )

View File

@@ -29,7 +29,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
def _cmd_patch(self): def _cmd_patch(self):
"""Patch the game. Only use this command if /auto_patch fails.""" """Patch the game. Only use this command if /auto_patch fails."""
if isinstance(self.ctx, UndertaleContext): if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
self.ctx.patch_game() self.ctx.patch_game()
self.output("Patched.") self.output("Patched.")
@@ -43,7 +43,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None):
"""Patch the game automatically.""" """Patch the game automatically."""
if isinstance(self.ctx, UndertaleContext): if isinstance(self.ctx, UndertaleContext):
os.makedirs(name=os.path.join(os.getcwd(), "Undertale"), exist_ok=True) os.makedirs(name=Utils.user_path("Undertale"), exist_ok=True)
tempInstall = steaminstall tempInstall = steaminstall
if not os.path.isfile(os.path.join(tempInstall, "data.win")): if not os.path.isfile(os.path.join(tempInstall, "data.win")):
tempInstall = None tempInstall = None
@@ -62,7 +62,7 @@ class UndertaleCommandProcessor(ClientCommandProcessor):
for file_name in os.listdir(tempInstall): for file_name in os.listdir(tempInstall):
if file_name != "steam_api.dll": if file_name != "steam_api.dll":
shutil.copy(os.path.join(tempInstall, file_name), shutil.copy(os.path.join(tempInstall, file_name),
os.path.join(os.getcwd(), "Undertale", file_name)) Utils.user_path("Undertale", file_name))
self.ctx.patch_game() self.ctx.patch_game()
self.output("Patching successful!") self.output("Patching successful!")
@@ -111,12 +111,12 @@ class UndertaleContext(CommonContext):
self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE")
def patch_game(self): def patch_game(self):
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "rb") as f: with open(Utils.user_path("Undertale", "data.win"), "rb") as f:
patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff")) patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff"))
with open(os.path.join(os.getcwd(), "Undertale", "data.win"), "wb") as f: with open(Utils.user_path("Undertale", "data.win"), "wb") as f:
f.write(patchedFile) f.write(patchedFile)
os.makedirs(name=os.path.join(os.getcwd(), "Undertale", "Custom Sprites"), exist_ok=True) os.makedirs(name=Utils.user_path("Undertale", "Custom Sprites"), exist_ok=True)
with open(os.path.expandvars(os.path.join(os.getcwd(), "Undertale", "Custom Sprites", with open(os.path.expandvars(Utils.user_path("Undertale", "Custom Sprites",
"Which Character.txt")), "w") as f: "Which Character.txt")), "w") as f:
f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only " f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only "
"line other than this one.\n", "frisk"]) "line other than this one.\n", "frisk"])
@@ -247,8 +247,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict):
with open(os.path.join(ctx.save_game_folder, filename), "w") as f: with open(os.path.join(ctx.save_game_folder, filename), "w") as f:
toDraw = "" toDraw = ""
for i in range(20): for i in range(20):
if i < len(str(ctx.item_names.lookup_in_slot(l.item))): if i < len(str(ctx.item_names.lookup_in_game(l.item))):
toDraw += str(ctx.item_names.lookup_in_slot(l.item))[i] toDraw += str(ctx.item_names.lookup_in_game(l.item))[i]
else: else:
break break
f.write(toDraw) f.write(toDraw)

View File

@@ -176,7 +176,7 @@ class WargrooveContext(CommonContext):
if not os.path.isfile(path): if not os.path.isfile(path):
open(path, 'w').close() open(path, 'w').close()
# Announcing commander unlocks # Announcing commander unlocks
item_name = self.item_names.lookup_in_slot(network_item.item) item_name = self.item_names.lookup_in_game(network_item.item)
if item_name in faction_table.keys(): if item_name in faction_table.keys():
for commander in faction_table[item_name]: for commander in faction_table[item_name]:
logger.info(f"{commander.name} has been unlocked!") logger.info(f"{commander.name} has been unlocked!")
@@ -197,7 +197,7 @@ class WargrooveContext(CommonContext):
open(print_path, 'w').close() open(print_path, 'w').close()
with open(print_path, 'w') as f: with open(print_path, 'w') as f:
f.write("Received " + f.write("Received " +
self.item_names.lookup_in_slot(network_item.item) + self.item_names.lookup_in_game(network_item.item) +
" from " + " from " +
self.player_names[network_item.player]) self.player_names[network_item.player])
f.close() f.close()
@@ -342,7 +342,7 @@ class WargrooveContext(CommonContext):
faction_items = 0 faction_items = 0
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()] faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
for network_item in self.items_received: for network_item in self.items_received:
if self.item_names.lookup_in_slot(network_item.item) in faction_item_names: if self.item_names.lookup_in_game(network_item.item) in faction_item_names:
faction_items += 1 faction_items += 1
starting_groove = (faction_items - 1) * self.starting_groove_multiplier starting_groove = (faction_items - 1) * self.starting_groove_multiplier
# Must be an integer larger than 0 # Must be an integer larger than 0

View File

@@ -325,10 +325,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
def run(self): def run(self):
while 1: while 1:
next_room = rooms_to_run.get(block=True, timeout=None) next_room = rooms_to_run.get(block=True, timeout=None)
gc.collect(0)
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop) task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
self._tasks.append(task) self._tasks.append(task)
task.add_done_callback(self._done) task.add_done_callback(self._done)
logging.info(f"Starting room {next_room} on {name}.") logging.info(f"Starting room {next_room} on {name}.")
del task # delete reference to task object
starter = Starter() starter = Starter()
starter.daemon = True starter.daemon = True

View File

@@ -1,6 +1,6 @@
import datetime import datetime
import os import os
from typing import List, Dict, Union from typing import Any, IO, Dict, Iterator, List, Tuple, Union
import jinja2.exceptions import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
@@ -97,25 +97,37 @@ def new_room(seed: UUID):
return redirect(url_for("host_room", room=room.id)) return redirect(url_for("host_room", room=room.id))
def _read_log(path: str): def _read_log(log: IO[Any], offset: int = 0) -> Iterator[bytes]:
if os.path.exists(path): marker = log.read(3) # skip optional BOM
with open(path, encoding="utf-8-sig") as log: if marker != b'\xEF\xBB\xBF':
yield from log log.seek(0, os.SEEK_SET)
else: log.seek(offset, os.SEEK_CUR)
yield f"Logfile {path} does not exist. " \ yield from log
f"Likely a crash during spinup of multiworld instance or it is still spinning up." log.close() # free file handle as soon as possible
@app.route('/log/<suuid:room>') @app.route('/log/<suuid:room>')
def display_log(room: UUID): def display_log(room: UUID) -> Union[str, Response, Tuple[str, int]]:
room = Room.get(id=room) room = Room.get(id=room)
if room is None: if room is None:
return abort(404) return abort(404)
if room.owner == session["_id"]: if room.owner == session["_id"]:
file_path = os.path.join("logs", str(room.id) + ".txt") file_path = os.path.join("logs", str(room.id) + ".txt")
if os.path.exists(file_path): try:
return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8") log = open(file_path, "rb")
return "Log File does not exist." range_header = request.headers.get("Range")
if range_header:
range_type, range_values = range_header.split('=')
start, end = map(str.strip, range_values.split('-', 1))
if range_type != "bytes" or end != "":
return "Unsupported range", 500
# NOTE: we skip Content-Range in the response here, which isn't great but works for our JS
return Response(_read_log(log, int(start)), mimetype="text/plain", status=206)
return Response(_read_log(log), mimetype="text/plain")
except FileNotFoundError:
return Response(f"Logfile {file_path} does not exist. "
f"Likely a crash during spinup of multiworld instance or it is still spinning up.",
mimetype="text/plain")
return "Access Denied", 403 return "Access Denied", 403
@@ -139,7 +151,22 @@ def host_room(room: UUID):
with db_session: with db_session:
room.last_activity = now # will trigger a spinup, if it's not already running 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) def get_log(max_size: int = 1024000) -> str:
try:
with open(os.path.join("logs", str(room.id) + ".txt"), "rb") as log:
raw_size = 0
fragments: List[str] = []
for block in _read_log(log):
if raw_size + len(block) > max_size:
fragments.append("")
break
raw_size += len(block)
fragments.append(block.decode("utf-8"))
return "".join(fragments)
except FileNotFoundError:
return ""
return render_template("hostRoom.html", room=room, should_refresh=should_refresh, get_log=get_log)
@app.route('/favicon.ico') @app.route('/favicon.ico')

View File

@@ -3,6 +3,7 @@ import json
import os import os
from textwrap import dedent from textwrap import dedent
from typing import Dict, Union from typing import Dict, Union
from docutils.core import publish_parts
import yaml import yaml
from flask import redirect, render_template, request, Response from flask import redirect, render_template, request, Response
@@ -66,6 +67,22 @@ def filter_dedent(text: str) -> str:
return dedent(text).strip("\n ") return dedent(text).strip("\n ")
@app.template_filter("rst_to_html")
def filter_rst_to_html(text: str) -> str:
"""Converts reStructuredText (such as a Python docstring) to HTML."""
if text.startswith(" ") or text.startswith("\t"):
text = dedent(text)
elif "\n" in text:
lines = text.splitlines()
text = lines[0] + "\n" + dedent("\n".join(lines[1:]))
return publish_parts(text, writer_name='html', settings=None, settings_overrides={
'raw_enable': False,
'file_insertion_enabled': False,
'output_encoding': 'unicode'
})['body']
@app.template_test("ordered") @app.template_test("ordered")
def test_ordered(obj): def test_ordered(obj):
return isinstance(obj, collections.abc.Sequence) return isinstance(obj, collections.abc.Sequence)

View File

@@ -8,7 +8,8 @@ from . import cache
def robots(): def robots():
# If this host is not official, do not allow search engine crawling # If this host is not official, do not allow search engine crawling
if not app.config["ASSET_RIGHTS"]: if not app.config["ASSET_RIGHTS"]:
return app.send_static_file('robots.txt') # filename changed in case the path is intercepted and served by an outside service
return app.send_static_file('robots_file.txt')
# Send 404 if the host has affirmed this to be the official WebHost # Send 404 if the host has affirmed this to be the official WebHost
abort(404) abort(404)

View File

@@ -12,12 +12,12 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
*/ */
/* Base styles for the element that has a tooltip */ /* Base styles for the element that has a tooltip */
[data-tooltip], .tooltip { [data-tooltip], .tooltip-container {
position: relative; position: relative;
} }
/* Base styles for the entire tooltip */ /* Base styles for the entire tooltip */
[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after { [data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
position: absolute; position: absolute;
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
@@ -39,14 +39,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
pointer-events: none; pointer-events: none;
} }
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after{ [data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before,
.tooltip-container:hover .tooltip {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
word-break: break-word; word-break: break-word;
} }
/** Directional arrow styles */ /** Directional arrow styles */
.tooltip:before, [data-tooltip]:before { [data-tooltip]:before, .tooltip-container:before {
z-index: 10000; z-index: 10000;
border: 6px solid transparent; border: 6px solid transparent;
background: transparent; background: transparent;
@@ -54,7 +55,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
} }
/** Content styles */ /** Content styles */
.tooltip:after, [data-tooltip]:after { [data-tooltip]:after, .tooltip {
width: 260px; width: 260px;
z-index: 10000; z-index: 10000;
padding: 8px; padding: 8px;
@@ -63,24 +64,26 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
background-color: hsla(0, 0%, 20%, 0.9); background-color: hsla(0, 0%, 20%, 0.9);
color: #fff; color: #fff;
content: attr(data-tooltip); content: attr(data-tooltip);
white-space: pre-wrap;
font-size: 14px; font-size: 14px;
line-height: 1.2; line-height: 1.2;
} }
[data-tooltip]:before, [data-tooltip]:after{ [data-tooltip]:after {
white-space: pre-wrap;
}
[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
visibility: hidden; visibility: hidden;
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after, [data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip {
.tooltip-top:before, .tooltip-top:after {
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
} }
[data-tooltip]:before, .tooltip:before, .tooltip-top:before { [data-tooltip]:before, .tooltip-container:before {
margin-left: -6px; margin-left: -6px;
margin-bottom: -12px; margin-bottom: -12px;
border-top-color: #000; border-top-color: #000;
@@ -88,19 +91,19 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
} }
/** Horizontally align tooltips on the top and bottom */ /** Horizontally align tooltips on the top and bottom */
[data-tooltip]:after, .tooltip:after, .tooltip-top:after { [data-tooltip]:after, .tooltip {
margin-left: -80px; margin-left: -80px;
} }
[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after, [data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before,
.tooltip-top:hover:before, .tooltip-top:hover:after { .tooltip-container:hover .tooltip {
-webkit-transform: translateY(-12px); -webkit-transform: translateY(-12px);
-moz-transform: translateY(-12px); -moz-transform: translateY(-12px);
transform: translateY(-12px); transform: translateY(-12px);
} }
/** Tooltips on the left */ /** Tooltips on the left */
.tooltip-left:before, .tooltip-left:after { .tooltip-left:before, [data-tooltip].tooltip-left:after, .tooltip-left .tooltip {
right: 100%; right: 100%;
bottom: 50%; bottom: 50%;
left: auto; left: auto;
@@ -115,14 +118,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
border-left-color: hsla(0, 0%, 20%, 0.9); border-left-color: hsla(0, 0%, 20%, 0.9);
} }
.tooltip-left:hover:before, .tooltip-left:hover:after { .tooltip-left:hover:before, [data-tooltip].tooltip-left:hover:after, .tooltip-left:hover .tooltip {
-webkit-transform: translateX(-12px); -webkit-transform: translateX(-12px);
-moz-transform: translateX(-12px); -moz-transform: translateX(-12px);
transform: translateX(-12px); transform: translateX(-12px);
} }
/** Tooltips on the bottom */ /** Tooltips on the bottom */
.tooltip-bottom:before, .tooltip-bottom:after { .tooltip-bottom:before, [data-tooltip].tooltip-bottom:after, .tooltip-bottom .tooltip {
top: 100%; top: 100%;
bottom: auto; bottom: auto;
left: 50%; left: 50%;
@@ -136,14 +139,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
border-bottom-color: hsla(0, 0%, 20%, 0.9); border-bottom-color: hsla(0, 0%, 20%, 0.9);
} }
.tooltip-bottom:hover:before, .tooltip-bottom:hover:after { .tooltip-bottom:hover:before, [data-tooltip].tooltip-bottom:hover:after,
.tooltip-bottom:hover .tooltip {
-webkit-transform: translateY(12px); -webkit-transform: translateY(12px);
-moz-transform: translateY(12px); -moz-transform: translateY(12px);
transform: translateY(12px); transform: translateY(12px);
} }
/** Tooltips on the right */ /** Tooltips on the right */
.tooltip-right:before, .tooltip-right:after { .tooltip-right:before, [data-tooltip].tooltip-right:after, .tooltip-right .tooltip {
bottom: 50%; bottom: 50%;
left: 100%; left: 100%;
} }
@@ -156,7 +160,8 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
border-right-color: hsla(0, 0%, 20%, 0.9); border-right-color: hsla(0, 0%, 20%, 0.9);
} }
.tooltip-right:hover:before, .tooltip-right:hover:after { .tooltip-right:hover:before, [data-tooltip].tooltip-right:hover:after,
.tooltip-right:hover .tooltip {
-webkit-transform: translateX(12px); -webkit-transform: translateX(12px);
-moz-transform: translateX(12px); -moz-transform: translateX(12px);
transform: translateX(12px); transform: translateX(12px);
@@ -168,7 +173,16 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top,
} }
/** Center content vertically for tooltips ont he left and right */ /** Center content vertically for tooltips ont he left and right */
.tooltip-left:after, .tooltip-right:after { [data-tooltip].tooltip-left:after, [data-tooltip].tooltip-right:after,
.tooltip-left .tooltip, .tooltip-right .tooltip {
margin-left: 0; margin-left: 0;
margin-bottom: -16px; margin-bottom: -16px;
} }
.tooltip ul, .tooltip ol {
padding-left: 1rem;
}
.tooltip :last-child {
margin-bottom: 0;
}

View File

@@ -44,7 +44,7 @@
{{ macros.list_patches_room(room) }} {{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %} {% if room.owner == session["_id"] %}
<div style="display: flex; align-items: center;"> <div style="display: flex; align-items: center;">
<form method=post style="flex-grow: 1; margin-right: 1em;"> <form method="post" id="command-form" style="flex-grow: 1; margin-right: 1em;">
<div class="form-group"> <div class="form-group">
<label for="cmd"></label> <label for="cmd"></label>
<input class="form-control" type="text" id="cmd" name="cmd" <input class="form-control" type="text" id="cmd" name="cmd"
@@ -55,24 +55,89 @@
Open Log File... Open Log File...
</a> </a>
</div> </div>
<div id="logger"></div> {% set log = get_log() -%}
<script type="application/ecmascript"> {%- set log_len = log | length - 1 if log.endswith("…") else log | length -%}
let xmlhttp = new XMLHttpRequest(); <div id="logger" style="white-space: pre">{{ log }}</div>
let url = '{{ url_for('display_log', room = room.id) }}'; <script>
let url = '{{ url_for('display_log', room = room.id) }}';
let bytesReceived = {{ log_len }};
let updateLogTimeout;
let awaitingCommandResponse = false;
let logger = document.getElementById("logger");
xmlhttp.onreadystatechange = function () { function scrollToBottom(el) {
if (this.readyState === 4 && this.status === 200) { let bot = el.scrollHeight - el.clientHeight;
document.getElementById("logger").innerText = this.responseText; el.scrollTop += Math.ceil((bot - el.scrollTop)/10);
} if (bot - el.scrollTop >= 1) {
}; window.clearTimeout(el.scrollTimer);
el.scrollTimer = window.setTimeout(() => {
function request_new() { scrollToBottom(el)
xmlhttp.open("GET", url, true); }, 16);
xmlhttp.send();
} }
}
window.setTimeout(request_new, 1000); async function updateLog() {
window.setInterval(request_new, 10000); try {
let res = await fetch(url, {
headers: {
'Range': `bytes=${bytesReceived}-`,
}
});
if (res.ok) {
let text = await res.text();
if (text.length > 0) {
awaitingCommandResponse = false;
if (bytesReceived === 0 || res.status !== 206) {
logger.innerHTML = '';
}
if (res.status !== 206) {
bytesReceived = 0;
} else {
bytesReceived += new Blob([text]).size;
}
if (logger.innerHTML.endsWith('…')) {
logger.innerHTML = logger.innerHTML.substring(0, logger.innerHTML.length - 1);
}
logger.appendChild(document.createTextNode(text));
scrollToBottom(logger);
}
}
}
finally {
window.clearTimeout(updateLogTimeout);
updateLogTimeout = window.setTimeout(updateLog, awaitingCommandResponse ? 500 : 10000);
}
}
async function postForm(ev) {
/** @type {HTMLInputElement} */
let cmd = document.getElementById("cmd");
if (cmd.value === "") {
ev.preventDefault();
return;
}
/** @type {HTMLFormElement} */
let form = document.getElementById("command-form");
let req = fetch(form.action || window.location.href, {
method: form.method,
body: new FormData(form),
redirect: "manual",
});
ev.preventDefault(); // has to happen before first await
form.reset();
let res = await req;
if (res.ok || res.type === 'opaqueredirect') {
awaitingCommandResponse = true;
window.clearTimeout(updateLogTimeout);
updateLogTimeout = window.setTimeout(updateLog, 100);
} else {
window.alert(res.statusText);
}
}
document.getElementById("command-form").addEventListener("submit", postForm);
updateLogTimeout = window.setTimeout(updateLog, 1000);
logger.scrollTop = logger.scrollHeight;
</script> </script>
{% endif %} {% endif %}
</div> </div>

View File

@@ -57,9 +57,9 @@
<select id="{{ option_name }}-select" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}> <select id="{{ option_name }}-select" data-option-name="{{ option_name }}" {{ "disabled" if option.default == "random" }}>
{% for key, val in option.special_range_names.items() %} {% for key, val in option.special_range_names.items() %}
{% if option.default == val %} {% if option.default == val %}
<option value="{{ val }}" selected>{{ key }} ({{ val }})</option> <option value="{{ val }}" selected>{{ key|replace("_", " ")|title }} ({{ val }})</option>
{% else %} {% else %}
<option value="{{ val }}">{{ key }} ({{ val }})</option> <option value="{{ val }}">{{ key|replace("_", " ")|title }} ({{ val }})</option>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<option value="custom" hidden>Custom</option> <option value="custom" hidden>Custom</option>
@@ -111,7 +111,7 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro ItemDict(option_name, option, world) %} {% macro ItemDict(option_name, option) %}
{{ OptionTitle(option_name, option) }} {{ OptionTitle(option_name, option) }}
<div class="option-container"> <div class="option-container">
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %} {% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
@@ -135,7 +135,7 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro LocationSet(option_name, option, world) %} {% macro LocationSet(option_name, option) %}
{{ OptionTitle(option_name, option) }} {{ OptionTitle(option_name, option) }}
<div class="option-container"> <div class="option-container">
{% for group_name in world.location_name_groups.keys()|sort %} {% for group_name in world.location_name_groups.keys()|sort %}
@@ -158,7 +158,7 @@
</div> </div>
{% endmacro %} {% endmacro %}
{% macro ItemSet(option_name, option, world) %} {% macro ItemSet(option_name, option) %}
{{ OptionTitle(option_name, option) }} {{ OptionTitle(option_name, option) }}
<div class="option-container"> <div class="option-container">
{% for group_name in world.item_name_groups.keys()|sort %} {% for group_name in world.item_name_groups.keys()|sort %}
@@ -196,7 +196,18 @@
{% macro OptionTitle(option_name, option) %} {% macro OptionTitle(option_name, option) %}
<label for="{{ option_name }}"> <label for="{{ option_name }}">
{{ option.display_name|default(option_name) }}: {{ option.display_name|default(option_name) }}:
<span class="interactive" data-tooltip="{% filter dedent %}{{(option.__doc__ | default("Please document me!"))|escape }}{% endfilter %}">(?)</span> <span
class="interactive tooltip-container"
{% if not (option.rich_text_doc | default(world.web.rich_text_options_doc, true)) %}
data-tooltip="{{(option.__doc__ | default("Please document me!"))|replace('\n ', '\n')|escape|trim}}"
{% endif %}>
(?)
{% if option.rich_text_doc | default(world.web.rich_text_options_doc, true) %}
<div class="tooltip">
{{ option.__doc__ | default("**Please document me!**") | rst_to_html | safe }}
</div>
{% endif %}
</span>
</label> </label>
{% endmacro %} {% endmacro %}

View File

@@ -1,5 +1,5 @@
{% extends 'pageWrapper.html' %} {% extends 'pageWrapper.html' %}
{% import 'playerOptions/macros.html' as inputs %} {% import 'playerOptions/macros.html' as inputs with context %}
{% block head %} {% block head %}
<title>{{ world_name }} Options</title> <title>{{ world_name }} Options</title>
@@ -94,16 +94,16 @@
{{ inputs.FreeText(option_name, option) }} {{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %} {% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option, world) }} {{ inputs.ItemDict(option_name, option) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %} {% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }} {{ inputs.OptionList(option_name, option) }}
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %} {% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
{{ inputs.LocationSet(option_name, option, world) }} {{ inputs.LocationSet(option_name, option) }}
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %} {% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
{{ inputs.ItemSet(option_name, option, world) }} {{ inputs.ItemSet(option_name, option) }}
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %} {% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
{{ inputs.OptionSet(option_name, option) }} {{ inputs.OptionSet(option_name, option) }}
@@ -134,16 +134,16 @@
{{ inputs.FreeText(option_name, option) }} {{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %} {% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option, world) }} {{ inputs.ItemDict(option_name, option) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %} {% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }} {{ inputs.OptionList(option_name, option) }}
{% elif issubclass(option, Options.LocationSet) and option.verify_location_name %} {% elif issubclass(option, Options.LocationSet) and option.verify_location_name %}
{{ inputs.LocationSet(option_name, option, world) }} {{ inputs.LocationSet(option_name, option) }}
{% elif issubclass(option, Options.ItemSet) and option.verify_item_name %} {% elif issubclass(option, Options.ItemSet) and option.verify_item_name %}
{{ inputs.ItemSet(option_name, option, world) }} {{ inputs.ItemSet(option_name, option) }}
{% elif issubclass(option, Options.OptionSet) and option.valid_keys %} {% elif issubclass(option, Options.OptionSet) and option.valid_keys %}
{{ inputs.OptionSet(option_name, option) }} {{ inputs.OptionSet(option_name, option) }}

View File

@@ -19,7 +19,7 @@
{% for id, name in option.name_lookup.items() %} {% for id, name in option.name_lookup.items() %}
{% if name != 'random' %} {% if name != 'random' %}
{% if option.default != 'random' %} {% if option.default != 'random' %}
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name else None) }} {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.default == id else None) }}
{% else %} {% else %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }} {{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %} {% endif %}
@@ -41,13 +41,13 @@
The following values have special meanings, and may fall outside the normal range. The following values have special meanings, and may fall outside the normal range.
<ul> <ul>
{% for name, value in option.special_range_names.items() %} {% for name, value in option.special_range_names.items() %}
<li>{{ value }}: {{ name }}</li> <li>{{ value }}: {{ name|replace("_", " ")|title }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
<div class="add-option-div"> <div class="add-option-div">
<input type="number" class="range-option-value" data-option="{{ option_name }}" /> <input type="number" class="range-option-value" data-option="{{ option_name }}" />
<button class="add-range-option-button" data-option="{{ option_name }}">Add</button> <button type="button" class="add-range-option-button" data-option="{{ option_name }}">Add</button>
</div> </div>
</div> </div>
<table class="range-rows" data-option="{{ option_name }}"> <table class="range-rows" data-option="{{ option_name }}">
@@ -72,7 +72,7 @@
This option allows custom values only. Please enter your desired values below. This option allows custom values only. Please enter your desired values below.
<div class="custom-value-wrapper"> <div class="custom-value-wrapper">
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" /> <input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
<button data-option="{{ option_name }}">Add</button> <button type="button" data-option="{{ option_name }}">Add</button>
</div> </div>
<table> <table>
<tbody> <tbody>
@@ -89,7 +89,7 @@
Custom values are also allowed for this option. To create one, enter it into the input box below. Custom values are also allowed for this option. To create one, enter it into the input box below.
<div class="custom-value-wrapper"> <div class="custom-value-wrapper">
<input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" /> <input class="custom-value" data-option="{{ option_name }}" placeholder="Custom Value" />
<button data-option="{{ option_name }}">Add</button> <button type="button" data-option="{{ option_name }}">Add</button>
</div> </div>
</div> </div>
<table> <table>
@@ -97,7 +97,7 @@
{% for id, name in option.name_lookup.items() %} {% for id, name in option.name_lookup.items() %}
{% if name != 'random' %} {% if name != 'random' %}
{% if option.default != 'random' %} {% if option.default != 'random' %}
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name else None) }} {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.default == id else None) }}
{% else %} {% else %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }} {{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %} {% endif %}

View File

@@ -1366,28 +1366,28 @@ if "Starcraft 2" in network_data_package["games"]:
organics_icon_base_url = "https://0rganics.org/archipelago/sc2wol/" organics_icon_base_url = "https://0rganics.org/archipelago/sc2wol/"
icons = { icons = {
"Starting Minerals": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-mineral-protoss.png", "Starting Minerals": github_icon_base_url + "blizzard/icon-mineral-nobg.png",
"Starting Vespene": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-gas-terran.png", "Starting Vespene": github_icon_base_url + "blizzard/icon-gas-terran-nobg.png",
"Starting Supply": github_icon_base_url + "blizzard/icon-supply-terran_nobg.png", "Starting Supply": github_icon_base_url + "blizzard/icon-supply-terran_nobg.png",
"Terran Infantry Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel1.png", "Terran Infantry Weapons Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryweaponslevel1.png",
"Terran Infantry Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel2.png", "Terran Infantry Weapons Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryweaponslevel2.png",
"Terran Infantry Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel3.png", "Terran Infantry Weapons Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryweaponslevel3.png",
"Terran Infantry Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel1.png", "Terran Infantry Armor Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryarmorlevel1.png",
"Terran Infantry Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel2.png", "Terran Infantry Armor Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryarmorlevel2.png",
"Terran Infantry Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel3.png", "Terran Infantry Armor Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-infantryarmorlevel3.png",
"Terran Vehicle Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel1.png", "Terran Vehicle Weapons Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleweaponslevel1.png",
"Terran Vehicle Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel2.png", "Terran Vehicle Weapons Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleweaponslevel2.png",
"Terran Vehicle Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel3.png", "Terran Vehicle Weapons Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleweaponslevel3.png",
"Terran Vehicle Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel1.png", "Terran Vehicle Armor Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleplatinglevel1.png",
"Terran Vehicle Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel2.png", "Terran Vehicle Armor Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleplatinglevel2.png",
"Terran Vehicle Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel3.png", "Terran Vehicle Armor Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-vehicleplatinglevel3.png",
"Terran Ship Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel1.png", "Terran Ship Weapons Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-shipweaponslevel1.png",
"Terran Ship Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel2.png", "Terran Ship Weapons Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-shipweaponslevel2.png",
"Terran Ship Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel3.png", "Terran Ship Weapons Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-shipweaponslevel3.png",
"Terran Ship Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel1.png", "Terran Ship Armor Level 1": github_icon_base_url + "blizzard/btn-upgrade-terran-shipplatinglevel1.png",
"Terran Ship Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel2.png", "Terran Ship Armor Level 2": github_icon_base_url + "blizzard/btn-upgrade-terran-shipplatinglevel2.png",
"Terran Ship Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel3.png", "Terran Ship Armor Level 3": github_icon_base_url + "blizzard/btn-upgrade-terran-shipplatinglevel3.png",
"Bunker": "https://static.wikia.nocookie.net/starcraft/images/c/c5/Bunker_SC2_Icon1.jpg", "Bunker": "https://static.wikia.nocookie.net/starcraft/images/c/c5/Bunker_SC2_Icon1.jpg",
"Missile Turret": "https://static.wikia.nocookie.net/starcraft/images/5/5f/MissileTurret_SC2_Icon1.jpg", "Missile Turret": "https://static.wikia.nocookie.net/starcraft/images/5/5f/MissileTurret_SC2_Icon1.jpg",

View File

@@ -152,7 +152,7 @@ def get_payload(ctx: ZeldaContext):
def reconcile_shops(ctx: ZeldaContext): def reconcile_shops(ctx: ZeldaContext):
checked_location_names = [ctx.location_names.lookup_in_slot(location) for location in ctx.checked_locations] checked_location_names = [ctx.location_names.lookup_in_game(location) for location in ctx.checked_locations]
shops = [location for location in checked_location_names if "Shop" in location] shops = [location for location in checked_location_names if "Shop" in location]
left_slots = [shop for shop in shops if "Left" in shop] left_slots = [shop for shop in shops if "Left" in shop]
middle_slots = [shop for shop in shops if "Middle" in shop] middle_slots = [shop for shop in shops if "Middle" in shop]
@@ -190,7 +190,7 @@ async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone=
locations_checked = [] locations_checked = []
location = None location = None
for location in ctx.missing_locations: for location in ctx.missing_locations:
location_name = ctx.location_names.lookup_in_slot(location) location_name = ctx.location_names.lookup_in_game(location)
if location_name in Locations.overworld_locations and zone == "overworld": if location_name in Locations.overworld_locations and zone == "overworld":
status = locations_array[Locations.major_location_offsets[location_name]] status = locations_array[Locations.major_location_offsets[location_name]]

View File

@@ -68,21 +68,21 @@ requires:
{%- elif option.options -%} {%- elif option.options -%}
{%- for suboption_option_id, sub_option_name in option.name_lookup.items() %} {%- for suboption_option_id, sub_option_name in option.name_lookup.items() %}
{{ sub_option_name }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %} {{ yaml_dump(sub_option_name) }}: {% if suboption_option_id == option.default %}50{% else %}0{% endif %}
{%- endfor -%} {%- endfor -%}
{%- if option.name_lookup[option.default] not in option.options %} {%- if option.name_lookup[option.default] not in option.options %}
{{ option.default }}: 50 {{ yaml_dump(option.default) }}: 50
{%- endif -%} {%- endif -%}
{%- elif option.default is string %} {%- elif option.default is string %}
{{ option.default }}: 50 {{ yaml_dump(option.default) }}: 50
{%- elif option.default is iterable and option.default is not mapping %} {%- elif option.default is iterable and option.default is not mapping %}
{{ option.default | list }} {{ option.default | list }}
{%- else %} {%- else %}
{{ yaml_dump(option.default) | trim | indent(4, first=false) }} {{ yaml_dump(option.default) | indent(4, first=false) }}
{%- endif -%} {%- endif -%}
{{ "\n" }} {{ "\n" }}
{%- endfor %} {%- endfor %}

View File

@@ -1,8 +1,8 @@
# Archipelago World Code Owners / Maintainers Document # Archipelago World Code Owners / Maintainers Document
# #
# This file is used to notate the current "owners" or "maintainers" of any currently merged world folder. For any pull # This file is used to notate the current "owners" or "maintainers" of any currently merged world folder as well as
# requests that modify these worlds, a code owner must approve the PR in addition to a core maintainer. This is not to # certain documentation. For any pull requests that modify these worlds/docs, a code owner must approve the PR in
# be used for files/folders outside the /worlds folder, those will always need sign off from a core maintainer. # addition to a core maintainer. All other files and folders are owned and maintained by core maintainers directly.
# #
# All usernames must be GitHub usernames (and are case sensitive). # All usernames must be GitHub usernames (and are case sensitive).
@@ -87,9 +87,6 @@
# Lingo # Lingo
/worlds/lingo/ @hatkirby /worlds/lingo/ @hatkirby
# Links Awakening DX
/worlds/ladx/ @zig-for
# Lufia II Ancient Cave # Lufia II Ancient Cave
/worlds/lufia2ac/ @el-u /worlds/lufia2ac/ @el-u
/worlds/lufia2ac/docs/ @wordfcuk @el-u /worlds/lufia2ac/docs/ @wordfcuk @el-u
@@ -218,6 +215,8 @@
# Final Fantasy (1) # Final Fantasy (1)
# /worlds/ff1/ # /worlds/ff1/
# Links Awakening DX
# /worlds/ladx/
## Disabled Unmaintained Worlds ## Disabled Unmaintained Worlds
@@ -227,3 +226,11 @@
# Ori and the Blind Forest # Ori and the Blind Forest
# /worlds_disabled/oribf/ # /worlds_disabled/oribf/
###################
## Documentation ##
###################
# Apworld Dev Faq
/docs/apworld_dev_faq.md @qwint @ScipioWright

68
docs/apworld_dev_faq.md Normal file
View File

@@ -0,0 +1,68 @@
# APWorld Dev FAQ
This document is meant as a reference tool to show solutions to common problems when developing an apworld.
It is not intended to answer every question about Archipelago and it assumes you have read the other docs,
including [Contributing](contributing.md), [Adding Games](<adding games.md>), and [World API](<world api.md>).
---
### My game has a restrictive start that leads to fill errors
Hint to the Generator that an item needs to be in sphere one with local_early_items. Here, `1` represents the number of "Sword" items to attempt to place in sphere one.
```py
early_item_name = "Sword"
self.multiworld.local_early_items[self.player][early_item_name] = 1
```
Some alternative ways to try to fix this problem are:
* Add more locations to sphere one of your world, potentially only when there would be a restrictive start
* Pre-place items yourself, such as during `create_items`
* Put items into the player's starting inventory using `push_precollected`
* Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a restrictive start
---
### I have multiple settings that change the item/location pool counts and need to balance them out
In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be unbalanced. But in real, complex situations, that might be unfeasible.
If that's the case, you can create extra filler based on the difference between your unfilled locations and your itempool by comparing [get_unfilled_locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#:~:text=get_unfilled_locations) to your list of items to submit
Note: to use self.create_filler(), self.get_filler_item_name() should be defined to only return valid filler item names
```py
total_locations = len(self.multiworld.get_unfilled_locations(self.player))
item_pool = self.create_non_filler_items()
for _ in range(total_locations - len(item_pool)):
item_pool.append(self.create_filler())
self.multiworld.itempool += item_pool
```
A faster alternative to the `for` loop would be to use a [list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions):
```py
item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))]
```
---
### I learned about indirect conditions in the world API document, but I want to know more. What are they and why are they necessary?
The world API document mentions indirect conditions and **when** you should use them, but not *how* they work and *why* they are necessary. This is because the explanation is quite complicated.
Region sweep (the algorithm that determines which regions are reachable) is a Breadth-First Search of the region graph from the origin region, checking entrances one by one and adding newly reached nodes (regions) and their entrances to the queue until there is nothing more to check.
For performance reasons, AP only checks every entrance once. However, if an entrance's access condition depends on regions, then it is possible for this to happen:
1. An entrance that depends on a region is checked and determined to be nontraversable because the region hasn't been reached yet during the graph search.
2. After that, the region is reached by the graph search.
The entrance *would* now be determined to be traversable if it were rechecked, but it is not.
To account for this case, AP would have to recheck all entrances every time a new region is reached until no new regions are reached.
However, there is a way to **manually** define that a *specific* entrance needs to be rechecked during region sweep if a *specific* region is reached during it. This is what an indirect condition is.
This keeps almost all of the performance upsides. Even a game making heavy use of indirect conditions (See: The Witness) is still significantly faster than if it just blanket "rechecked all entrances until nothing new is found".
The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is simple: They call `region.can_reach` on their respective parent/source region.
We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition, and that some games have very complex access rules.
As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682) being merged, it is also possible for a world to opt out of indirect conditions entirely, although it does come at a flat performance cost.
It should only be used by games that *really* need it. For most games, it should be reasonable to know all entrance &rarr; region dependencies, and in this case, indirect conditions are still preferred because they are faster.

View File

@@ -85,6 +85,50 @@ class ExampleWorld(World):
options: ExampleGameOptions options: ExampleGameOptions
``` ```
### Option Documentation
Options' [docstrings] are used as their user-facing documentation. They're displayed on the WebHost setup page when a
user hovers over the yellow "(?)" icon, and included in the YAML templates generated for each game.
[docstrings]: /docs/world%20api.md#docstrings
The WebHost can display Option documentation either as plain text with all whitespace preserved (other than the base
indentation), or as HTML generated from the standard Python [reStructuredText] format. Although plain text is the
default for backwards compatibility, world authors are encouraged to write their Option documentation as
reStructuredText and enable rich text rendering by setting `World.rich_text_options_doc = True`.
[reStructuredText]: https://docutils.sourceforge.io/rst.html
```python
from worlds.AutoWorld import WebWorld
class ExampleWebWorld(WebWorld):
# Render all this world's options as rich text.
rich_text_options_doc = True
```
You can set a single option to use rich or plain text by setting
`Option.rich_text_doc`.
```python
from Options import Toggle, Range, Choice, PerGameCommonOptions
class Difficulty(Choice):
"""Sets overall game difficulty.
- **Easy:** All enemies die in one hit.
- **Normal:** Enemies and the player both have normal health bars.
- **Hard:** The player dies in one hit."""
display_name = "Difficulty"
rich_text_doc = True
option_easy = 0
option_normal = 1
option_hard = 2
default = 1
```
### Option Groups ### Option Groups
Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified
by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment

View File

@@ -56,6 +56,12 @@ webhost:
* `options_page` can be changed to a link instead of an AP-generated options page. * `options_page` can be changed to a link instead of an AP-generated options page.
* `rich_text_options_doc` controls whether [Option documentation] uses plain text (`False`) or rich text (`True`). It
defaults to `False`, but world authors are encouraged to set it to `True` for nicer-looking documentation that looks
good on both the WebHost and the YAML template.
[Option documentation]: /docs/options%20api.md#option-documentation
* `theme` to be used for your game-specific AP pages. Available themes: * `theme` to be used for your game-specific AP pages. Available themes:
| dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone | | dirt | grass (default) | grassFlowers | ice | jungle | ocean | partyTime | stone |
@@ -450,8 +456,9 @@ In addition, the following methods can be implemented and are called in this ord
called to place player's regions and their locations into the MultiWorld's regions list. called to place player's regions and their locations into the MultiWorld's regions list.
If it's hard to separate, this can be done during `generate_early` or `create_items` as well. If it's hard to separate, this can be done during `generate_early` or `create_items` as well.
* `create_items(self)` * `create_items(self)`
called to place player's items into the MultiWorld's itempool. After this step all regions called to place player's items into the MultiWorld's itempool. By the end of this step all regions, locations and
and items have to be in the MultiWorld's regions and itempool, and these lists should not be modified afterward. items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions
after this step. Locations cannot be moved to different regions after this step.
* `set_rules(self)` * `set_rules(self)`
called to set access and item rules on locations and entrances. called to set access and item rules on locations and entrances.
* `generate_basic(self)` * `generate_basic(self)`

View File

@@ -219,7 +219,7 @@ Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{
Root: HKCR; Subkey: ".apworld"; ValueData: "{#MyAppName}worlddata"; Flags: uninsdeletevalue; ValueType: string; ValueName: ""; Root: HKCR; Subkey: ".apworld"; ValueData: "{#MyAppName}worlddata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}worlddata"; ValueData: "Archipelago World Data"; Flags: uninsdeletekey; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}worlddata"; ValueData: "Archipelago World Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}worlddata\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: ""; Root: HKCR; Subkey: "{#MyAppName}worlddata\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey; Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: ""; Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";

View File

@@ -595,8 +595,9 @@ class GameManager(App):
"!help for server commands.") "!help for server commands.")
def connect_button_action(self, button): def connect_button_action(self, button):
self.ctx.username = None
self.ctx.password = None
if self.ctx.server: if self.ctx.server:
self.ctx.username = None
async_start(self.ctx.disconnect()) async_start(self.ctx.disconnect())
else: else:
async_start(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", ""))) async_start(self.ctx.connect(self.server_connect_bar.text.replace("/connect ", "")))
@@ -836,6 +837,10 @@ class KivyJSONtoTextParser(JSONtoTextParser):
return self._handle_text(node) return self._handle_text(node)
def _handle_text(self, node: JSONMessagePart): def _handle_text(self, node: JSONMessagePart):
# All other text goes through _handle_color, and we don't want to escape markup twice,
# or mess up text that already has intentional markup applied to it
if node.get("type", "text") == "text":
node["text"] = escape_markup(node["text"])
for ref in node.get("refs", []): for ref in node.get("refs", []):
node["text"] = f"[ref={self.ref_count}|{ref}]{node['text']}[/ref]" node["text"] = f"[ref={self.ref_count}|{ref}]{node['text']}[/ref]"
self.ref_count += 1 self.ref_count += 1

View File

@@ -3,6 +3,7 @@ Application settings / host.yaml interface using type hints.
This is different from player options. This is different from player options.
""" """
import os
import os.path import os.path
import shutil import shutil
import sys import sys
@@ -11,7 +12,6 @@ import warnings
from enum import IntEnum from enum import IntEnum
from threading import Lock from threading import Lock
from typing import cast, Any, BinaryIO, ClassVar, Dict, Iterator, List, Optional, TextIO, Tuple, Union, TypeVar from typing import cast, Any, BinaryIO, ClassVar, Dict, Iterator, List, Optional, TextIO, Tuple, Union, TypeVar
import os
__all__ = [ __all__ = [
"get_settings", "fmt_doc", "no_gui", "get_settings", "fmt_doc", "no_gui",
@@ -798,6 +798,7 @@ class Settings(Group):
atexit.register(autosave) atexit.register(autosave)
def save(self, location: Optional[str] = None) -> None: # as above def save(self, location: Optional[str] = None) -> None: # as above
from Utils import parse_yaml
location = location or self._filename location = location or self._filename
assert location, "No file specified" assert location, "No file specified"
temp_location = location + ".tmp" # not using tempfile to test expected file access temp_location = location + ".tmp" # not using tempfile to test expected file access
@@ -807,10 +808,18 @@ class Settings(Group):
# can't use utf-8-sig because it breaks backward compat: pyyaml on Windows with bytes does not strip the BOM # can't use utf-8-sig because it breaks backward compat: pyyaml on Windows with bytes does not strip the BOM
with open(temp_location, "w", encoding="utf-8") as f: with open(temp_location, "w", encoding="utf-8") as f:
self.dump(f) self.dump(f)
# replace old with new f.flush()
if os.path.exists(location): if hasattr(os, "fsync"):
os.fsync(f.fileno())
# validate new file is valid yaml
with open(temp_location, encoding="utf-8") as f:
parse_yaml(f.read())
# replace old with new, try atomic operation first
try:
os.rename(temp_location, location)
except (OSError, FileExistsError):
os.unlink(location) os.unlink(location)
os.rename(temp_location, location) os.rename(temp_location, location)
self._filename = location self._filename = location
def dump(self, f: TextIO, level: int = 0) -> None: def dump(self, f: TextIO, level: int = 0) -> None:
@@ -832,7 +841,6 @@ def get_settings() -> Settings:
with _lock: # make sure we only have one instance with _lock: # make sure we only have one instance
res = getattr(get_settings, "_cache", None) res = getattr(get_settings, "_cache", None)
if not res: if not res:
import os
from Utils import user_path, local_path from Utils import user_path, local_path
filenames = ("options.yaml", "host.yaml") filenames = ("options.yaml", "host.yaml")
locations: List[str] = [] locations: List[str] = []

View File

@@ -21,7 +21,7 @@ from pathlib import Path
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it # This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
try: try:
requirement = 'cx-Freeze==7.0.0' requirement = 'cx-Freeze==7.2.0'
import pkg_resources import pkg_resources
try: try:
pkg_resources.require(requirement) pkg_resources.require(requirement)

View File

@@ -292,12 +292,12 @@ class WorldTestBase(unittest.TestCase):
"""Ensure all state can reach everything and complete the game with the defined options""" """Ensure all state can reach everything and complete the game with the defined options"""
if not (self.run_default_tests and self.constructed): if not (self.run_default_tests and self.constructed):
return return
with self.subTest("Game", game=self.game): with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
excluded = self.multiworld.worlds[self.player].options.exclude_locations.value excluded = self.multiworld.worlds[self.player].options.exclude_locations.value
state = self.multiworld.get_all_state(False) state = self.multiworld.get_all_state(False)
for location in self.multiworld.get_locations(): for location in self.multiworld.get_locations():
if location.name not in excluded: if location.name not in excluded:
with self.subTest("Location should be reached", location=location): with self.subTest("Location should be reached", location=location.name):
reachable = location.can_reach(state) reachable = location.can_reach(state)
self.assertTrue(reachable, f"{location.name} unreachable") self.assertTrue(reachable, f"{location.name} unreachable")
with self.subTest("Beatable"): with self.subTest("Beatable"):
@@ -308,7 +308,7 @@ class WorldTestBase(unittest.TestCase):
"""Ensure empty state can reach at least one location with the defined options""" """Ensure empty state can reach at least one location with the defined options"""
if not (self.run_default_tests and self.constructed): if not (self.run_default_tests and self.constructed):
return return
with self.subTest("Game", game=self.game): with self.subTest("Game", game=self.game, seed=self.multiworld.seed):
state = CollectionState(self.multiworld) state = CollectionState(self.multiworld)
locations = self.multiworld.get_reachable_locations(state, self.player) locations = self.multiworld.get_reachable_locations(state, self.player)
self.assertGreater(len(locations), 0, self.assertGreater(len(locations), 0,
@@ -329,7 +329,7 @@ class WorldTestBase(unittest.TestCase):
for n in range(len(locations) - 1, -1, -1): for n in range(len(locations) - 1, -1, -1):
if locations[n].can_reach(state): if locations[n].can_reach(state):
sphere.append(locations.pop(n)) sphere.append(locations.pop(n))
self.assertTrue(sphere or self.multiworld.accessibility[1] == "minimal", self.assertTrue(sphere or self.multiworld.worlds[1].options.accessibility == "minimal",
f"Unreachable locations: {locations}") f"Unreachable locations: {locations}")
if not sphere: if not sphere:
break break

View File

@@ -1,11 +1,12 @@
import os import os
import os.path
import unittest import unittest
from io import StringIO from io import StringIO
from tempfile import TemporaryFile from tempfile import TemporaryDirectory, TemporaryFile
from typing import Any, Dict, List, cast from typing import Any, Dict, List, cast
import Utils import Utils
from settings import Settings, Group from settings import Group, Settings, ServerOptions
class TestIDs(unittest.TestCase): class TestIDs(unittest.TestCase):
@@ -80,3 +81,27 @@ class TestSettingsDumper(unittest.TestCase):
self.assertEqual(value_spaces[2], value_spaces[0]) # start of sub-list self.assertEqual(value_spaces[2], value_spaces[0]) # start of sub-list
self.assertGreater(value_spaces[3], value_spaces[0], self.assertGreater(value_spaces[3], value_spaces[0],
f"{value_lines[3]} should have more indentation than {value_lines[0]} in {lines}") f"{value_lines[3]} should have more indentation than {value_lines[0]} in {lines}")
class TestSettingsSave(unittest.TestCase):
def test_save(self) -> None:
"""Test that saving and updating works"""
with TemporaryDirectory() as d:
filename = os.path.join(d, "host.yaml")
new_release_mode = ServerOptions.ReleaseMode("enabled")
# create default host.yaml
settings = Settings(None)
settings.save(filename)
self.assertTrue(os.path.exists(filename),
"Default settings could not be saved")
self.assertNotEqual(settings.server_options.release_mode, new_release_mode,
"Unexpected default release mode")
# update host.yaml
settings.server_options.release_mode = new_release_mode
settings.save(filename)
self.assertFalse(os.path.exists(filename + ".tmp"),
"Temp file was not removed during save")
# read back host.yaml
settings = Settings(filename)
self.assertEqual(settings.server_options.release_mode, new_release_mode,
"Settings were not overwritten")

View File

@@ -41,15 +41,15 @@ class TestBase(unittest.TestCase):
state = multiworld.get_all_state(False) state = multiworld.get_all_state(False)
for location in multiworld.get_locations(): for location in multiworld.get_locations():
if location.name not in excluded: if location.name not in excluded:
with self.subTest("Location should be reached", location=location): with self.subTest("Location should be reached", location=location.name):
self.assertTrue(location.can_reach(state), f"{location.name} unreachable") self.assertTrue(location.can_reach(state), f"{location.name} unreachable")
for region in multiworld.get_regions(): for region in multiworld.get_regions():
if region.name in unreachable_regions: if region.name in unreachable_regions:
with self.subTest("Region should be unreachable", region=region): with self.subTest("Region should be unreachable", region=region.name):
self.assertFalse(region.can_reach(state)) self.assertFalse(region.can_reach(state))
else: else:
with self.subTest("Region should be reached", region=region): with self.subTest("Region should be reached", region=region.name):
self.assertTrue(region.can_reach(state)) self.assertTrue(region.can_reach(state))
with self.subTest("Completion Condition"): with self.subTest("Completion Condition"):

View File

@@ -26,6 +26,7 @@ def _generate_local_inner(games: Iterable[str],
with TemporaryDirectory() as players_dir: with TemporaryDirectory() as players_dir:
with TemporaryDirectory() as output_dir: with TemporaryDirectory() as output_dir:
import Generate import Generate
import Main
for n, game in enumerate(games, 1): for n, game in enumerate(games, 1):
player_path = Path(players_dir) / f"{n}.yaml" player_path = Path(players_dir) / f"{n}.yaml"
@@ -42,7 +43,7 @@ def _generate_local_inner(games: Iterable[str],
sys.argv = [sys.argv[0], "--seed", str(hash(tuple(games))), sys.argv = [sys.argv[0], "--seed", str(hash(tuple(games))),
"--player_files_path", players_dir, "--player_files_path", players_dir,
"--outputpath", output_dir] "--outputpath", output_dir]
Generate.main() Main.main(*Generate.main())
output_files = list(Path(output_dir).glob('*.zip')) output_files = list(Path(output_dir).glob('*.zip'))
assert len(output_files) == 1 assert len(output_files) == 1
final_file = dest / output_files[0].name final_file = dest / output_files[0].name

View File

@@ -9,6 +9,7 @@ from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
import Generate import Generate
import Main
class TestGenerateMain(unittest.TestCase): class TestGenerateMain(unittest.TestCase):
@@ -58,7 +59,7 @@ class TestGenerateMain(unittest.TestCase):
'--player_files_path', str(self.abs_input_dir), '--player_files_path', str(self.abs_input_dir),
'--outputpath', self.output_tempdir.name] '--outputpath', self.output_tempdir.name]
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}') print(f'Testing Generate.py {sys.argv} in {os.getcwd()}')
Generate.main() Main.main(*Generate.main())
self.assertOutput(self.output_tempdir.name) self.assertOutput(self.output_tempdir.name)
@@ -67,7 +68,7 @@ class TestGenerateMain(unittest.TestCase):
'--player_files_path', str(self.rel_input_dir), '--player_files_path', str(self.rel_input_dir),
'--outputpath', self.output_tempdir.name] '--outputpath', self.output_tempdir.name]
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}') print(f'Testing Generate.py {sys.argv} in {os.getcwd()}')
Generate.main() Main.main(*Generate.main())
self.assertOutput(self.output_tempdir.name) self.assertOutput(self.output_tempdir.name)
@@ -86,7 +87,7 @@ class TestGenerateMain(unittest.TestCase):
sys.argv = [sys.argv[0], '--seed', '0', sys.argv = [sys.argv[0], '--seed', '0',
'--outputpath', self.output_tempdir.name] '--outputpath', self.output_tempdir.name]
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}, player_files_path={self.yaml_input_dir}') print(f'Testing Generate.py {sys.argv} in {os.getcwd()}, player_files_path={self.yaml_input_dir}')
Generate.main() Main.main(*Generate.main())
finally: finally:
user_path.cached_path = user_path_backup user_path.cached_path = user_path_backup

View File

@@ -0,0 +1,36 @@
import unittest
import typing
from uuid import uuid4
from flask import Flask
from flask.testing import FlaskClient
class TestBase(unittest.TestCase):
app: typing.ClassVar[Flask]
client: FlaskClient
@classmethod
def setUpClass(cls) -> None:
from WebHostLib import app as raw_app
from WebHost import get_app
raw_app.config["PONY"] = {
"provider": "sqlite",
"filename": ":memory:",
"create_db": True,
}
raw_app.config.update({
"TESTING": True,
"DEBUG": True,
})
try:
cls.app = get_app()
except AssertionError as e:
# since we only have 1 global app object, this might fail, but luckily all tests use the same config
if "register_blueprint" not in e.args[0]:
raise
cls.app = raw_app
def setUp(self) -> None:
self.client = self.app.test_client()

View File

@@ -1,31 +1,16 @@
import io import io
import unittest
import json import json
import yaml import yaml
from . import TestBase
class TestDocs(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
from WebHostLib import app as raw_app
from WebHost import get_app
raw_app.config["PONY"] = {
"provider": "sqlite",
"filename": ":memory:",
"create_db": True,
}
raw_app.config.update({
"TESTING": True,
})
app = get_app()
cls.client = app.test_client() class TestAPIGenerate(TestBase):
def test_correct_error_empty_request(self) -> None:
def test_correct_error_empty_request(self):
response = self.client.post("/api/generate") response = self.client.post("/api/generate")
self.assertIn("No options found. Expected file attachment or json weights.", response.text) self.assertIn("No options found. Expected file attachment or json weights.", response.text)
def test_generation_queued_weights(self): def test_generation_queued_weights(self) -> None:
options = { options = {
"Tester1": "Tester1":
{ {
@@ -43,7 +28,7 @@ class TestDocs(unittest.TestCase):
self.assertTrue(json_data["text"].startswith("Generation of seed ")) self.assertTrue(json_data["text"].startswith("Generation of seed "))
self.assertTrue(json_data["text"].endswith(" started successfully.")) self.assertTrue(json_data["text"].endswith(" started successfully."))
def test_generation_queued_file(self): def test_generation_queued_file(self) -> None:
options = { options = {
"game": "Archipelago", "game": "Archipelago",
"name": "Tester", "name": "Tester",

View File

@@ -0,0 +1,192 @@
import os
from uuid import UUID, uuid4, uuid5
from flask import url_for
from . import TestBase
class TestHostFakeRoom(TestBase):
room_id: UUID
log_filename: str
def setUp(self) -> None:
from pony.orm import db_session
from Utils import user_path
from WebHostLib.models import Room, Seed
super().setUp()
with self.client.session_transaction() as session:
session["_id"] = uuid4()
with db_session:
# create an empty seed and a room from it
seed = Seed(multidata=b"", owner=session["_id"])
room = Room(seed=seed, owner=session["_id"], tracker=uuid4())
self.room_id = room.id
self.log_filename = user_path("logs", f"{self.room_id}.txt")
def tearDown(self) -> None:
from pony.orm import db_session, select
from WebHostLib.models import Command, Room
with db_session:
for command in select(command for command in Command if command.room.id == self.room_id): # type: ignore
command.delete()
room: Room = Room.get(id=self.room_id)
room.seed.delete()
room.delete()
try:
os.unlink(self.log_filename)
except FileNotFoundError:
pass
def test_display_log_missing_full(self) -> None:
"""
Verify that we get a 200 response even if log is missing.
This is required to not get an error for fetch.
"""
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("display_log", room=self.room_id))
self.assertEqual(response.status_code, 200)
def test_display_log_missing_range(self) -> None:
"""
Verify that we get a full response for missing log even if we asked for range.
This is required for the JS logic to differentiate between log update and log error message.
"""
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("display_log", room=self.room_id), headers={
"Range": "bytes=100-"
})
self.assertEqual(response.status_code, 200)
def test_display_log_denied(self) -> None:
"""Verify that only the owner can see the log."""
other_client = self.app.test_client()
with self.app.app_context(), self.app.test_request_context():
response = other_client.get(url_for("display_log", room=self.room_id))
self.assertEqual(response.status_code, 403)
def test_display_log_missing_room(self) -> None:
"""Verify log for missing room gives an error as opposed to missing log for existing room."""
missing_room_id = uuid5(uuid4(), "") # rooms are always uuid4, so this can't exist
other_client = self.app.test_client()
with self.app.app_context(), self.app.test_request_context():
response = other_client.get(url_for("display_log", room=missing_room_id))
self.assertEqual(response.status_code, 404)
def test_display_log_full(self) -> None:
"""Verify full log response."""
with open(self.log_filename, "w", encoding="utf-8") as f:
text = "x" * 200
f.write(text)
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("display_log", room=self.room_id))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.get_data(True), text)
def test_display_log_range(self) -> None:
"""Verify that Range header in request gives a range in response."""
with open(self.log_filename, "w", encoding="utf-8") as f:
f.write(" " * 100)
text = "x" * 100
f.write(text)
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("display_log", room=self.room_id), headers={
"Range": "bytes=100-"
})
self.assertEqual(response.status_code, 206)
self.assertEqual(response.get_data(True), text)
def test_display_log_range_bom(self) -> None:
"""Verify that a BOM in the log file is skipped for range."""
with open(self.log_filename, "w", encoding="utf-8-sig") as f:
f.write(" " * 100)
text = "x" * 100
f.write(text)
self.assertEqual(f.tell(), 203) # including BOM
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("display_log", room=self.room_id), headers={
"Range": "bytes=100-"
})
self.assertEqual(response.status_code, 206)
self.assertEqual(response.get_data(True), text)
def test_host_room_missing(self) -> None:
"""Verify that missing room gives a 404 response."""
missing_room_id = uuid5(uuid4(), "") # rooms are always uuid4, so this can't exist
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("host_room", room=missing_room_id))
self.assertEqual(response.status_code, 404)
def test_host_room_own(self) -> None:
"""Verify that own room gives the full output."""
with open(self.log_filename, "w", encoding="utf-8-sig") as f:
text = "* should be visible *"
f.write(text)
with self.app.app_context(), self.app.test_request_context():
response = self.client.get(url_for("host_room", room=self.room_id))
response_text = response.get_data(True)
self.assertEqual(response.status_code, 200)
self.assertIn("href=\"/seed/", response_text)
self.assertIn(text, response_text)
def test_host_room_other(self) -> None:
"""Verify that non-own room gives the reduced output."""
from pony.orm import db_session
from WebHostLib.models import Room
with db_session:
room: Room = Room.get(id=self.room_id)
room.last_port = 12345
with open(self.log_filename, "w", encoding="utf-8-sig") as f:
text = "* should not be visible *"
f.write(text)
other_client = self.app.test_client()
with self.app.app_context(), self.app.test_request_context():
response = other_client.get(url_for("host_room", room=self.room_id))
response_text = response.get_data(True)
self.assertEqual(response.status_code, 200)
self.assertNotIn("href=\"/seed/", response_text)
self.assertNotIn(text, response_text)
self.assertIn("/connect ", response_text)
self.assertIn(":12345", response_text)
def test_host_room_own_post(self) -> None:
"""Verify command from owner gets queued for the server and response is redirect."""
from pony.orm import db_session, select
from WebHostLib.models import Command
with self.app.app_context(), self.app.test_request_context():
response = self.client.post(url_for("host_room", room=self.room_id), data={
"cmd": "/help"
})
self.assertEqual(response.status_code, 302, response.text)\
with db_session:
commands = select(command for command in Command if command.room.id == self.room_id) # type: ignore
self.assertIn("/help", (command.commandtext for command in commands))
def test_host_room_other_post(self) -> None:
"""Verify command from non-owner does not get queued for the server."""
from pony.orm import db_session, select
from WebHostLib.models import Command
other_client = self.app.test_client()
with self.app.app_context(), self.app.test_request_context():
response = other_client.post(url_for("host_room", room=self.room_id), data={
"cmd": "/help"
})
self.assertLess(response.status_code, 500)
with db_session:
commands = select(command for command in Command if command.room.id == self.room_id) # type: ignore
self.assertNotIn("/help", (command.commandtext for command in commands))

View File

@@ -223,6 +223,21 @@ class WebWorld(metaclass=WebWorldRegister):
option_groups: ClassVar[List[OptionGroup]] = [] option_groups: ClassVar[List[OptionGroup]] = []
"""Ordered list of option groupings. Any options not set in a group will be placed in a pre-built "Game Options".""" """Ordered list of option groupings. Any options not set in a group will be placed in a pre-built "Game Options"."""
rich_text_options_doc = False
"""Whether the WebHost should render Options' docstrings as rich text.
If this is True, Options' docstrings are interpreted as reStructuredText_,
the standard Python markup format. In the WebHost, they're rendered to HTML
so that lists, emphasis, and other rich text features are displayed
properly.
If this is False, the docstrings are instead interpreted as plain text, and
displayed as-is on the WebHost with whitespace preserved. For backwards
compatibility, this is the default.
.. _reStructuredText: https://docutils.sourceforge.io/rst.html
"""
location_descriptions: Dict[str, str] = {} location_descriptions: Dict[str, str] = {}
"""An optional map from location names (or location group names) to brief descriptions for users.""" """An optional map from location names (or location group names) to brief descriptions for users."""
@@ -265,7 +280,7 @@ class World(metaclass=AutoWorldRegister):
future. Protocol level compatibility check moved to MultiServer.min_client_version. future. Protocol level compatibility check moved to MultiServer.min_client_version.
""" """
required_server_version: Tuple[int, int, int] = (0, 2, 4) required_server_version: Tuple[int, int, int] = (0, 5, 0)
"""update this if the resulting multidata breaks forward-compatibility of the server""" """update this if the resulting multidata breaks forward-compatibility of the server"""
hint_blacklist: ClassVar[FrozenSet[str]] = frozenset() hint_blacklist: ClassVar[FrozenSet[str]] = frozenset()

View File

@@ -128,3 +128,4 @@ from .AutoWorld import AutoWorldRegister
network_data_package: DataPackage = { network_data_package: DataPackage = {
"games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()}, "games": {world_name: world.get_data_package_data() for world_name, world in AutoWorldRegister.world_types.items()},
} }

View File

@@ -1,7 +1,5 @@
from worlds.adventure import location_table from .Options import BatLogic, DifficultySwitchB
from worlds.adventure.Options import BatLogic, DifficultySwitchB, DifficultySwitchA
from worlds.generic.Rules import add_rule, set_rule, forbid_item from worlds.generic.Rules import add_rule, set_rule, forbid_item
from BaseClasses import LocationProgressType
def set_rules(self) -> None: def set_rules(self) -> None:

View File

@@ -292,6 +292,9 @@ blacklisted_combos = {
# See above comment # See above comment
"Time Rift - Deep Sea": ["Alpine Free Roam", "Nyakuza Free Roam", "Contractual Obligations", "Time Rift - Deep Sea": ["Alpine Free Roam", "Nyakuza Free Roam", "Contractual Obligations",
"Murder on the Owl Express"], "Murder on the Owl Express"],
# was causing test failures
"Time Rift - Balcony": ["Alpine Free Roam"],
} }

View File

@@ -863,6 +863,8 @@ def set_rift_rules(world: "HatInTimeWorld", regions: Dict[str, Region]):
if world.is_dlc1(): if world.is_dlc1():
for entrance in regions["Time Rift - Balcony"].entrances: for entrance in regions["Time Rift - Balcony"].entrances:
add_rule(entrance, lambda state: can_clear_required_act(state, world, "The Arctic Cruise - Finale")) add_rule(entrance, lambda state: can_clear_required_act(state, world, "The Arctic Cruise - Finale"))
reg_act_connection(world, world.multiworld.get_entrance("The Arctic Cruise - Finale",
world.player).connected_region, entrance)
for entrance in regions["Time Rift - Deep Sea"].entrances: for entrance in regions["Time Rift - Deep Sea"].entrances:
add_rule(entrance, lambda state: has_relic_combo(state, world, "Cake")) add_rule(entrance, lambda state: has_relic_combo(state, world, "Cake"))
@@ -939,6 +941,7 @@ def set_default_rift_rules(world: "HatInTimeWorld"):
if world.is_dlc1(): if world.is_dlc1():
for entrance in world.multiworld.get_region("Time Rift - Balcony", world.player).entrances: for entrance in world.multiworld.get_region("Time Rift - Balcony", world.player).entrances:
add_rule(entrance, lambda state: can_clear_required_act(state, world, "The Arctic Cruise - Finale")) add_rule(entrance, lambda state: can_clear_required_act(state, world, "The Arctic Cruise - Finale"))
reg_act_connection(world, "Rock the Boat", entrance.name)
for entrance in world.multiworld.get_region("Time Rift - Deep Sea", world.player).entrances: for entrance in world.multiworld.get_region("Time Rift - Deep Sea", world.player).entrances:
add_rule(entrance, lambda state: has_relic_combo(state, world, "Cake")) add_rule(entrance, lambda state: has_relic_combo(state, world, "Cake"))

View File

@@ -12,41 +12,29 @@
## Instructions ## Instructions
1. Have Steam running. Open the Steam console with this link: [steam://open/console](steam://open/console) 1. **BACK UP YOUR SAVE FILES IN YOUR MAIN INSTALL IF YOU CARE ABOUT THEM!!!**
This may not work for some browsers. If that's the case, and you're on Windows, open the Run dialog using Win+R, Go to `steamapps/common/HatinTime/HatinTimeGame/SaveData/` and copy everything inside that folder over to a safe place.
paste the link into the box, and hit Enter. **This is important! Changing the game version CAN and WILL break your existing save files!!!**
2. In the Steam console, enter the following command: 2. In your Steam library, right-click on **A Hat in Time** in the list of games and click on **Properties**.
`download_depot 253230 253232 7770543545116491859`. ***Wait for the console to say the download is finished!***
This can take a while to finish (30+ minutes) depending on your connection speed, so please be patient. Additionally,
**try to prevent your connection from being interrupted or slowed while Steam is downloading the depot,**
or else the download may potentially become corrupted (see first FAQ issue below).
3. Once the download finishes, go to `steamapps/content/app_253230` in Steam's program folder. 3. Click the **Betas** tab. In the **Beta Participation** dropdown, select `tcplink`.
While it downloads, you can subscribe to the [Archipelago workshop mod.]((https://steamcommunity.com/sharedfiles/filedetails/?id=3026842601))
4. There should be a folder named `depot_253232`. Rename it to HatinTime_AP and move it to your `steamapps/common` folder. 4. Once the game finishes downloading, start it up.
In Game Settings, make sure **Enable Developer Console** is checked.
5. In the HatinTime_AP folder, navigate to `Binaries/Win64` and create a new file: `steam_appid.txt`. 5. You should now be good to go. See below for more details on how to use the mod and connect to an Archipelago game.
In this new text file, input the number **253230** on the first line.
6. Create a shortcut of `HatinTimeGame.exe` from that folder and move it to wherever you'd like.
You will use this shortcut to open the Archipelago-compatible version of A Hat in Time.
7. Start up the game using your new shortcut. To confirm if you are on the correct version,
go to Settings -> Game Settings. If you don't see an option labelled ***Live Game Events*** you should be running
the correct version of the game. In Game Settings, make sure ***Enable Developer Console*** is checked.
## Connecting to the Archipelago server ## Connecting to the Archipelago server
To connect to the multiworld server, simply run the **ArchipelagoAHITClient** To connect to the multiworld server, simply run the **Archipelago AHIT Client** from the Launcher
(or run it from the Launcher if you have the apworld installed) and connect it to the Archipelago server. and connect it to the Archipelago server.
The game will connect to the client automatically when you create a new save file. The game will connect to the client automatically when you create a new save file.
@@ -61,33 +49,8 @@ make sure ***Enable Developer Console*** is checked in Game Settings and press t
## FAQ/Common Issues ## FAQ/Common Issues
### I followed the setup, but I receive an odd error message upon starting the game or creating a save file!
If you receive an error message such as
**"Failed to find default engine .ini to retrieve My Documents subdirectory to use. Force quitting."** or
**"Failed to load map "hub_spaceship"** after booting up the game or creating a save file respectively, then the depot
download was likely corrupted. The only way to fix this is to start the entire download all over again.
Unfortunately, this appears to be an underlying issue with Steam's depot downloader. The only way to really prevent this
from happening is to ensure that your connection is not interrupted or slowed while downloading.
### The game keeps crashing on startup after the splash screen! ### The game is not connecting when starting a new save!
This issue is unfortunately very hard to fix, and the underlying cause is not known. If it does happen however,
try the following:
- Close Steam **entirely**.
- Open the downpatched version of the game (with Steam closed) and allow it to load to the titlescreen.
- Close the game, and then open Steam again.
- After launching the game, the issue should hopefully disappear. If not, repeat the above steps until it does.
### I followed the setup, but "Live Game Events" still shows up in the options menu!
The most common cause of this is the `steam_appid.txt` file. If you're on Windows 10, file extensions are hidden by
default (thanks Microsoft). You likely made the mistake of still naming the file `steam_appid.txt`, which, since file
extensions are hidden, would result in the file being named `steam_appid.txt.txt`, which is incorrect.
To show file extensions in Windows 10, open any folder, click the View tab at the top, and check
"File name extensions". Then you can correct the name of the file. If the name of the file is correct,
and you're still running into the issue, re-read the setup guide again in case you missed a step.
If you still can't get it to work, ask for help in the Discord thread.
### The game is running on the older version, but it's not connecting when starting a new save!
For unknown reasons, the mod will randomly disable itself in the mod menu. To fix this, go to the Mods menu For unknown reasons, the mod will randomly disable itself in the mod menu. To fix this, go to the Mods menu
(rocket icon) in-game, and re-enable the mod. (rocket icon) in-game, and re-enable the mod.

View File

@@ -339,7 +339,7 @@ async def track_locations(ctx, roomid, roomdata) -> bool:
def new_check(location_id): def new_check(location_id):
new_locations.append(location_id) new_locations.append(location_id)
ctx.locations_checked.add(location_id) ctx.locations_checked.add(location_id)
location = ctx.location_names.lookup_in_slot(location_id) location = ctx.location_names.lookup_in_game(location_id)
snes_logger.info( snes_logger.info(
f'New Check: {location} ' + f'New Check: {location} ' +
f'({len(ctx.checked_locations) + 1 if ctx.checked_locations else len(ctx.locations_checked)}/' + f'({len(ctx.checked_locations) + 1 if ctx.checked_locations else len(ctx.locations_checked)}/' +
@@ -552,7 +552,7 @@ class ALTTPSNIClient(SNIClient):
item = ctx.items_received[recv_index] item = ctx.items_received[recv_index]
recv_index += 1 recv_index += 1
logging.info('Received %s from %s (%s) (%d/%d in list)' % ( logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_names.lookup_in_slot(item.item), 'red', 'bold'), color(ctx.item_names.lookup_in_game(item.item), 'red', 'bold'),
color(ctx.player_names[item.player], 'yellow'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_names.lookup_in_slot(item.location, item.player), recv_index, len(ctx.items_received))) ctx.location_names.lookup_in_slot(item.location, item.player), recv_index, len(ctx.items_received)))
@@ -682,7 +682,7 @@ def get_alttp_settings(romfile: str):
if 'yes' in choice: if 'yes' in choice:
import LttPAdjuster import LttPAdjuster
from worlds.alttp.Rom import get_base_rom_path from .Rom import get_base_rom_path
last_settings.rom = romfile last_settings.rom = romfile
last_settings.baserom = get_base_rom_path() last_settings.baserom = get_base_rom_path()
last_settings.world = None last_settings.world = None

View File

@@ -1437,7 +1437,7 @@ def connect_mandatory_exits(world, entrances, caves, must_be_exits, player):
invalid_cave_connections = defaultdict(set) invalid_cave_connections = defaultdict(set)
if world.glitches_required[player] in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']: if world.glitches_required[player] in ['overworld_glitches', 'hybrid_major_glitches', 'no_logic']:
from worlds.alttp import OverworldGlitchRules from . import OverworldGlitchRules
for entrance in OverworldGlitchRules.get_non_mandatory_exits(world.mode[player] == 'inverted'): for entrance in OverworldGlitchRules.get_non_mandatory_exits(world.mode[player] == 'inverted'):
invalid_connections[entrance] = set() invalid_connections[entrance] = set()
if entrance in must_be_exits: if entrance in must_be_exits:

View File

@@ -486,7 +486,7 @@ class LTTPBosses(PlandoBosses):
@classmethod @classmethod
def can_place_boss(cls, boss: str, location: str) -> bool: def can_place_boss(cls, boss: str, location: str) -> bool:
from worlds.alttp.Bosses import can_place_boss from .Bosses import can_place_boss
level = '' level = ''
words = location.split(" ") words = location.split(" ")
if words[-1] in ("top", "middle", "bottom"): if words[-1] in ("top", "middle", "bottom"):

View File

@@ -220,26 +220,7 @@ def get_invalid_bunny_revival_dungeons():
yield 'Sanctuary' yield 'Sanctuary'
def no_logic_rules(world, player):
"""
Add OWG transitions to no logic player's world
"""
create_no_logic_connections(player, world, get_boots_clip_exits_lw(world.mode[player] == 'inverted'))
create_no_logic_connections(player, world, get_boots_clip_exits_dw(world.mode[player] == 'inverted', player))
# Glitched speed drops.
create_no_logic_connections(player, world, get_glitched_speed_drops_dw(world.mode[player] == 'inverted'))
# Mirror clip spots.
if world.mode[player] != 'inverted':
create_no_logic_connections(player, world, get_mirror_clip_spots_dw())
create_no_logic_connections(player, world, get_mirror_offset_spots_dw())
else:
create_no_logic_connections(player, world, get_mirror_offset_spots_lw(player))
def overworld_glitch_connections(world, player): def overworld_glitch_connections(world, player):
# Boots-accessible locations. # Boots-accessible locations.
create_owg_connections(player, world, get_boots_clip_exits_lw(world.mode[player] == 'inverted')) create_owg_connections(player, world, get_boots_clip_exits_lw(world.mode[player] == 'inverted'))
create_owg_connections(player, world, get_boots_clip_exits_dw(world.mode[player] == 'inverted', player)) create_owg_connections(player, world, get_boots_clip_exits_dw(world.mode[player] == 'inverted', player))

View File

@@ -406,7 +406,7 @@ def create_dungeon_region(world: MultiWorld, player: int, name: str, hint: str,
def _create_region(world: MultiWorld, player: int, name: str, type: LTTPRegionType, hint: str, locations=None, def _create_region(world: MultiWorld, player: int, name: str, type: LTTPRegionType, hint: str, locations=None,
exits=None): exits=None):
from worlds.alttp.SubClasses import ALttPLocation from .SubClasses import ALttPLocation
ret = LTTPRegion(name, type, hint, player, world) ret = LTTPRegion(name, type, hint, player, world)
if exits: if exits:
for exit in exits: for exit in exits:
@@ -760,7 +760,7 @@ location_table: typing.Dict[str,
'Turtle Rock - Prize': ( 'Turtle Rock - Prize': (
[0x120A7, 0x53F24, 0x53F25, 0x18005C, 0x180079, 0xC708], None, True, 'Turtle Rock')} [0x120A7, 0x53F24, 0x53F25, 0x18005C, 0x180079, 0xC708], None, True, 'Turtle Rock')}
from worlds.alttp.Shops import shop_table_by_location_id, shop_table_by_location from .Shops import shop_table_by_location_id, shop_table_by_location
lookup_id_to_name = {data[0]: name for name, data in location_table.items() if type(data[0]) == int} lookup_id_to_name = {data[0]: name for name, data in location_table.items() if type(data[0]) == int}
lookup_id_to_name = {**lookup_id_to_name, **{data[1]: name for name, data in key_drop_data.items()}} lookup_id_to_name = {**lookup_id_to_name, **{data[1]: name for name, data in key_drop_data.items()}}
lookup_id_to_name.update(shop_table_by_location_id) lookup_id_to_name.update(shop_table_by_location_id)

View File

@@ -10,7 +10,7 @@ from . import OverworldGlitchRules
from .Bosses import GanonDefeatRule from .Bosses import GanonDefeatRule
from .Items import item_factory, item_name_groups, item_table, progression_items from .Items import item_factory, item_name_groups, item_table, progression_items
from .Options import small_key_shuffle from .Options import small_key_shuffle
from .OverworldGlitchRules import no_logic_rules, overworld_glitches_rules from .OverworldGlitchRules import overworld_glitches_rules
from .Regions import LTTPRegionType, location_table from .Regions import LTTPRegionType, location_table
from .StateHelpers import (can_extend_magic, can_kill_most_things, from .StateHelpers import (can_extend_magic, can_kill_most_things,
can_lift_heavy_rocks, can_lift_rocks, can_lift_heavy_rocks, can_lift_rocks,
@@ -33,7 +33,6 @@ def set_rules(world):
'WARNING! Seeds generated under this logic often require major glitches and may be impossible!') 'WARNING! Seeds generated under this logic often require major glitches and may be impossible!')
if world.players == 1: if world.players == 1:
no_logic_rules(world, player)
for exit in world.get_region('Menu', player).exits: for exit in world.get_region('Menu', player).exits:
exit.hide_path = True exit.hide_path = True
return return
@@ -406,16 +405,14 @@ def global_rules(multiworld: MultiWorld, player: int):
set_rule(multiworld.get_location('Swamp Palace - Waterway Pot Key', player), lambda state: can_use_bombs(state, player)) set_rule(multiworld.get_location('Swamp Palace - Waterway Pot Key', player), lambda state: can_use_bombs(state, player))
set_rule(multiworld.get_entrance('Thieves Town Big Key Door', player), lambda state: state.has('Big Key (Thieves Town)', player)) set_rule(multiworld.get_entrance('Thieves Town Big Key Door', player), lambda state: state.has('Big Key (Thieves Town)', player))
if multiworld.worlds[player].dungeons["Thieves Town"].boss.enemizer_name == "Blind": if multiworld.worlds[player].dungeons["Thieves Town"].boss.enemizer_name == "Blind":
set_rule(multiworld.get_entrance('Blind Fight', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player, 3) and can_use_bombs(state, player)) set_rule(multiworld.get_entrance('Blind Fight', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player, 3) and can_use_bombs(state, player))
set_rule(multiworld.get_location('Thieves\' Town - Big Chest', player), set_rule(multiworld.get_location('Thieves\' Town - Big Chest', player),
lambda state: ((state._lttp_has_key('Small Key (Thieves Town)', player, 3)) or (location_item_name(state, 'Thieves\' Town - Big Chest', player) == ("Small Key (Thieves Town)", player)) and state._lttp_has_key('Small Key (Thieves Town)', player, 2)) and state.has('Hammer', player)) lambda state: ((state._lttp_has_key('Small Key (Thieves Town)', player, 3)) or (location_item_name(state, 'Thieves\' Town - Big Chest', player) == ("Small Key (Thieves Town)", player)) and state._lttp_has_key('Small Key (Thieves Town)', player, 2)) and state.has('Hammer', player))
set_rule(multiworld.get_location('Thieves\' Town - Blind\'s Cell', player),
lambda state: state._lttp_has_key('Small Key (Thieves Town)', player))
if multiworld.accessibility[player] != 'locations' and not multiworld.key_drop_shuffle[player]: if multiworld.accessibility[player] != 'locations' and not multiworld.key_drop_shuffle[player]:
set_always_allow(multiworld.get_location('Thieves\' Town - Big Chest', player), lambda state, item: item.name == 'Small Key (Thieves Town)' and item.player == player) set_always_allow(multiworld.get_location('Thieves\' Town - Big Chest', player), lambda state, item: item.name == 'Small Key (Thieves Town)' and item.player == player)
set_rule(multiworld.get_location('Thieves\' Town - Attic', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player, 3)) set_rule(multiworld.get_location('Thieves\' Town - Attic', player), lambda state: state._lttp_has_key('Small Key (Thieves Town)', player, 3))
set_rule(multiworld.get_location('Thieves\' Town - Spike Switch Pot Key', player), set_rule(multiworld.get_location('Thieves\' Town - Spike Switch Pot Key', player),
lambda state: state._lttp_has_key('Small Key (Thieves Town)', player)) lambda state: state._lttp_has_key('Small Key (Thieves Town)', player))
@@ -491,7 +488,7 @@ def global_rules(multiworld: MultiWorld, player: int):
set_rule(multiworld.get_location('Turtle Rock - Roller Room - Right', player), lambda state: state.has('Cane of Somaria', player) and state.has('Fire Rod', player)) set_rule(multiworld.get_location('Turtle Rock - Roller Room - Right', player), lambda state: state.has('Cane of Somaria', player) and state.has('Fire Rod', player))
set_rule(multiworld.get_location('Turtle Rock - Big Chest', player), lambda state: state.has('Big Key (Turtle Rock)', player) and (state.has('Cane of Somaria', player) or state.has('Hookshot', player))) set_rule(multiworld.get_location('Turtle Rock - Big Chest', player), lambda state: state.has('Big Key (Turtle Rock)', player) and (state.has('Cane of Somaria', player) or state.has('Hookshot', player)))
set_rule(multiworld.get_entrance('Turtle Rock (Big Chest) (North)', player), lambda state: state.has('Cane of Somaria', player) or state.has('Hookshot', player)) set_rule(multiworld.get_entrance('Turtle Rock (Big Chest) (North)', player), lambda state: state.has('Cane of Somaria', player) or state.has('Hookshot', player))
set_rule(multiworld.get_entrance('Turtle Rock Big Key Door', player), lambda state: state.has('Big Key (Turtle Rock)', player) and can_kill_most_things(state, player, 10)) set_rule(multiworld.get_entrance('Turtle Rock Big Key Door', player), lambda state: state.has('Big Key (Turtle Rock)', player) and can_kill_most_things(state, player, 10) and can_bomb_or_bonk(state, player))
set_rule(multiworld.get_location('Turtle Rock - Chain Chomps', player), lambda state: can_use_bombs(state, player) or can_shoot_arrows(state, player) set_rule(multiworld.get_location('Turtle Rock - Chain Chomps', player), lambda state: can_use_bombs(state, player) or can_shoot_arrows(state, player)
or has_beam_sword(state, player) or state.has_any(["Blue Boomerang", "Red Boomerang", "Hookshot", "Cane of Somaria", "Fire Rod", "Ice Rod"], player)) or has_beam_sword(state, player) or state.has_any(["Blue Boomerang", "Red Boomerang", "Hookshot", "Cane of Somaria", "Fire Rod", "Ice Rod"], player))
set_rule(multiworld.get_entrance('Turtle Rock (Dark Room) (North)', player), lambda state: state.has('Cane of Somaria', player)) set_rule(multiworld.get_entrance('Turtle Rock (Dark Room) (North)', player), lambda state: state.has('Cane of Somaria', player))

View File

@@ -37,7 +37,8 @@ class TestThievesTown(TestDungeon):
["Thieves' Town - Blind's Cell", False, []], ["Thieves' Town - Blind's Cell", False, []],
["Thieves' Town - Blind's Cell", False, [], ['Big Key (Thieves Town)']], ["Thieves' Town - Blind's Cell", False, [], ['Big Key (Thieves Town)']],
["Thieves' Town - Blind's Cell", True, ['Big Key (Thieves Town)']], ["Thieves' Town - Blind's Cell", False, [], ['Small Key (Thieves Town)']],
["Thieves' Town - Blind's Cell", True, ['Big Key (Thieves Town)', 'Small Key (Thieves Town)']],
["Thieves' Town - Boss", False, []], ["Thieves' Town - Boss", False, []],
["Thieves' Town - Boss", False, [], ['Big Key (Thieves Town)']], ["Thieves' Town - Boss", False, [], ['Big Key (Thieves Town)']],

View File

@@ -30,7 +30,7 @@ class AquariaLocations:
locations_verse_cave_r = { locations_verse_cave_r = {
"Verse Cave, bulb in the skeleton room": 698107, "Verse Cave, bulb in the skeleton room": 698107,
"Verse Cave, bulb in the path left of the skeleton room": 698108, "Verse Cave, bulb in the path right of the skeleton room": 698108,
"Verse Cave right area, Big Seed": 698175, "Verse Cave right area, Big Seed": 698175,
} }
@@ -122,6 +122,7 @@ class AquariaLocations:
"Open Water top right area, second urn in the Mithalas exit": 698149, "Open Water top right area, second urn in the Mithalas exit": 698149,
"Open Water top right area, third urn in the Mithalas exit": 698150, "Open Water top right area, third urn in the Mithalas exit": 698150,
} }
locations_openwater_tr_turtle = { locations_openwater_tr_turtle = {
"Open Water top right area, bulb in the turtle room": 698009, "Open Water top right area, bulb in the turtle room": 698009,
"Open Water top right area, Transturtle": 698211, "Open Water top right area, Transturtle": 698211,
@@ -195,7 +196,7 @@ class AquariaLocations:
locations_cathedral_l = { locations_cathedral_l = {
"Mithalas City Castle, bulb in the flesh hole": 698042, "Mithalas City Castle, bulb in the flesh hole": 698042,
"Mithalas City Castle, Blue banner": 698165, "Mithalas City Castle, Blue Banner": 698165,
"Mithalas City Castle, urn in the bedroom": 698130, "Mithalas City Castle, urn in the bedroom": 698130,
"Mithalas City Castle, first urn of the single lamp path": 698131, "Mithalas City Castle, first urn of the single lamp path": 698131,
"Mithalas City Castle, second urn of the single lamp path": 698132, "Mithalas City Castle, second urn of the single lamp path": 698132,
@@ -226,7 +227,7 @@ class AquariaLocations:
"Mithalas Cathedral, third urn in the path behind the flesh vein": 698146, "Mithalas Cathedral, third urn in the path behind the flesh vein": 698146,
"Mithalas Cathedral, fourth urn in the top right room": 698147, "Mithalas Cathedral, fourth urn in the top right room": 698147,
"Mithalas Cathedral, Mithalan Dress": 698189, "Mithalas Cathedral, Mithalan Dress": 698189,
"Mithalas Cathedral right area, urn below the left entrance": 698198, "Mithalas Cathedral, urn below the left entrance": 698198,
} }
locations_cathedral_underground = { locations_cathedral_underground = {
@@ -239,7 +240,7 @@ class AquariaLocations:
} }
locations_cathedral_boss = { locations_cathedral_boss = {
"Cathedral boss area, beating Mithalan God": 698202, "Mithalas boss area, beating Mithalan God": 698202,
} }
locations_forest_tl = { locations_forest_tl = {
@@ -269,7 +270,7 @@ class AquariaLocations:
locations_forest_bl = { locations_forest_bl = {
"Kelp Forest bottom left area, bulb close to the spirit crystals": 698054, "Kelp Forest bottom left area, bulb close to the spirit crystals": 698054,
"Kelp Forest bottom left area, Walker baby": 698186, "Kelp Forest bottom left area, Walker Baby": 698186,
"Kelp Forest bottom left area, Transturtle": 698212, "Kelp Forest bottom left area, Transturtle": 698212,
} }
@@ -451,7 +452,7 @@ class AquariaLocations:
locations_body_c = { locations_body_c = {
"The Body center area, breaking Li's cage": 698201, "The Body center area, breaking Li's cage": 698201,
"The Body main area, bulb on the main path blocking tube": 698097, "The Body center area, bulb on the main path blocking tube": 698097,
} }
locations_body_l = { locations_body_l = {

View File

@@ -5,7 +5,7 @@ Description: Manage options in the Aquaria game multiworld randomizer
""" """
from dataclasses import dataclass from dataclasses import dataclass
from Options import Toggle, Choice, Range, DeathLink, PerGameCommonOptions, DefaultOnToggle, StartInventoryPool from Options import Toggle, Choice, Range, PerGameCommonOptions, DefaultOnToggle, StartInventoryPool
class IngredientRandomizer(Choice): class IngredientRandomizer(Choice):
@@ -111,6 +111,14 @@ class BindSongNeededToGetUnderRockBulb(Toggle):
display_name = "Bind song needed to get sing bulbs under rocks" display_name = "Bind song needed to get sing bulbs under rocks"
class BlindGoal(Toggle):
"""
Hide the goal's requirements from the help page so that you have to go to the last boss door to know
what is needed to access the boss.
"""
display_name = "Hide the goal's requirements"
class UnconfineHomeWater(Choice): class UnconfineHomeWater(Choice):
""" """
Open the way out of the Home Water area so that Naija can go to open water and beyond without the bind song. Open the way out of the Home Water area so that Naija can go to open water and beyond without the bind song.
@@ -142,4 +150,4 @@ class AquariaOptions(PerGameCommonOptions):
dish_randomizer: DishRandomizer dish_randomizer: DishRandomizer
aquarian_translation: AquarianTranslation aquarian_translation: AquarianTranslation
skip_first_vision: SkipFirstVision skip_first_vision: SkipFirstVision
death_link: DeathLink blind_goal: BlindGoal

View File

@@ -300,7 +300,7 @@ class AquariaRegions:
AquariaLocations.locations_cathedral_l_sc) AquariaLocations.locations_cathedral_l_sc)
self.cathedral_r = self.__add_region("Mithalas Cathedral", self.cathedral_r = self.__add_region("Mithalas Cathedral",
AquariaLocations.locations_cathedral_r) AquariaLocations.locations_cathedral_r)
self.cathedral_underground = self.__add_region("Mithalas Cathedral Underground area", self.cathedral_underground = self.__add_region("Mithalas Cathedral underground",
AquariaLocations.locations_cathedral_underground) AquariaLocations.locations_cathedral_underground)
self.cathedral_boss_r = self.__add_region("Mithalas Cathedral, Mithalan God room", self.cathedral_boss_r = self.__add_region("Mithalas Cathedral, Mithalan God room",
AquariaLocations.locations_cathedral_boss) AquariaLocations.locations_cathedral_boss)
@@ -597,22 +597,22 @@ class AquariaRegions:
lambda state: _has_beast_form(state, self.player) and lambda state: _has_beast_form(state, self.player) and
_has_energy_form(state, self.player) and _has_energy_form(state, self.player) and
_has_bind_song(state, self.player)) _has_bind_song(state, self.player))
self.__connect_regions("Mithalas castle", "Cathedral underground", self.__connect_regions("Mithalas castle", "Mithalas Cathedral underground",
self.cathedral_l, self.cathedral_underground, self.cathedral_l, self.cathedral_underground,
lambda state: _has_beast_form(state, self.player) and lambda state: _has_beast_form(state, self.player) and
_has_bind_song(state, self.player)) _has_bind_song(state, self.player))
self.__connect_regions("Mithalas castle", "Cathedral right area", self.__connect_regions("Mithalas castle", "Mithalas Cathedral",
self.cathedral_l, self.cathedral_r, self.cathedral_l, self.cathedral_r,
lambda state: _has_bind_song(state, self.player) and lambda state: _has_bind_song(state, self.player) and
_has_energy_form(state, self.player)) _has_energy_form(state, self.player))
self.__connect_regions("Cathedral right area", "Cathedral underground", self.__connect_regions("Mithalas Cathedral", "Mithalas Cathedral underground",
self.cathedral_r, self.cathedral_underground, self.cathedral_r, self.cathedral_underground,
lambda state: _has_energy_form(state, self.player)) lambda state: _has_energy_form(state, self.player))
self.__connect_one_way_regions("Cathedral underground", "Cathedral boss left area", self.__connect_one_way_regions("Mithalas Cathedral underground", "Cathedral boss left area",
self.cathedral_underground, self.cathedral_boss_r, self.cathedral_underground, self.cathedral_boss_r,
lambda state: _has_energy_form(state, self.player) and lambda state: _has_energy_form(state, self.player) and
_has_bind_song(state, self.player)) _has_bind_song(state, self.player))
self.__connect_one_way_regions("Cathedral boss left area", "Cathedral underground", self.__connect_one_way_regions("Cathedral boss left area", "Mithalas Cathedral underground",
self.cathedral_boss_r, self.cathedral_underground, self.cathedral_boss_r, self.cathedral_underground,
lambda state: _has_beast_form(state, self.player)) lambda state: _has_beast_form(state, self.player))
self.__connect_regions("Cathedral boss right area", "Cathedral boss left area", self.__connect_regions("Cathedral boss right area", "Cathedral boss left area",
@@ -1099,7 +1099,7 @@ class AquariaRegions:
lambda state: _has_beast_form(state, self.player)) lambda state: _has_beast_form(state, self.player))
add_rule(self.multiworld.get_location("Open Water bottom left area, bulb inside the lowest fish pass", self.player), add_rule(self.multiworld.get_location("Open Water bottom left area, bulb inside the lowest fish pass", self.player),
lambda state: _has_fish_form(state, self.player)) lambda state: _has_fish_form(state, self.player))
add_rule(self.multiworld.get_location("Kelp Forest bottom left area, Walker baby", self.player), add_rule(self.multiworld.get_location("Kelp Forest bottom left area, Walker Baby", self.player),
lambda state: _has_spirit_form(state, self.player)) lambda state: _has_spirit_form(state, self.player))
add_rule(self.multiworld.get_location("The Veil top left area, bulb hidden behind the blocking rock", self.player), add_rule(self.multiworld.get_location("The Veil top left area, bulb hidden behind the blocking rock", self.player),
lambda state: _has_bind_song(state, self.player)) lambda state: _has_bind_song(state, self.player))
@@ -1134,7 +1134,7 @@ class AquariaRegions:
self.multiworld.get_location("Energy Temple boss area, Fallen God Tooth", self.multiworld.get_location("Energy Temple boss area, Fallen God Tooth",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Cathedral boss area, beating Mithalan God", self.multiworld.get_location("Mithalas boss area, beating Mithalan God",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Kelp Forest boss area, beating Drunian God", self.multiworld.get_location("Kelp Forest boss area, beating Drunian God",
@@ -1191,7 +1191,7 @@ class AquariaRegions:
self.multiworld.get_location("Kelp Forest bottom left area, bulb close to the spirit crystals", self.multiworld.get_location("Kelp Forest bottom left area, bulb close to the spirit crystals",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Kelp Forest bottom left area, Walker baby", self.multiworld.get_location("Kelp Forest bottom left area, Walker Baby",
self.player).item_rule =\ self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Sun Temple, Sun Key", self.multiworld.get_location("Sun Temple, Sun Key",

View File

@@ -204,7 +204,8 @@ class AquariaWorld(World):
def fill_slot_data(self) -> Dict[str, Any]: def fill_slot_data(self) -> Dict[str, Any]:
return {"ingredientReplacement": self.ingredients_substitution, return {"ingredientReplacement": self.ingredients_substitution,
"aquarianTranslate": bool(self.options.aquarian_translation.value), "aquarian_translate": bool(self.options.aquarian_translation.value),
"blind_goal": bool(self.options.blind_goal.value),
"secret_needed": self.options.objective.value > 0, "secret_needed": self.options.objective.value > 0,
"minibosses_to_kill": self.options.mini_bosses_to_beat.value, "minibosses_to_kill": self.options.mini_bosses_to_beat.value,
"bigbosses_to_kill": self.options.big_bosses_to_beat.value, "bigbosses_to_kill": self.options.big_bosses_to_beat.value,

View File

@@ -60,7 +60,7 @@ after_home_water_locations = [
"Mithalas City, Doll", "Mithalas City, Doll",
"Mithalas City, urn inside a home fish pass", "Mithalas City, urn inside a home fish pass",
"Mithalas City Castle, bulb in the flesh hole", "Mithalas City Castle, bulb in the flesh hole",
"Mithalas City Castle, Blue banner", "Mithalas City Castle, Blue Banner",
"Mithalas City Castle, urn in the bedroom", "Mithalas City Castle, urn in the bedroom",
"Mithalas City Castle, first urn of the single lamp path", "Mithalas City Castle, first urn of the single lamp path",
"Mithalas City Castle, second urn of the single lamp path", "Mithalas City Castle, second urn of the single lamp path",
@@ -82,14 +82,14 @@ after_home_water_locations = [
"Mithalas Cathedral, third urn in the path behind the flesh vein", "Mithalas Cathedral, third urn in the path behind the flesh vein",
"Mithalas Cathedral, fourth urn in the top right room", "Mithalas Cathedral, fourth urn in the top right room",
"Mithalas Cathedral, Mithalan Dress", "Mithalas Cathedral, Mithalan Dress",
"Mithalas Cathedral right area, urn below the left entrance", "Mithalas Cathedral, urn below the left entrance",
"Cathedral Underground, bulb in the center part", "Cathedral Underground, bulb in the center part",
"Cathedral Underground, first bulb in the top left part", "Cathedral Underground, first bulb in the top left part",
"Cathedral Underground, second bulb in the top left part", "Cathedral Underground, second bulb in the top left part",
"Cathedral Underground, third bulb in the top left part", "Cathedral Underground, third bulb in the top left part",
"Cathedral Underground, bulb close to the save crystal", "Cathedral Underground, bulb close to the save crystal",
"Cathedral Underground, bulb in the bottom right path", "Cathedral Underground, bulb in the bottom right path",
"Cathedral boss area, beating Mithalan God", "Mithalas boss area, beating Mithalan God",
"Kelp Forest top left area, bulb in the bottom left clearing", "Kelp Forest top left area, bulb in the bottom left clearing",
"Kelp Forest top left area, bulb in the path down from the top left clearing", "Kelp Forest top left area, bulb in the path down from the top left clearing",
"Kelp Forest top left area, bulb in the top left clearing", "Kelp Forest top left area, bulb in the top left clearing",
@@ -104,7 +104,7 @@ after_home_water_locations = [
"Kelp Forest top right area, Black Pearl", "Kelp Forest top right area, Black Pearl",
"Kelp Forest top right area, bulb in the top fish pass", "Kelp Forest top right area, bulb in the top fish pass",
"Kelp Forest bottom left area, bulb close to the spirit crystals", "Kelp Forest bottom left area, bulb close to the spirit crystals",
"Kelp Forest bottom left area, Walker baby", "Kelp Forest bottom left area, Walker Baby",
"Kelp Forest bottom left area, Transturtle", "Kelp Forest bottom left area, Transturtle",
"Kelp Forest bottom right area, Odd Container", "Kelp Forest bottom right area, Odd Container",
"Kelp Forest boss area, beating Drunian God", "Kelp Forest boss area, beating Drunian God",
@@ -175,7 +175,7 @@ after_home_water_locations = [
"Sunken City left area, Girl Costume", "Sunken City left area, Girl Costume",
"Sunken City, bulb on top of the boss area", "Sunken City, bulb on top of the boss area",
"The Body center area, breaking Li's cage", "The Body center area, breaking Li's cage",
"The Body main area, bulb on the main path blocking tube", "The Body center area, bulb on the main path blocking tube",
"The Body left area, first bulb in the top face room", "The Body left area, first bulb in the top face room",
"The Body left area, second bulb in the top face room", "The Body left area, second bulb in the top face room",
"The Body left area, bulb below the water stream", "The Body left area, bulb below the water stream",

View File

@@ -4,7 +4,7 @@ Date: Thu, 18 Apr 2024 18:45:56 +0000
Description: Unit test used to test accessibility of locations with and without the beast form Description: Unit test used to test accessibility of locations with and without the beast form
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
class BeastFormAccessTest(AquariaTestBase): class BeastFormAccessTest(AquariaTestBase):

View File

@@ -5,7 +5,7 @@ Description: Unit test used to test accessibility of locations with and without
under rock needing bind song option) under rock needing bind song option)
""" """
from worlds.aquaria.test import AquariaTestBase, after_home_water_locations from . import AquariaTestBase, after_home_water_locations
class BindSongAccessTest(AquariaTestBase): class BindSongAccessTest(AquariaTestBase):

View File

@@ -5,8 +5,8 @@ Description: Unit test used to test accessibility of locations with and without
under rock needing bind song option) under rock needing bind song option)
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
from worlds.aquaria.test.test_bind_song_access import after_home_water_locations from .test_bind_song_access import after_home_water_locations
class BindSongOptionAccessTest(AquariaTestBase): class BindSongOptionAccessTest(AquariaTestBase):

View File

@@ -4,7 +4,7 @@ Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test accessibility of region with the home water confine via option Description: Unit test used to test accessibility of region with the home water confine via option
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
class ConfinedHomeWaterAccessTest(AquariaTestBase): class ConfinedHomeWaterAccessTest(AquariaTestBase):

View File

@@ -4,7 +4,7 @@ Date: Thu, 18 Apr 2024 18:45:56 +0000
Description: Unit test used to test accessibility of locations with and without the dual song Description: Unit test used to test accessibility of locations with and without the dual song
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
class LiAccessTest(AquariaTestBase): class LiAccessTest(AquariaTestBase):

View File

@@ -5,7 +5,7 @@ Description: Unit test used to test accessibility of locations with and without
energy form option) energy form option)
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
class EnergyFormAccessTest(AquariaTestBase): class EnergyFormAccessTest(AquariaTestBase):
@@ -39,8 +39,8 @@ class EnergyFormAccessTest(AquariaTestBase):
"Mithalas Cathedral, third urn in the path behind the flesh vein", "Mithalas Cathedral, third urn in the path behind the flesh vein",
"Mithalas Cathedral, fourth urn in the top right room", "Mithalas Cathedral, fourth urn in the top right room",
"Mithalas Cathedral, Mithalan Dress", "Mithalas Cathedral, Mithalan Dress",
"Mithalas Cathedral right area, urn below the left entrance", "Mithalas Cathedral, urn below the left entrance",
"Cathedral boss area, beating Mithalan God", "Mithalas boss area, beating Mithalan God",
"Kelp Forest top left area, bulb close to the Verse Egg", "Kelp Forest top left area, bulb close to the Verse Egg",
"Kelp Forest top left area, Verse Egg", "Kelp Forest top left area, Verse Egg",
"Kelp Forest boss area, beating Drunian God", "Kelp Forest boss area, beating Drunian God",

View File

@@ -4,7 +4,7 @@ Date: Thu, 18 Apr 2024 18:45:56 +0000
Description: Unit test used to test accessibility of locations with and without the fish form Description: Unit test used to test accessibility of locations with and without the fish form
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
class FishFormAccessTest(AquariaTestBase): class FishFormAccessTest(AquariaTestBase):

View File

@@ -4,7 +4,7 @@ Date: Thu, 18 Apr 2024 18:45:56 +0000
Description: Unit test used to test accessibility of locations with and without Li Description: Unit test used to test accessibility of locations with and without Li
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
class LiAccessTest(AquariaTestBase): class LiAccessTest(AquariaTestBase):
@@ -24,7 +24,7 @@ class LiAccessTest(AquariaTestBase):
"Sunken City left area, Girl Costume", "Sunken City left area, Girl Costume",
"Sunken City, bulb on top of the boss area", "Sunken City, bulb on top of the boss area",
"The Body center area, breaking Li's cage", "The Body center area, breaking Li's cage",
"The Body main area, bulb on the main path blocking tube", "The Body center area, bulb on the main path blocking tube",
"The Body left area, first bulb in the top face room", "The Body left area, first bulb in the top face room",
"The Body left area, second bulb in the top face room", "The Body left area, second bulb in the top face room",
"The Body left area, bulb below the water stream", "The Body left area, bulb below the water stream",

View File

@@ -4,7 +4,7 @@ Date: Thu, 18 Apr 2024 18:45:56 +0000
Description: Unit test used to test accessibility of locations with and without a light (Dumbo pet or sun form) Description: Unit test used to test accessibility of locations with and without a light (Dumbo pet or sun form)
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
class LightAccessTest(AquariaTestBase): class LightAccessTest(AquariaTestBase):

View File

@@ -4,7 +4,7 @@ Date: Thu, 18 Apr 2024 18:45:56 +0000
Description: Unit test used to test accessibility of locations with and without the nature form Description: Unit test used to test accessibility of locations with and without the nature form
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
class NatureFormAccessTest(AquariaTestBase): class NatureFormAccessTest(AquariaTestBase):
@@ -38,7 +38,7 @@ class NatureFormAccessTest(AquariaTestBase):
"Beating the Golem", "Beating the Golem",
"Sunken City cleared", "Sunken City cleared",
"The Body center area, breaking Li's cage", "The Body center area, breaking Li's cage",
"The Body main area, bulb on the main path blocking tube", "The Body center area, bulb on the main path blocking tube",
"The Body left area, first bulb in the top face room", "The Body left area, first bulb in the top face room",
"The Body left area, second bulb in the top face room", "The Body left area, second bulb in the top face room",
"The Body left area, bulb below the water stream", "The Body left area, bulb below the water stream",

View File

@@ -4,7 +4,7 @@ Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that no progression items can be put in hard or hidden locations when option enabled Description: Unit test used to test that no progression items can be put in hard or hidden locations when option enabled
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
from BaseClasses import ItemClassification from BaseClasses import ItemClassification
@@ -16,7 +16,7 @@ class UNoProgressionHardHiddenTest(AquariaTestBase):
unfillable_locations = [ unfillable_locations = [
"Energy Temple boss area, Fallen God Tooth", "Energy Temple boss area, Fallen God Tooth",
"Cathedral boss area, beating Mithalan God", "Mithalas boss area, beating Mithalan God",
"Kelp Forest boss area, beating Drunian God", "Kelp Forest boss area, beating Drunian God",
"Sun Temple boss area, beating Sun God", "Sun Temple boss area, beating Sun God",
"Sunken City, bulb on top of the boss area", "Sunken City, bulb on top of the boss area",
@@ -35,7 +35,7 @@ class UNoProgressionHardHiddenTest(AquariaTestBase):
"Bubble Cave, bulb in the right cave wall (behind the ice crystal)", "Bubble Cave, bulb in the right cave wall (behind the ice crystal)",
"Bubble Cave, Verse Egg", "Bubble Cave, Verse Egg",
"Kelp Forest bottom left area, bulb close to the spirit crystals", "Kelp Forest bottom left area, bulb close to the spirit crystals",
"Kelp Forest bottom left area, Walker baby", "Kelp Forest bottom left area, Walker Baby",
"Sun Temple, Sun Key", "Sun Temple, Sun Key",
"The Body bottom area, Mutant Costume", "The Body bottom area, Mutant Costume",
"Sun Temple, bulb in the hidden room of the right part", "Sun Temple, bulb in the hidden room of the right part",

View File

@@ -4,8 +4,7 @@ Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test that progression items can be put in hard or hidden locations when option disabled Description: Unit test used to test that progression items can be put in hard or hidden locations when option disabled
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
from BaseClasses import ItemClassification
class UNoProgressionHardHiddenTest(AquariaTestBase): class UNoProgressionHardHiddenTest(AquariaTestBase):
@@ -16,7 +15,7 @@ class UNoProgressionHardHiddenTest(AquariaTestBase):
unfillable_locations = [ unfillable_locations = [
"Energy Temple boss area, Fallen God Tooth", "Energy Temple boss area, Fallen God Tooth",
"Cathedral boss area, beating Mithalan God", "Mithalas boss area, beating Mithalan God",
"Kelp Forest boss area, beating Drunian God", "Kelp Forest boss area, beating Drunian God",
"Sun Temple boss area, beating Sun God", "Sun Temple boss area, beating Sun God",
"Sunken City, bulb on top of the boss area", "Sunken City, bulb on top of the boss area",
@@ -35,7 +34,7 @@ class UNoProgressionHardHiddenTest(AquariaTestBase):
"Bubble Cave, bulb in the right cave wall (behind the ice crystal)", "Bubble Cave, bulb in the right cave wall (behind the ice crystal)",
"Bubble Cave, Verse Egg", "Bubble Cave, Verse Egg",
"Kelp Forest bottom left area, bulb close to the spirit crystals", "Kelp Forest bottom left area, bulb close to the spirit crystals",
"Kelp Forest bottom left area, Walker baby", "Kelp Forest bottom left area, Walker Baby",
"Sun Temple, Sun Key", "Sun Temple, Sun Key",
"The Body bottom area, Mutant Costume", "The Body bottom area, Mutant Costume",
"Sun Temple, bulb in the hidden room of the right part", "Sun Temple, bulb in the hidden room of the right part",

View File

@@ -4,7 +4,7 @@ Date: Thu, 18 Apr 2024 18:45:56 +0000
Description: Unit test used to test accessibility of locations with and without the spirit form Description: Unit test used to test accessibility of locations with and without the spirit form
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
class SpiritFormAccessTest(AquariaTestBase): class SpiritFormAccessTest(AquariaTestBase):
@@ -16,7 +16,7 @@ class SpiritFormAccessTest(AquariaTestBase):
"The Veil bottom area, bulb in the spirit path", "The Veil bottom area, bulb in the spirit path",
"Mithalas City Castle, Trident Head", "Mithalas City Castle, Trident Head",
"Open Water skeleton path, King Skull", "Open Water skeleton path, King Skull",
"Kelp Forest bottom left area, Walker baby", "Kelp Forest bottom left area, Walker Baby",
"Abyss right area, bulb behind the rock in the whale room", "Abyss right area, bulb behind the rock in the whale room",
"The Whale, Verse Egg", "The Whale, Verse Egg",
"Ice Cave, bulb in the room to the right", "Ice Cave, bulb in the room to the right",

View File

@@ -4,7 +4,7 @@ Date: Thu, 18 Apr 2024 18:45:56 +0000
Description: Unit test used to test accessibility of locations with and without the sun form Description: Unit test used to test accessibility of locations with and without the sun form
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
class SunFormAccessTest(AquariaTestBase): class SunFormAccessTest(AquariaTestBase):

View File

@@ -5,7 +5,7 @@ Description: Unit test used to test accessibility of region with the unconfined
turtle and energy door turtle and energy door
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
class UnconfineHomeWaterBothAccessTest(AquariaTestBase): class UnconfineHomeWaterBothAccessTest(AquariaTestBase):

View File

@@ -4,7 +4,7 @@ Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test accessibility of region with the unconfined home water option via the energy door Description: Unit test used to test accessibility of region with the unconfined home water option via the energy door
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
class UnconfineHomeWaterEnergyDoorAccessTest(AquariaTestBase): class UnconfineHomeWaterEnergyDoorAccessTest(AquariaTestBase):

View File

@@ -4,7 +4,7 @@ Date: Fri, 03 May 2024 14:07:35 +0000
Description: Unit test used to test accessibility of region with the unconfined home water option via transturtle Description: Unit test used to test accessibility of region with the unconfined home water option via transturtle
""" """
from worlds.aquaria.test import AquariaTestBase from . import AquariaTestBase
class UnconfineHomeWaterTransturtleAccessTest(AquariaTestBase): class UnconfineHomeWaterTransturtleAccessTest(AquariaTestBase):

View File

@@ -762,7 +762,7 @@ location_table: List[LocationDict] = [
'game_id': "graf385"}, 'game_id': "graf385"},
{'name': "Tagged 389 Graffiti Spots", {'name': "Tagged 389 Graffiti Spots",
'stage': Stages.Misc, 'stage': Stages.Misc,
'game_id': "graf379"}, 'game_id': "graf389"},
] ]

View File

@@ -146,7 +146,7 @@ class Castlevania64Client(BizHawkClient):
text_color = bytearray([0xA2, 0x0B]) text_color = bytearray([0xA2, 0x0B])
else: else:
text_color = bytearray([0xA2, 0x02]) text_color = bytearray([0xA2, 0x02])
received_text, num_lines = cv64_text_wrap(f"{ctx.item_names.lookup_in_slot(next_item.item)}\n" received_text, num_lines = cv64_text_wrap(f"{ctx.item_names.lookup_in_game(next_item.item)}\n"
f"from {ctx.player_names[next_item.player]}", 96) f"from {ctx.player_names[next_item.player]}", 96)
await bizhawk.guarded_write(ctx.bizhawk_ctx, await bizhawk.guarded_write(ctx.bizhawk_ctx,
[(0x389BE1, [next_item.item & 0xFF], "RDRAM"), [(0x389BE1, [next_item.item & 0xFF], "RDRAM"),

View File

@@ -60,7 +60,7 @@ class DKC3SNIClient(SNIClient):
return return
new_checks = [] new_checks = []
from worlds.dkc3.Rom import location_rom_data, item_rom_data, boss_location_ids, level_unlock_map from .Rom import location_rom_data, item_rom_data, boss_location_ids, level_unlock_map
location_ram_data = await snes_read(ctx, WRAM_START + 0x5FE, 0x81) location_ram_data = await snes_read(ctx, WRAM_START + 0x5FE, 0x81)
for loc_id, loc_data in location_rom_data.items(): for loc_id, loc_data in location_rom_data.items():
if loc_id not in ctx.locations_checked: if loc_id not in ctx.locations_checked:
@@ -86,7 +86,7 @@ class DKC3SNIClient(SNIClient):
for new_check_id in new_checks: for new_check_id in new_checks:
ctx.locations_checked.add(new_check_id) ctx.locations_checked.add(new_check_id)
location = ctx.location_names.lookup_in_slot(new_check_id) location = ctx.location_names.lookup_in_game(new_check_id)
snes_logger.info( snes_logger.info(
f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})')
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [new_check_id]}]) await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [new_check_id]}])
@@ -99,7 +99,7 @@ class DKC3SNIClient(SNIClient):
item = ctx.items_received[recv_index] item = ctx.items_received[recv_index]
recv_index += 1 recv_index += 1
logging.info('Received %s from %s (%s) (%d/%d in list)' % ( logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_names.lookup_in_slot(item.item), 'red', 'bold'), color(ctx.item_names.lookup_in_game(item.item), 'red', 'bold'),
color(ctx.player_names[item.player], 'yellow'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_names.lookup_in_slot(item.location, item.player), recv_index, len(ctx.items_received))) ctx.location_names.lookup_in_slot(item.location, item.player), recv_index, len(ctx.items_received)))

View File

@@ -8,11 +8,15 @@ from .Locations import DLCQuestLocation, location_table
from .Options import DLCQuestOptions from .Options import DLCQuestOptions
from .Regions import create_regions from .Regions import create_regions
from .Rules import set_rules from .Rules import set_rules
from .presets import dlcq_options_presets
from .option_groups import dlcq_option_groups
client_version = 0 client_version = 0
class DLCqwebworld(WebWorld): class DLCqwebworld(WebWorld):
options_presets = dlcq_options_presets
option_groups = dlcq_option_groups
setup_en = Tutorial( setup_en = Tutorial(
"Multiworld Setup Guide", "Multiworld Setup Guide",
"A guide to setting up the Archipelago DLCQuest game on your computer.", "A guide to setting up the Archipelago DLCQuest game on your computer.",

View File

@@ -0,0 +1,27 @@
from typing import List
from Options import ProgressionBalancing, Accessibility, OptionGroup
from .Options import (Campaign, ItemShuffle, TimeIsMoney, EndingChoice, PermanentCoins, DoubleJumpGlitch, CoinSanity,
CoinSanityRange, DeathLink)
dlcq_option_groups: List[OptionGroup] = [
OptionGroup("General", [
Campaign,
ItemShuffle,
CoinSanity,
]),
OptionGroup("Customization", [
EndingChoice,
PermanentCoins,
CoinSanityRange,
]),
OptionGroup("Tedious and Grind", [
TimeIsMoney,
DoubleJumpGlitch,
]),
OptionGroup("Advanced Options", [
DeathLink,
ProgressionBalancing,
Accessibility,
]),
]

View File

@@ -0,0 +1,68 @@
from typing import Any, Dict
from .Options import DoubleJumpGlitch, CoinSanity, CoinSanityRange, PermanentCoins, TimeIsMoney, EndingChoice, Campaign, ItemShuffle
all_random_settings = {
DoubleJumpGlitch.internal_name: "random",
CoinSanity.internal_name: "random",
CoinSanityRange.internal_name: "random",
PermanentCoins.internal_name: "random",
TimeIsMoney.internal_name: "random",
EndingChoice.internal_name: "random",
Campaign.internal_name: "random",
ItemShuffle.internal_name: "random",
"death_link": "random",
}
main_campaign_settings = {
DoubleJumpGlitch.internal_name: DoubleJumpGlitch.option_none,
CoinSanity.internal_name: CoinSanity.option_coin,
CoinSanityRange.internal_name: 30,
PermanentCoins.internal_name: PermanentCoins.option_false,
TimeIsMoney.internal_name: TimeIsMoney.option_required,
EndingChoice.internal_name: EndingChoice.option_true,
Campaign.internal_name: Campaign.option_basic,
ItemShuffle.internal_name: ItemShuffle.option_shuffled,
}
lfod_campaign_settings = {
DoubleJumpGlitch.internal_name: DoubleJumpGlitch.option_none,
CoinSanity.internal_name: CoinSanity.option_coin,
CoinSanityRange.internal_name: 30,
PermanentCoins.internal_name: PermanentCoins.option_false,
TimeIsMoney.internal_name: TimeIsMoney.option_required,
EndingChoice.internal_name: EndingChoice.option_true,
Campaign.internal_name: Campaign.option_live_freemium_or_die,
ItemShuffle.internal_name: ItemShuffle.option_shuffled,
}
easy_settings = {
DoubleJumpGlitch.internal_name: DoubleJumpGlitch.option_none,
CoinSanity.internal_name: CoinSanity.option_none,
CoinSanityRange.internal_name: 40,
PermanentCoins.internal_name: PermanentCoins.option_true,
TimeIsMoney.internal_name: TimeIsMoney.option_required,
EndingChoice.internal_name: EndingChoice.option_true,
Campaign.internal_name: Campaign.option_both,
ItemShuffle.internal_name: ItemShuffle.option_shuffled,
}
hard_settings = {
DoubleJumpGlitch.internal_name: DoubleJumpGlitch.option_simple,
CoinSanity.internal_name: CoinSanity.option_coin,
CoinSanityRange.internal_name: 30,
PermanentCoins.internal_name: PermanentCoins.option_false,
TimeIsMoney.internal_name: TimeIsMoney.option_optional,
EndingChoice.internal_name: EndingChoice.option_true,
Campaign.internal_name: Campaign.option_both,
ItemShuffle.internal_name: ItemShuffle.option_shuffled,
}
dlcq_options_presets: Dict[str, Dict[str, Any]] = {
"All random": all_random_settings,
"Main campaign": main_campaign_settings,
"LFOD campaign": lfod_campaign_settings,
"Both easy": easy_settings,
"Both hard": hard_settings,
}

View File

@@ -60,17 +60,18 @@ class DOOM2World(World):
# Item ratio that scales depending on episode count. These are the ratio for 3 episode. In DOOM1. # Item ratio that scales depending on episode count. These are the ratio for 3 episode. In DOOM1.
# The ratio have been tweaked seem, and feel good. # The ratio have been tweaked seem, and feel good.
items_ratio: Dict[str, float] = { items_ratio: Dict[str, float] = {
"Armor": 41, "Armor": 39,
"Mega Armor": 25, "Mega Armor": 23,
"Berserk": 12, "Berserk": 11,
"Invulnerability": 10, "Invulnerability": 10,
"Partial invisibility": 18, "Partial invisibility": 18,
"Supercharge": 28, "Supercharge": 26,
"Medikit": 15, "Medikit": 15,
"Box of bullets": 13, "Box of bullets": 13,
"Box of rockets": 13, "Box of rockets": 13,
"Box of shotgun shells": 13, "Box of shotgun shells": 13,
"Energy cell pack": 10 "Energy cell pack": 10,
"Megasphere": 7
} }
def __init__(self, multiworld: MultiWorld, player: int): def __init__(self, multiworld: MultiWorld, player: int):
@@ -233,6 +234,7 @@ class DOOM2World(World):
self.create_ratioed_items("Invulnerability", itempool) self.create_ratioed_items("Invulnerability", itempool)
self.create_ratioed_items("Partial invisibility", itempool) self.create_ratioed_items("Partial invisibility", itempool)
self.create_ratioed_items("Supercharge", itempool) self.create_ratioed_items("Supercharge", itempool)
self.create_ratioed_items("Megasphere", itempool)
while len(itempool) < self.location_count: while len(itempool) < self.location_count:
itempool.append(self.create_item(self.get_filler_item_name())) itempool.append(self.create_item(self.get_filler_item_name()))

View File

@@ -247,7 +247,7 @@ async def game_watcher(ctx: FactorioContext):
if ctx.locations_checked != research_data: if ctx.locations_checked != research_data:
bridge_logger.debug( bridge_logger.debug(
f"New researches done: " f"New researches done: "
f"{[ctx.location_names.lookup_in_slot(rid) for rid in research_data - ctx.locations_checked]}") f"{[ctx.location_names.lookup_in_game(rid) for rid in research_data - ctx.locations_checked]}")
ctx.locations_checked = research_data ctx.locations_checked = research_data
await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}]) await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}])
death_link_tick = data.get("death_link_tick", 0) death_link_tick = data.get("death_link_tick", 0)
@@ -360,7 +360,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
transfer_item: NetworkItem = ctx.items_received[ctx.send_index] transfer_item: NetworkItem = ctx.items_received[ctx.send_index]
item_id = transfer_item.item item_id = transfer_item.item
player_name = ctx.player_names[transfer_item.player] player_name = ctx.player_names[transfer_item.player]
item_name = ctx.item_names.lookup_in_slot(item_id) item_name = ctx.item_names.lookup_in_game(item_id)
factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.") factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.")
commands[ctx.send_index] = f"/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}" commands[ctx.send_index] = f"/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}"
ctx.send_index += 1 ctx.send_index += 1

View File

@@ -71,7 +71,7 @@ class FFMQClient(SNIClient):
received = await snes_read(ctx, RECEIVED_DATA[0], RECEIVED_DATA[1]) received = await snes_read(ctx, RECEIVED_DATA[0], RECEIVED_DATA[1])
data = await snes_read(ctx, READ_DATA_START, READ_DATA_END - READ_DATA_START) data = await snes_read(ctx, READ_DATA_START, READ_DATA_END - READ_DATA_START)
check_2 = await snes_read(ctx, 0xF53749, 1) check_2 = await snes_read(ctx, 0xF53749, 1)
if check_1 in (b'\x00', b'\x55') or check_2 in (b'\x00', b'\x55'): if check_1 != b'01' or check_2 != b'01':
return return
def get_range(data_range): def get_range(data_range):

View File

@@ -222,10 +222,10 @@ for item, data in item_table.items():
def create_items(self) -> None: def create_items(self) -> None:
items = [] items = []
starting_weapon = self.multiworld.starting_weapon[self.player].current_key.title().replace("_", " ") starting_weapon = self.options.starting_weapon.current_key.title().replace("_", " ")
self.multiworld.push_precollected(self.create_item(starting_weapon)) self.multiworld.push_precollected(self.create_item(starting_weapon))
self.multiworld.push_precollected(self.create_item("Steel Armor")) self.multiworld.push_precollected(self.create_item("Steel Armor"))
if self.multiworld.sky_coin_mode[self.player] == "start_with": if self.options.sky_coin_mode == "start_with":
self.multiworld.push_precollected(self.create_item("Sky Coin")) self.multiworld.push_precollected(self.create_item("Sky Coin"))
precollected_item_names = {item.name for item in self.multiworld.precollected_items[self.player]} precollected_item_names = {item.name for item in self.multiworld.precollected_items[self.player]}
@@ -233,28 +233,28 @@ def create_items(self) -> None:
def add_item(item_name): def add_item(item_name):
if item_name in ["Steel Armor", "Sky Fragment"] or "Progressive" in item_name: if item_name in ["Steel Armor", "Sky Fragment"] or "Progressive" in item_name:
return return
if item_name.lower().replace(" ", "_") == self.multiworld.starting_weapon[self.player].current_key: if item_name.lower().replace(" ", "_") == self.options.starting_weapon.current_key:
return return
if self.multiworld.progressive_gear[self.player]: if self.options.progressive_gear:
for item_group in prog_map: for item_group in prog_map:
if item_name in self.item_name_groups[item_group]: if item_name in self.item_name_groups[item_group]:
item_name = prog_map[item_group] item_name = prog_map[item_group]
break break
if item_name == "Sky Coin": if item_name == "Sky Coin":
if self.multiworld.sky_coin_mode[self.player] == "shattered_sky_coin": if self.options.sky_coin_mode == "shattered_sky_coin":
for _ in range(40): for _ in range(40):
items.append(self.create_item("Sky Fragment")) items.append(self.create_item("Sky Fragment"))
return return
elif self.multiworld.sky_coin_mode[self.player] == "save_the_crystals": elif self.options.sky_coin_mode == "save_the_crystals":
items.append(self.create_filler()) items.append(self.create_filler())
return return
if item_name in precollected_item_names: if item_name in precollected_item_names:
items.append(self.create_filler()) items.append(self.create_filler())
return return
i = self.create_item(item_name) i = self.create_item(item_name)
if self.multiworld.logic[self.player] != "friendly" and item_name in ("Magic Mirror", "Mask"): if self.options.logic != "friendly" and item_name in ("Magic Mirror", "Mask"):
i.classification = ItemClassification.useful i.classification = ItemClassification.useful
if (self.multiworld.logic[self.player] == "expert" and self.multiworld.map_shuffle[self.player] == "none" and if (self.options.logic == "expert" and self.options.map_shuffle == "none" and
item_name == "Exit Book"): item_name == "Exit Book"):
i.classification = ItemClassification.progression i.classification = ItemClassification.progression
items.append(i) items.append(i)
@@ -263,11 +263,11 @@ def create_items(self) -> None:
for item in self.item_name_groups[item_group]: for item in self.item_name_groups[item_group]:
add_item(item) add_item(item)
if self.multiworld.brown_boxes[self.player] == "include": if self.options.brown_boxes == "include":
filler_items = [] filler_items = []
for item, count in fillers.items(): for item, count in fillers.items():
filler_items += [self.create_item(item) for _ in range(count)] filler_items += [self.create_item(item) for _ in range(count)]
if self.multiworld.sky_coin_mode[self.player] == "shattered_sky_coin": if self.options.sky_coin_mode == "shattered_sky_coin":
self.multiworld.random.shuffle(filler_items) self.multiworld.random.shuffle(filler_items)
filler_items = filler_items[39:] filler_items = filler_items[39:]
items += filler_items items += filler_items

View File

@@ -1,4 +1,5 @@
from Options import Choice, FreeText, Toggle, Range from Options import Choice, FreeText, Toggle, Range, PerGameCommonOptions
from dataclasses import dataclass
class Logic(Choice): class Logic(Choice):
@@ -321,36 +322,36 @@ class KaelisMomFightsMinotaur(Toggle):
default = 0 default = 0
option_definitions = { @dataclass
"logic": Logic, class FFMQOptions(PerGameCommonOptions):
"brown_boxes": BrownBoxes, logic: Logic
"sky_coin_mode": SkyCoinMode, brown_boxes: BrownBoxes
"shattered_sky_coin_quantity": ShatteredSkyCoinQuantity, sky_coin_mode: SkyCoinMode
"starting_weapon": StartingWeapon, shattered_sky_coin_quantity: ShatteredSkyCoinQuantity
"progressive_gear": ProgressiveGear, starting_weapon: StartingWeapon
"leveling_curve": LevelingCurve, progressive_gear: ProgressiveGear
"starting_companion": StartingCompanion, leveling_curve: LevelingCurve
"available_companions": AvailableCompanions, starting_companion: StartingCompanion
"companions_locations": CompanionsLocations, available_companions: AvailableCompanions
"kaelis_mom_fight_minotaur": KaelisMomFightsMinotaur, companions_locations: CompanionsLocations
"companion_leveling_type": CompanionLevelingType, kaelis_mom_fight_minotaur: KaelisMomFightsMinotaur
"companion_spellbook_type": CompanionSpellbookType, companion_leveling_type: CompanionLevelingType
"enemies_density": EnemiesDensity, companion_spellbook_type: CompanionSpellbookType
"enemies_scaling_lower": EnemiesScalingLower, enemies_density: EnemiesDensity
"enemies_scaling_upper": EnemiesScalingUpper, enemies_scaling_lower: EnemiesScalingLower
"bosses_scaling_lower": BossesScalingLower, enemies_scaling_upper: EnemiesScalingUpper
"bosses_scaling_upper": BossesScalingUpper, bosses_scaling_lower: BossesScalingLower
"enemizer_attacks": EnemizerAttacks, bosses_scaling_upper: BossesScalingUpper
"enemizer_groups": EnemizerGroups, enemizer_attacks: EnemizerAttacks
"shuffle_res_weak_types": ShuffleResWeakType, enemizer_groups: EnemizerGroups
"shuffle_enemies_position": ShuffleEnemiesPositions, shuffle_res_weak_types: ShuffleResWeakType
"progressive_formations": ProgressiveFormations, shuffle_enemies_position: ShuffleEnemiesPositions
"doom_castle_mode": DoomCastle, progressive_formations: ProgressiveFormations
"doom_castle_shortcut": DoomCastleShortcut, doom_castle_mode: DoomCastle
"tweak_frustrating_dungeons": TweakFrustratingDungeons, doom_castle_shortcut: DoomCastleShortcut
"map_shuffle": MapShuffle, tweak_frustrating_dungeons: TweakFrustratingDungeons
"crest_shuffle": CrestShuffle, map_shuffle: MapShuffle
"shuffle_battlefield_rewards": ShuffleBattlefieldRewards, crest_shuffle: CrestShuffle
"map_shuffle_seed": MapShuffleSeed, shuffle_battlefield_rewards: ShuffleBattlefieldRewards
"battlefields_battles_quantities": BattlefieldsBattlesQuantities, map_shuffle_seed: MapShuffleSeed
} battlefields_battles_quantities: BattlefieldsBattlesQuantities

View File

@@ -1,13 +1,13 @@
import yaml import yaml
import os import os
import zipfile import zipfile
import Utils
from copy import deepcopy from copy import deepcopy
from .Regions import object_id_table from .Regions import object_id_table
from Utils import __version__
from worlds.Files import APPatch from worlds.Files import APPatch
import pkgutil import pkgutil
settings_template = yaml.load(pkgutil.get_data(__name__, "data/settings.yaml"), yaml.Loader) settings_template = Utils.parse_yaml(pkgutil.get_data(__name__, "data/settings.yaml"))
def generate_output(self, output_directory): def generate_output(self, output_directory):
@@ -21,7 +21,7 @@ def generate_output(self, output_directory):
item_name = "".join(item_name.split(" ")) item_name = "".join(item_name.split(" "))
else: else:
if item.advancement or item.useful or (item.trap and if item.advancement or item.useful or (item.trap and
self.multiworld.per_slot_randoms[self.player].randint(0, 1)): self.random.randint(0, 1)):
item_name = "APItem" item_name = "APItem"
else: else:
item_name = "APItemFiller" item_name = "APItemFiller"
@@ -46,60 +46,60 @@ def generate_output(self, output_directory):
options = deepcopy(settings_template) options = deepcopy(settings_template)
options["name"] = self.multiworld.player_name[self.player] options["name"] = self.multiworld.player_name[self.player]
option_writes = { option_writes = {
"enemies_density": cc(self.multiworld.enemies_density[self.player]), "enemies_density": cc(self.options.enemies_density),
"chests_shuffle": "Include", "chests_shuffle": "Include",
"shuffle_boxes_content": self.multiworld.brown_boxes[self.player] == "shuffle", "shuffle_boxes_content": self.options.brown_boxes == "shuffle",
"npcs_shuffle": "Include", "npcs_shuffle": "Include",
"battlefields_shuffle": "Include", "battlefields_shuffle": "Include",
"logic_options": cc(self.multiworld.logic[self.player]), "logic_options": cc(self.options.logic),
"shuffle_enemies_position": tf(self.multiworld.shuffle_enemies_position[self.player]), "shuffle_enemies_position": tf(self.options.shuffle_enemies_position),
"enemies_scaling_lower": cc(self.multiworld.enemies_scaling_lower[self.player]), "enemies_scaling_lower": cc(self.options.enemies_scaling_lower),
"enemies_scaling_upper": cc(self.multiworld.enemies_scaling_upper[self.player]), "enemies_scaling_upper": cc(self.options.enemies_scaling_upper),
"bosses_scaling_lower": cc(self.multiworld.bosses_scaling_lower[self.player]), "bosses_scaling_lower": cc(self.options.bosses_scaling_lower),
"bosses_scaling_upper": cc(self.multiworld.bosses_scaling_upper[self.player]), "bosses_scaling_upper": cc(self.options.bosses_scaling_upper),
"enemizer_attacks": cc(self.multiworld.enemizer_attacks[self.player]), "enemizer_attacks": cc(self.options.enemizer_attacks),
"leveling_curve": cc(self.multiworld.leveling_curve[self.player]), "leveling_curve": cc(self.options.leveling_curve),
"battles_quantity": cc(self.multiworld.battlefields_battles_quantities[self.player]) if "battles_quantity": cc(self.options.battlefields_battles_quantities) if
self.multiworld.battlefields_battles_quantities[self.player].value < 5 else self.options.battlefields_battles_quantities.value < 5 else
"RandomLow" if "RandomLow" if
self.multiworld.battlefields_battles_quantities[self.player].value == 5 else self.options.battlefields_battles_quantities.value == 5 else
"RandomHigh", "RandomHigh",
"shuffle_battlefield_rewards": tf(self.multiworld.shuffle_battlefield_rewards[self.player]), "shuffle_battlefield_rewards": tf(self.options.shuffle_battlefield_rewards),
"random_starting_weapon": True, "random_starting_weapon": True,
"progressive_gear": tf(self.multiworld.progressive_gear[self.player]), "progressive_gear": tf(self.options.progressive_gear),
"tweaked_dungeons": tf(self.multiworld.tweak_frustrating_dungeons[self.player]), "tweaked_dungeons": tf(self.options.tweak_frustrating_dungeons),
"doom_castle_mode": cc(self.multiworld.doom_castle_mode[self.player]), "doom_castle_mode": cc(self.options.doom_castle_mode),
"doom_castle_shortcut": tf(self.multiworld.doom_castle_shortcut[self.player]), "doom_castle_shortcut": tf(self.options.doom_castle_shortcut),
"sky_coin_mode": cc(self.multiworld.sky_coin_mode[self.player]), "sky_coin_mode": cc(self.options.sky_coin_mode),
"sky_coin_fragments_qty": cc(self.multiworld.shattered_sky_coin_quantity[self.player]), "sky_coin_fragments_qty": cc(self.options.shattered_sky_coin_quantity),
"enable_spoilers": False, "enable_spoilers": False,
"progressive_formations": cc(self.multiworld.progressive_formations[self.player]), "progressive_formations": cc(self.options.progressive_formations),
"map_shuffling": cc(self.multiworld.map_shuffle[self.player]), "map_shuffling": cc(self.options.map_shuffle),
"crest_shuffle": tf(self.multiworld.crest_shuffle[self.player]), "crest_shuffle": tf(self.options.crest_shuffle),
"enemizer_groups": cc(self.multiworld.enemizer_groups[self.player]), "enemizer_groups": cc(self.options.enemizer_groups),
"shuffle_res_weak_type": tf(self.multiworld.shuffle_res_weak_types[self.player]), "shuffle_res_weak_type": tf(self.options.shuffle_res_weak_types),
"companion_leveling_type": cc(self.multiworld.companion_leveling_type[self.player]), "companion_leveling_type": cc(self.options.companion_leveling_type),
"companion_spellbook_type": cc(self.multiworld.companion_spellbook_type[self.player]), "companion_spellbook_type": cc(self.options.companion_spellbook_type),
"starting_companion": cc(self.multiworld.starting_companion[self.player]), "starting_companion": cc(self.options.starting_companion),
"available_companions": ["Zero", "One", "Two", "available_companions": ["Zero", "One", "Two",
"Three", "Four"][self.multiworld.available_companions[self.player].value], "Three", "Four"][self.options.available_companions.value],
"companions_locations": cc(self.multiworld.companions_locations[self.player]), "companions_locations": cc(self.options.companions_locations),
"kaelis_mom_fight_minotaur": tf(self.multiworld.kaelis_mom_fight_minotaur[self.player]), "kaelis_mom_fight_minotaur": tf(self.options.kaelis_mom_fight_minotaur),
} }
for option, data in option_writes.items(): for option, data in option_writes.items():
options["Final Fantasy Mystic Quest"][option][data] = 1 options["Final Fantasy Mystic Quest"][option][data] = 1
rom_name = f'MQ{__version__.replace(".", "")[0:3]}_{self.player}_{self.multiworld.seed_name:11}'[:21] rom_name = f'MQ{Utils.__version__.replace(".", "")[0:3]}_{self.player}_{self.multiworld.seed_name:11}'[:21]
self.rom_name = bytearray(rom_name, self.rom_name = bytearray(rom_name,
'utf8') 'utf8')
self.rom_name_available_event.set() self.rom_name_available_event.set()
setup = {"version": "1.5", "name": self.multiworld.player_name[self.player], "romname": rom_name, "seed": setup = {"version": "1.5", "name": self.multiworld.player_name[self.player], "romname": rom_name, "seed":
hex(self.multiworld.per_slot_randoms[self.player].randint(0, 0xFFFFFFFF)).split("0x")[1].upper()} hex(self.random.randint(0, 0xFFFFFFFF)).split("0x")[1].upper()}
starting_items = [output_item_name(item) for item in self.multiworld.precollected_items[self.player]] starting_items = [output_item_name(item) for item in self.multiworld.precollected_items[self.player]]
if self.multiworld.sky_coin_mode[self.player] == "shattered_sky_coin": if self.options.sky_coin_mode == "shattered_sky_coin":
starting_items.append("SkyCoin") starting_items.append("SkyCoin")
file_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.apmq") file_path = os.path.join(output_directory, f"{self.multiworld.get_out_file_name_base(self.player)}.apmq")

View File

@@ -1,11 +1,9 @@
from BaseClasses import Region, MultiWorld, Entrance, Location, LocationProgressType, ItemClassification from BaseClasses import Region, MultiWorld, Entrance, Location, LocationProgressType, ItemClassification
from worlds.generic.Rules import add_rule from worlds.generic.Rules import add_rule
from .data.rooms import rooms, entrances
from .Items import item_groups, yaml_item from .Items import item_groups, yaml_item
import pkgutil
import yaml
rooms = yaml.load(pkgutil.get_data(__name__, "data/rooms.yaml"), yaml.Loader) entrance_names = {entrance["id"]: entrance["name"] for entrance in entrances}
entrance_names = {entrance["id"]: entrance["name"] for entrance in yaml.load(pkgutil.get_data(__name__, "data/entrances.yaml"), yaml.Loader)}
object_id_table = {} object_id_table = {}
object_type_table = {} object_type_table = {}
@@ -69,7 +67,7 @@ def create_regions(self):
location_table else None, object["type"], object["access"], location_table else None, object["type"], object["access"],
self.create_item(yaml_item(object["on_trigger"][0])) if object["type"] == "Trigger" else None) for object in self.create_item(yaml_item(object["on_trigger"][0])) if object["type"] == "Trigger" else None) for object in
room["game_objects"] if "Hero Chest" not in object["name"] and object["type"] not in ("BattlefieldGp", room["game_objects"] if "Hero Chest" not in object["name"] and object["type"] not in ("BattlefieldGp",
"BattlefieldXp") and (object["type"] != "Box" or self.multiworld.brown_boxes[self.player] == "include") and "BattlefieldXp") and (object["type"] != "Box" or self.options.brown_boxes == "include") and
not (object["name"] == "Kaeli Companion" and not object["on_trigger"])], room["links"])) not (object["name"] == "Kaeli Companion" and not object["on_trigger"])], room["links"]))
dark_king_room = self.multiworld.get_region("Doom Castle Dark King Room", self.player) dark_king_room = self.multiworld.get_region("Doom Castle Dark King Room", self.player)
@@ -91,15 +89,13 @@ def create_regions(self):
if "entrance" in link and link["entrance"] != -1: if "entrance" in link and link["entrance"] != -1:
spoiler = False spoiler = False
if link["entrance"] in crest_warps: if link["entrance"] in crest_warps:
if self.multiworld.crest_shuffle[self.player]: if self.options.crest_shuffle:
spoiler = True spoiler = True
elif self.multiworld.map_shuffle[self.player] == "everything": elif self.options.map_shuffle == "everything":
spoiler = True spoiler = True
elif "Subregion" in region.name and self.multiworld.map_shuffle[self.player] not in ("dungeons", elif "Subregion" in region.name and self.options.map_shuffle not in ("dungeons", "none"):
"none"):
spoiler = True spoiler = True
elif "Subregion" not in region.name and self.multiworld.map_shuffle[self.player] not in ("none", elif "Subregion" not in region.name and self.options.map_shuffle not in ("none", "overworld"):
"overworld"):
spoiler = True spoiler = True
if spoiler: if spoiler:
@@ -111,6 +107,7 @@ def create_regions(self):
connection.connect(connect_room) connection.connect(connect_room)
break break
non_dead_end_crest_rooms = [ non_dead_end_crest_rooms = [
'Libra Temple', 'Aquaria Gemini Room', "GrenadeMan's Mobius Room", 'Fireburg Gemini Room', 'Libra Temple', 'Aquaria Gemini Room', "GrenadeMan's Mobius Room", 'Fireburg Gemini Room',
'Sealed Temple', 'Alive Forest', 'Kaidge Temple Upper Ledge', 'Sealed Temple', 'Alive Forest', 'Kaidge Temple Upper Ledge',
@@ -140,7 +137,7 @@ def set_rules(self) -> None:
add_rule(self.multiworld.get_location("Gidrah", self.player), hard_boss_logic) add_rule(self.multiworld.get_location("Gidrah", self.player), hard_boss_logic)
add_rule(self.multiworld.get_location("Dullahan", self.player), hard_boss_logic) add_rule(self.multiworld.get_location("Dullahan", self.player), hard_boss_logic)
if self.multiworld.map_shuffle[self.player]: if self.options.map_shuffle:
for boss in ("Freezer Crab", "Ice Golem", "Jinn", "Medusa", "Dualhead Hydra"): for boss in ("Freezer Crab", "Ice Golem", "Jinn", "Medusa", "Dualhead Hydra"):
loc = self.multiworld.get_location(boss, self.player) loc = self.multiworld.get_location(boss, self.player)
checked_regions = {loc.parent_region} checked_regions = {loc.parent_region}
@@ -158,12 +155,12 @@ def set_rules(self) -> None:
return True return True
check_foresta(loc.parent_region) check_foresta(loc.parent_region)
if self.multiworld.logic[self.player] == "friendly": if self.options.logic == "friendly":
process_rules(self.multiworld.get_entrance("Overworld - Ice Pyramid", self.player), process_rules(self.multiworld.get_entrance("Overworld - Ice Pyramid", self.player),
["MagicMirror"]) ["MagicMirror"])
process_rules(self.multiworld.get_entrance("Overworld - Volcano", self.player), process_rules(self.multiworld.get_entrance("Overworld - Volcano", self.player),
["Mask"]) ["Mask"])
if self.multiworld.map_shuffle[self.player] in ("none", "overworld"): if self.options.map_shuffle in ("none", "overworld"):
process_rules(self.multiworld.get_entrance("Overworld - Bone Dungeon", self.player), process_rules(self.multiworld.get_entrance("Overworld - Bone Dungeon", self.player),
["Bomb"]) ["Bomb"])
process_rules(self.multiworld.get_entrance("Overworld - Wintry Cave", self.player), process_rules(self.multiworld.get_entrance("Overworld - Wintry Cave", self.player),
@@ -185,8 +182,8 @@ def set_rules(self) -> None:
process_rules(self.multiworld.get_entrance("Overworld - Mac Ship Doom", self.player), process_rules(self.multiworld.get_entrance("Overworld - Mac Ship Doom", self.player),
["DragonClaw", "CaptainCap"]) ["DragonClaw", "CaptainCap"])
if self.multiworld.logic[self.player] == "expert": if self.options.logic == "expert":
if self.multiworld.map_shuffle[self.player] == "none" and not self.multiworld.crest_shuffle[self.player]: if self.options.map_shuffle == "none" and not self.options.crest_shuffle:
inner_room = self.multiworld.get_region("Wintry Temple Inner Room", self.player) inner_room = self.multiworld.get_region("Wintry Temple Inner Room", self.player)
connection = Entrance(self.player, "Sealed Temple Exit Trick", inner_room) connection = Entrance(self.player, "Sealed Temple Exit Trick", inner_room)
connection.connect(self.multiworld.get_region("Wintry Temple Outer Room", self.player)) connection.connect(self.multiworld.get_region("Wintry Temple Outer Room", self.player))
@@ -198,14 +195,14 @@ def set_rules(self) -> None:
if entrance.connected_region.name in non_dead_end_crest_rooms: if entrance.connected_region.name in non_dead_end_crest_rooms:
entrance.access_rule = lambda state: False entrance.access_rule = lambda state: False
if self.multiworld.sky_coin_mode[self.player] == "shattered_sky_coin": if self.options.sky_coin_mode == "shattered_sky_coin":
logic_coins = [16, 24, 32, 32, 38][self.multiworld.shattered_sky_coin_quantity[self.player].value] logic_coins = [16, 24, 32, 32, 38][self.options.shattered_sky_coin_quantity.value]
self.multiworld.get_entrance("Focus Tower 1F - Sky Door", self.player).access_rule = \ self.multiworld.get_entrance("Focus Tower 1F - Sky Door", self.player).access_rule = \
lambda state: state.has("Sky Fragment", self.player, logic_coins) lambda state: state.has("Sky Fragment", self.player, logic_coins)
elif self.multiworld.sky_coin_mode[self.player] == "save_the_crystals": elif self.options.sky_coin_mode == "save_the_crystals":
self.multiworld.get_entrance("Focus Tower 1F - Sky Door", self.player).access_rule = \ self.multiworld.get_entrance("Focus Tower 1F - Sky Door", self.player).access_rule = \
lambda state: state.has_all(["Flamerus Rex", "Dualhead Hydra", "Ice Golem", "Pazuzu"], self.player) lambda state: state.has_all(["Flamerus Rex", "Dualhead Hydra", "Ice Golem", "Pazuzu"], self.player)
elif self.multiworld.sky_coin_mode[self.player] in ("standard", "start_with"): elif self.options.sky_coin_mode in ("standard", "start_with"):
self.multiworld.get_entrance("Focus Tower 1F - Sky Door", self.player).access_rule = \ self.multiworld.get_entrance("Focus Tower 1F - Sky Door", self.player).access_rule = \
lambda state: state.has("Sky Coin", self.player) lambda state: state.has("Sky Coin", self.player)
@@ -213,26 +210,24 @@ def set_rules(self) -> None:
def stage_set_rules(multiworld): def stage_set_rules(multiworld):
# If there's no enemies, there's no repeatable income sources # If there's no enemies, there's no repeatable income sources
no_enemies_players = [player for player in multiworld.get_game_players("Final Fantasy Mystic Quest") no_enemies_players = [player for player in multiworld.get_game_players("Final Fantasy Mystic Quest")
if multiworld.enemies_density[player] == "none"] if multiworld.worlds[player].options.enemies_density == "none"]
if (len([item for item in multiworld.itempool if item.classification in (ItemClassification.filler, if (len([item for item in multiworld.itempool if item.classification in (ItemClassification.filler,
ItemClassification.trap)]) > len([player for player in no_enemies_players if ItemClassification.trap)]) > len([player for player in no_enemies_players if
multiworld.accessibility[player] == "minimal"]) * 3): multiworld.worlds[player].options.accessibility == "minimal"]) * 3):
for player in no_enemies_players: for player in no_enemies_players:
for location in vendor_locations: for location in vendor_locations:
if multiworld.accessibility[player] == "locations": if multiworld.worlds[player].options.accessibility == "locations":
multiworld.get_location(location, player).progress_type = LocationProgressType.EXCLUDED multiworld.get_location(location, player).progress_type = LocationProgressType.EXCLUDED
else: else:
multiworld.get_location(location, player).access_rule = lambda state: False multiworld.get_location(location, player).access_rule = lambda state: False
else: else:
# There are not enough junk items to fill non-minimal players' vendors. Just set an item rule not allowing # There are not enough junk items to fill non-minimal players' vendors. Just set an item rule not allowing
# advancement items so that useful items can be placed # advancement items so that useful items can be placed.
for player in no_enemies_players: for player in no_enemies_players:
for location in vendor_locations: for location in vendor_locations:
multiworld.get_location(location, player).item_rule = lambda item: not item.advancement multiworld.get_location(location, player).item_rule = lambda item: not item.advancement
class FFMQLocation(Location): class FFMQLocation(Location):
game = "Final Fantasy Mystic Quest" game = "Final Fantasy Mystic Quest"

View File

@@ -10,7 +10,7 @@ from .Regions import create_regions, location_table, set_rules, stage_set_rules,
non_dead_end_crest_warps non_dead_end_crest_warps
from .Items import item_table, item_groups, create_items, FFMQItem, fillers from .Items import item_table, item_groups, create_items, FFMQItem, fillers
from .Output import generate_output from .Output import generate_output
from .Options import option_definitions from .Options import FFMQOptions
from .Client import FFMQClient from .Client import FFMQClient
@@ -45,7 +45,8 @@ class FFMQWorld(World):
item_name_to_id = {name: data.id for name, data in item_table.items() if data.id is not None} item_name_to_id = {name: data.id for name, data in item_table.items() if data.id is not None}
location_name_to_id = location_table location_name_to_id = location_table
option_definitions = option_definitions options_dataclass = FFMQOptions
options: FFMQOptions
topology_present = True topology_present = True
@@ -67,20 +68,14 @@ class FFMQWorld(World):
super().__init__(world, player) super().__init__(world, player)
def generate_early(self): def generate_early(self):
if self.multiworld.sky_coin_mode[self.player] == "shattered_sky_coin": if self.options.sky_coin_mode == "shattered_sky_coin":
self.multiworld.brown_boxes[self.player].value = 1 self.options.brown_boxes.value = 1
if self.multiworld.enemies_scaling_lower[self.player].value > \ if self.options.enemies_scaling_lower.value > self.options.enemies_scaling_upper.value:
self.multiworld.enemies_scaling_upper[self.player].value: self.options.enemies_scaling_lower.value, self.options.enemies_scaling_upper.value = \
(self.multiworld.enemies_scaling_lower[self.player].value, self.options.enemies_scaling_upper.value, self.options.enemies_scaling_lower.value
self.multiworld.enemies_scaling_upper[self.player].value) =\ if self.options.bosses_scaling_lower.value > self.options.bosses_scaling_upper.value:
(self.multiworld.enemies_scaling_upper[self.player].value, self.options.bosses_scaling_lower.value, self.options.bosses_scaling_upper.value = \
self.multiworld.enemies_scaling_lower[self.player].value) self.options.bosses_scaling_upper.value, self.options.bosses_scaling_lower.value
if self.multiworld.bosses_scaling_lower[self.player].value > \
self.multiworld.bosses_scaling_upper[self.player].value:
(self.multiworld.bosses_scaling_lower[self.player].value,
self.multiworld.bosses_scaling_upper[self.player].value) =\
(self.multiworld.bosses_scaling_upper[self.player].value,
self.multiworld.bosses_scaling_lower[self.player].value)
@classmethod @classmethod
def stage_generate_early(cls, multiworld): def stage_generate_early(cls, multiworld):
@@ -94,20 +89,20 @@ class FFMQWorld(World):
rooms_data = {} rooms_data = {}
for world in multiworld.get_game_worlds("Final Fantasy Mystic Quest"): for world in multiworld.get_game_worlds("Final Fantasy Mystic Quest"):
if (world.multiworld.map_shuffle[world.player] or world.multiworld.crest_shuffle[world.player] or if (world.options.map_shuffle or world.options.crest_shuffle or world.options.shuffle_battlefield_rewards
world.multiworld.crest_shuffle[world.player]): or world.options.companions_locations):
if world.multiworld.map_shuffle_seed[world.player].value.isdigit(): if world.options.map_shuffle_seed.value.isdigit():
multiworld.random.seed(int(world.multiworld.map_shuffle_seed[world.player].value)) multiworld.random.seed(int(world.options.map_shuffle_seed.value))
elif world.multiworld.map_shuffle_seed[world.player].value != "random": elif world.options.map_shuffle_seed.value != "random":
multiworld.random.seed(int(hash(world.multiworld.map_shuffle_seed[world.player].value)) multiworld.random.seed(int(hash(world.options.map_shuffle_seed.value))
+ int(world.multiworld.seed)) + int(world.multiworld.seed))
seed = hex(multiworld.random.randint(0, 0xFFFFFFFF)).split("0x")[1].upper() seed = hex(multiworld.random.randint(0, 0xFFFFFFFF)).split("0x")[1].upper()
map_shuffle = multiworld.map_shuffle[world.player].value map_shuffle = world.options.map_shuffle.value
crest_shuffle = multiworld.crest_shuffle[world.player].current_key crest_shuffle = world.options.crest_shuffle.current_key
battlefield_shuffle = multiworld.shuffle_battlefield_rewards[world.player].current_key battlefield_shuffle = world.options.shuffle_battlefield_rewards.current_key
companion_shuffle = multiworld.companions_locations[world.player].value companion_shuffle = world.options.companions_locations.value
kaeli_mom = multiworld.kaelis_mom_fight_minotaur[world.player].current_key kaeli_mom = world.options.kaelis_mom_fight_minotaur.current_key
query = f"s={seed}&m={map_shuffle}&c={crest_shuffle}&b={battlefield_shuffle}&cs={companion_shuffle}&km={kaeli_mom}" query = f"s={seed}&m={map_shuffle}&c={crest_shuffle}&b={battlefield_shuffle}&cs={companion_shuffle}&km={kaeli_mom}"
@@ -175,14 +170,14 @@ class FFMQWorld(World):
def extend_hint_information(self, hint_data): def extend_hint_information(self, hint_data):
hint_data[self.player] = {} hint_data[self.player] = {}
if self.multiworld.map_shuffle[self.player]: if self.options.map_shuffle:
single_location_regions = ["Subregion Volcano Battlefield", "Subregion Mac's Ship", "Subregion Doom Castle"] single_location_regions = ["Subregion Volcano Battlefield", "Subregion Mac's Ship", "Subregion Doom Castle"]
for subregion in ["Subregion Foresta", "Subregion Aquaria", "Subregion Frozen Fields", "Subregion Fireburg", for subregion in ["Subregion Foresta", "Subregion Aquaria", "Subregion Frozen Fields", "Subregion Fireburg",
"Subregion Volcano Battlefield", "Subregion Windia", "Subregion Mac's Ship", "Subregion Volcano Battlefield", "Subregion Windia", "Subregion Mac's Ship",
"Subregion Doom Castle"]: "Subregion Doom Castle"]:
region = self.multiworld.get_region(subregion, self.player) region = self.multiworld.get_region(subregion, self.player)
for location in region.locations: for location in region.locations:
if location.address and self.multiworld.map_shuffle[self.player] != "dungeons": if location.address and self.options.map_shuffle != "dungeons":
hint_data[self.player][location.address] = (subregion.split("Subregion ")[-1] hint_data[self.player][location.address] = (subregion.split("Subregion ")[-1]
+ (" Region" if subregion not in + (" Region" if subregion not in
single_location_regions else "")) single_location_regions else ""))
@@ -202,14 +197,13 @@ class FFMQWorld(World):
for location in exit_check.connected_region.locations: for location in exit_check.connected_region.locations:
if location.address: if location.address:
hint = [] hint = []
if self.multiworld.map_shuffle[self.player] != "dungeons": if self.options.map_shuffle != "dungeons":
hint.append((subregion.split("Subregion ")[-1] + (" Region" if subregion not hint.append((subregion.split("Subregion ")[-1] + (" Region" if subregion not
in single_location_regions else ""))) in single_location_regions else "")))
if self.multiworld.map_shuffle[self.player] != "overworld" and subregion not in \ if self.options.map_shuffle != "overworld":
("Subregion Mac's Ship", "Subregion Doom Castle"):
hint.append(overworld_spot.name.split("Overworld - ")[-1].replace("Pazuzu", hint.append(overworld_spot.name.split("Overworld - ")[-1].replace("Pazuzu",
"Pazuzu's")) "Pazuzu's"))
hint = " - ".join(hint) hint = " - ".join(hint).replace(" - Mac Ship", "")
if location.address in hint_data[self.player]: if location.address in hint_data[self.player]:
hint_data[self.player][location.address] += f"/{hint}" hint_data[self.player][location.address] += f"/{hint}"
else: else:

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,8 @@ Some steps also assume use of Windows, so may vary with your OS.
## Installing the Archipelago software ## Installing the Archipelago software
The most recent public release of Archipelago can be found on the GitHub Releases page: The most recent public release of Archipelago can be found on GitHub:
[Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases). [Archipelago Latest Release](https://github.com/ArchipelagoMW/Archipelago/releases/latest).
Run the exe file, and after accepting the license agreement you will be asked which components you would like to Run the exe file, and after accepting the license agreement you will be asked which components you would like to
install. install.

View File

@@ -71,6 +71,7 @@ class HereticWorld(World):
"Tome of Power": 16, "Tome of Power": 16,
"Silver Shield": 10, "Silver Shield": 10,
"Enchanted Shield": 5, "Enchanted Shield": 5,
"Torch": 5,
"Morph Ovum": 3, "Morph Ovum": 3,
"Mystic Urn": 2, "Mystic Urn": 2,
"Chaos Device": 1, "Chaos Device": 1,
@@ -242,6 +243,7 @@ class HereticWorld(World):
self.create_ratioed_items("Mystic Urn", itempool) self.create_ratioed_items("Mystic Urn", itempool)
self.create_ratioed_items("Ring of Invincibility", itempool) self.create_ratioed_items("Ring of Invincibility", itempool)
self.create_ratioed_items("Shadowsphere", itempool) self.create_ratioed_items("Shadowsphere", itempool)
self.create_ratioed_items("Torch", itempool)
self.create_ratioed_items("Timebomb of the Ancients", itempool) self.create_ratioed_items("Timebomb of the Ancients", itempool)
self.create_ratioed_items("Tome of Power", itempool) self.create_ratioed_items("Tome of Power", itempool)
self.create_ratioed_items("Silver Shield", itempool) self.create_ratioed_items("Silver Shield", itempool)

View File

@@ -554,7 +554,8 @@ class HKWorld(World):
for effect_name, effect_value in item_effects.get(item.name, {}).items(): for effect_name, effect_value in item_effects.get(item.name, {}).items():
if state.prog_items[item.player][effect_name] == effect_value: if state.prog_items[item.player][effect_name] == effect_value:
del state.prog_items[item.player][effect_name] del state.prog_items[item.player][effect_name]
state.prog_items[item.player][effect_name] -= effect_value else:
state.prog_items[item.player][effect_name] -= effect_value
return change return change

View File

@@ -330,7 +330,7 @@ class KDL3SNIClient(SNIClient):
item = ctx.items_received[recv_amount] item = ctx.items_received[recv_amount]
recv_amount += 1 recv_amount += 1
logging.info('Received %s from %s (%s) (%d/%d in list)' % ( logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(ctx.item_names.lookup_in_slot(item.item), 'red', 'bold'), color(ctx.item_names.lookup_in_game(item.item), 'red', 'bold'),
color(ctx.player_names[item.player], 'yellow'), color(ctx.player_names[item.player], 'yellow'),
ctx.location_names.lookup_in_slot(item.location, item.player), recv_amount, len(ctx.items_received))) ctx.location_names.lookup_in_slot(item.location, item.player), recv_amount, len(ctx.items_received)))
@@ -415,7 +415,7 @@ class KDL3SNIClient(SNIClient):
for new_check_id in new_checks: for new_check_id in new_checks:
ctx.locations_checked.add(new_check_id) ctx.locations_checked.add(new_check_id)
location = ctx.location_names.lookup_in_slot(new_check_id) location = ctx.location_names.lookup_in_game(new_check_id)
snes_logger.info( snes_logger.info(
f'New Check: {location} ({len(ctx.locations_checked)}/' f'New Check: {location} ({len(ctx.locations_checked)}/'
f'{len(ctx.missing_locations) + len(ctx.checked_locations)})') f'{len(ctx.missing_locations) + len(ctx.checked_locations)})')

View File

@@ -116,12 +116,19 @@ class KH2Context(CommonContext):
# self.inBattle = 0x2A0EAC4 + 0x40 # self.inBattle = 0x2A0EAC4 + 0x40
# self.onDeath = 0xAB9078 # self.onDeath = 0xAB9078
# PC Address anchors # PC Address anchors
self.Now = 0x0714DB8 # self.Now = 0x0714DB8 old address
self.Save = 0x09A70B0 # epic addresses
self.Now = 0x0716DF8
self.Save = 0x09A92F0
self.Journal = 0x743260
self.Shop = 0x743350
self.Slot1 = 0x2A22FD8
# self.Sys3 = 0x2A59DF0 # self.Sys3 = 0x2A59DF0
# self.Bt10 = 0x2A74880 # self.Bt10 = 0x2A74880
# self.BtlEnd = 0x2A0D3E0 # self.BtlEnd = 0x2A0D3E0
self.Slot1 = 0x2A20C98 # self.Slot1 = 0x2A20C98 old address
self.kh2_game_version = None # can be egs or steam
self.chest_set = set(exclusion_table["Chests"]) self.chest_set = set(exclusion_table["Chests"])
self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"]) self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"])
@@ -228,6 +235,9 @@ class KH2Context(CommonContext):
def kh2_write_int(self, address, value): def kh2_write_int(self, address, value):
self.kh2.write_int(self.kh2.base_address + address, value) self.kh2.write_int(self.kh2.base_address + address, value)
def kh2_read_string(self, address, length):
return self.kh2.read_string(self.kh2.base_address + address, length)
def on_package(self, cmd: str, args: dict): def on_package(self, cmd: str, args: dict):
if cmd in {"RoomInfo"}: if cmd in {"RoomInfo"}:
self.kh2seedname = args['seed_name'] self.kh2seedname = args['seed_name']
@@ -367,10 +377,26 @@ class KH2Context(CommonContext):
for weapon_location in all_weapon_slot: for weapon_location in all_weapon_slot:
all_weapon_location_id.append(self.kh2_loc_name_to_id[weapon_location]) all_weapon_location_id.append(self.kh2_loc_name_to_id[weapon_location])
self.all_weapon_location_id = set(all_weapon_location_id) self.all_weapon_location_id = set(all_weapon_location_id)
try: try:
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX") self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
logger.info("You are now auto-tracking") if self.kh2_game_version is None:
self.kh2connected = True if self.kh2_read_string(0x09A9830, 4) == "KH2J":
self.kh2_game_version = "STEAM"
self.Now = 0x0717008
self.Save = 0x09A9830
self.Slot1 = 0x2A23518
self.Journal = 0x7434E0
self.Shop = 0x7435D0
elif self.kh2_read_string(0x09A92F0, 4) == "KH2J":
self.kh2_game_version = "EGS"
else:
self.kh2_game_version = None
logger.info("Your game version is out of date. Please update your game via The Epic Games Store or Steam.")
if self.kh2_game_version is not None:
logger.info(f"You are now auto-tracking. {self.kh2_game_version}")
self.kh2connected = True
except Exception as e: except Exception as e:
if self.kh2connected: if self.kh2connected:
@@ -589,8 +615,8 @@ class KH2Context(CommonContext):
# if journal=-1 and shop = 5 then in shop # if journal=-1 and shop = 5 then in shop
# if journal !=-1 and shop = 10 then journal # if journal !=-1 and shop = 10 then journal
journal = self.kh2_read_short(0x741230) journal = self.kh2_read_short(self.Journal)
shop = self.kh2_read_short(0x741320) shop = self.kh2_read_short(self.Shop)
if (journal == -1 and shop == 5) or (journal != -1 and shop == 10): if (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
# print("your in the shop") # print("your in the shop")
sellable_dict = {} sellable_dict = {}
@@ -599,8 +625,8 @@ class KH2Context(CommonContext):
amount = self.kh2_read_byte(self.Save + itemdata.memaddr) amount = self.kh2_read_byte(self.Save + itemdata.memaddr)
sellable_dict[itemName] = amount sellable_dict[itemName] = amount
while (journal == -1 and shop == 5) or (journal != -1 and shop == 10): while (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
journal = self.kh2_read_short(0x741230) journal = self.kh2_read_short(self.Journal)
shop = self.kh2_read_short(0x741320) shop = self.kh2_read_short(self.Shop)
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
for item, amount in sellable_dict.items(): for item, amount in sellable_dict.items():
itemdata = self.item_name_to_data[item] itemdata = self.item_name_to_data[item]
@@ -750,7 +776,7 @@ class KH2Context(CommonContext):
item_data = self.item_name_to_data[item_name] item_data = self.item_name_to_data[item_name]
amount_of_items = 0 amount_of_items = 0
amount_of_items += self.kh2_seed_save_cache["AmountInvo"]["Magic"][item_name] amount_of_items += self.kh2_seed_save_cache["AmountInvo"]["Magic"][item_name]
if self.kh2_read_byte(self.Save + item_data.memaddr) != amount_of_items and self.kh2_read_byte(0x741320) in {10, 8}: if self.kh2_read_byte(self.Save + item_data.memaddr) != amount_of_items and self.kh2_read_byte(self.Shop) in {10, 8}:
self.kh2_write_byte(self.Save + item_data.memaddr, amount_of_items) self.kh2_write_byte(self.Save + item_data.memaddr, amount_of_items)
for item_name in master_stat: for item_name in master_stat:
@@ -802,7 +828,7 @@ class KH2Context(CommonContext):
self.kh2_write_byte(self.Save + 0x2502, current_item_slots + 1) self.kh2_write_byte(self.Save + 0x2502, current_item_slots + 1)
elif self.base_item_slots + amount_of_items < 8: elif self.base_item_slots + amount_of_items < 8:
self.kh2_write_byte(self.Save + 0x2502, self.base_item_slots + amount_of_items) self.kh2_write_byte(self.Save + 0x2502, self.base_item_slots + amount_of_items)
# if self.kh2_read_byte(self.Save + item_data.memaddr) != amount_of_items \ # if self.kh2_read_byte(self.Save + item_data.memaddr) != amount_of_items \
# and self.kh2_read_byte(self.Slot1 + 0x1B2) >= 5 and \ # and self.kh2_read_byte(self.Slot1 + 0x1B2) >= 5 and \
# self.kh2_read_byte(self.Save + 0x23DF) & 0x1 << 3 > 0 and self.kh2_read_byte(0x741320) in {10, 8}: # self.kh2_read_byte(self.Save + 0x23DF) & 0x1 << 3 > 0 and self.kh2_read_byte(0x741320) in {10, 8}:
@@ -905,8 +931,23 @@ async def kh2_watcher(ctx: KH2Context):
await asyncio.sleep(15) await asyncio.sleep(15)
ctx.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX") ctx.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
if ctx.kh2 is not None: if ctx.kh2 is not None:
logger.info("You are now auto-tracking") if ctx.kh2_game_version is None:
ctx.kh2connected = True if ctx.kh2_read_string(0x09A9830, 4) == "KH2J":
ctx.kh2_game_version = "STEAM"
ctx.Now = 0x0717008
ctx.Save = 0x09A9830
ctx.Slot1 = 0x2A23518
ctx.Journal = 0x7434E0
ctx.Shop = 0x7435D0
elif ctx.kh2_read_string(0x09A92F0, 4) == "KH2J":
ctx.kh2_game_version = "EGS"
else:
ctx.kh2_game_version = None
logger.info("Your game version is out of date. Please update your game via The Epic Games Store or Steam.")
if ctx.kh2_game_version is not None:
logger.info(f"You are now auto-tracking {ctx.kh2_game_version}")
ctx.kh2connected = True
except Exception as e: except Exception as e:
if ctx.kh2connected: if ctx.kh2connected:
ctx.kh2connected = False ctx.kh2connected = False

View File

@@ -4,6 +4,7 @@ import importlib.machinery
import os import os
import pkgutil import pkgutil
from collections import defaultdict from collections import defaultdict
from typing import TYPE_CHECKING
from .romTables import ROMWithTables from .romTables import ROMWithTables
from . import assembler from . import assembler
@@ -67,10 +68,14 @@ from BaseClasses import ItemClassification
from ..Locations import LinksAwakeningLocation from ..Locations import LinksAwakeningLocation
from ..Options import TrendyGame, Palette, MusicChangeCondition, BootsControls from ..Options import TrendyGame, Palette, MusicChangeCondition, BootsControls
if TYPE_CHECKING:
from .. import LinksAwakeningWorld
# Function to generate a final rom, this patches the rom with all required patches # Function to generate a final rom, this patches the rom with all required patches
def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, multiworld=None, player_name=None, player_names=[], player_id = 0): def generateRom(args, world: "LinksAwakeningWorld"):
rom_patches = [] rom_patches = []
player_names = list(world.multiworld.player_name.values())
rom = ROMWithTables(args.input_filename, rom_patches) rom = ROMWithTables(args.input_filename, rom_patches)
rom.player_names = player_names rom.player_names = player_names
@@ -84,10 +89,10 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
for pymod in pymods: for pymod in pymods:
pymod.prePatch(rom) pymod.prePatch(rom)
if settings.gfxmod: if world.ladxr_settings.gfxmod:
patches.aesthetics.gfxMod(rom, os.path.join("data", "sprites", "ladx", settings.gfxmod)) patches.aesthetics.gfxMod(rom, os.path.join("data", "sprites", "ladx", world.ladxr_settings.gfxmod))
item_list = [item for item in logic.iteminfo_list if not isinstance(item, KeyLocation)] item_list = [item for item in world.ladxr_logic.iteminfo_list if not isinstance(item, KeyLocation)]
assembler.resetConsts() assembler.resetConsts()
assembler.const("INV_SIZE", 16) assembler.const("INV_SIZE", 16)
@@ -116,7 +121,7 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
assembler.const("wLinkSpawnDelay", 0xDE13) assembler.const("wLinkSpawnDelay", 0xDE13)
#assembler.const("HARDWARE_LINK", 1) #assembler.const("HARDWARE_LINK", 1)
assembler.const("HARD_MODE", 1 if settings.hardmode != "none" else 0) assembler.const("HARD_MODE", 1 if world.ladxr_settings.hardmode != "none" else 0)
patches.core.cleanup(rom) patches.core.cleanup(rom)
patches.save.singleSaveSlot(rom) patches.save.singleSaveSlot(rom)
@@ -130,7 +135,7 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
patches.core.easyColorDungeonAccess(rom) patches.core.easyColorDungeonAccess(rom)
patches.owl.removeOwlEvents(rom) patches.owl.removeOwlEvents(rom)
patches.enemies.fixArmosKnightAsMiniboss(rom) patches.enemies.fixArmosKnightAsMiniboss(rom)
patches.bank3e.addBank3E(rom, auth, player_id, player_names) patches.bank3e.addBank3E(rom, world.multi_key, world.player, player_names)
patches.bank3f.addBank3F(rom) patches.bank3f.addBank3F(rom)
patches.bank34.addBank34(rom, item_list) patches.bank34.addBank34(rom, item_list)
patches.core.removeGhost(rom) patches.core.removeGhost(rom)
@@ -141,10 +146,11 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
from ..Options import ShuffleSmallKeys, ShuffleNightmareKeys from ..Options import ShuffleSmallKeys, ShuffleNightmareKeys
if ap_settings["shuffle_small_keys"] != ShuffleSmallKeys.option_original_dungeon or ap_settings["shuffle_nightmare_keys"] != ShuffleNightmareKeys.option_original_dungeon: if world.options.shuffle_small_keys != ShuffleSmallKeys.option_original_dungeon or\
world.options.shuffle_nightmare_keys != ShuffleNightmareKeys.option_original_dungeon:
patches.inventory.advancedInventorySubscreen(rom) patches.inventory.advancedInventorySubscreen(rom)
patches.inventory.moreSlots(rom) patches.inventory.moreSlots(rom)
if settings.witch: if world.ladxr_settings.witch:
patches.witch.updateWitch(rom) patches.witch.updateWitch(rom)
patches.softlock.fixAll(rom) patches.softlock.fixAll(rom)
patches.maptweaks.tweakMap(rom) patches.maptweaks.tweakMap(rom)
@@ -158,9 +164,9 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
patches.tarin.updateTarin(rom) patches.tarin.updateTarin(rom)
patches.fishingMinigame.updateFinishingMinigame(rom) patches.fishingMinigame.updateFinishingMinigame(rom)
patches.health.upgradeHealthContainers(rom) patches.health.upgradeHealthContainers(rom)
if settings.owlstatues in ("dungeon", "both"): if world.ladxr_settings.owlstatues in ("dungeon", "both"):
patches.owl.upgradeDungeonOwlStatues(rom) patches.owl.upgradeDungeonOwlStatues(rom)
if settings.owlstatues in ("overworld", "both"): if world.ladxr_settings.owlstatues in ("overworld", "both"):
patches.owl.upgradeOverworldOwlStatues(rom) patches.owl.upgradeOverworldOwlStatues(rom)
patches.goldenLeaf.fixGoldenLeaf(rom) patches.goldenLeaf.fixGoldenLeaf(rom)
patches.heartPiece.fixHeartPiece(rom) patches.heartPiece.fixHeartPiece(rom)
@@ -170,106 +176,110 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
patches.songs.upgradeMarin(rom) patches.songs.upgradeMarin(rom)
patches.songs.upgradeManbo(rom) patches.songs.upgradeManbo(rom)
patches.songs.upgradeMamu(rom) patches.songs.upgradeMamu(rom)
if settings.tradequest: if world.ladxr_settings.tradequest:
patches.tradeSequence.patchTradeSequence(rom, settings.boomerang) patches.tradeSequence.patchTradeSequence(rom, world.ladxr_settings.boomerang)
else: else:
# Monkey bridge patch, always have the bridge there. # Monkey bridge patch, always have the bridge there.
rom.patch(0x00, 0x333D, assembler.ASM("bit 4, e\njr Z, $05"), b"", fill_nop=True) rom.patch(0x00, 0x333D, assembler.ASM("bit 4, e\njr Z, $05"), b"", fill_nop=True)
patches.bowwow.fixBowwow(rom, everywhere=settings.bowwow != 'normal') patches.bowwow.fixBowwow(rom, everywhere=world.ladxr_settings.bowwow != 'normal')
if settings.bowwow != 'normal': if world.ladxr_settings.bowwow != 'normal':
patches.bowwow.bowwowMapPatches(rom) patches.bowwow.bowwowMapPatches(rom)
patches.desert.desertAccess(rom) patches.desert.desertAccess(rom)
if settings.overworld == 'dungeondive': if world.ladxr_settings.overworld == 'dungeondive':
patches.overworld.patchOverworldTilesets(rom) patches.overworld.patchOverworldTilesets(rom)
patches.overworld.createDungeonOnlyOverworld(rom) patches.overworld.createDungeonOnlyOverworld(rom)
elif settings.overworld == 'nodungeons': elif world.ladxr_settings.overworld == 'nodungeons':
patches.dungeon.patchNoDungeons(rom) patches.dungeon.patchNoDungeons(rom)
elif settings.overworld == 'random': elif world.ladxr_settings.overworld == 'random':
patches.overworld.patchOverworldTilesets(rom) patches.overworld.patchOverworldTilesets(rom)
mapgen.store_map(rom, logic.world.map) mapgen.store_map(rom, world.ladxr_logic.world.map)
#if settings.dungeon_items == 'keysy': #if settings.dungeon_items == 'keysy':
# patches.dungeon.removeKeyDoors(rom) # patches.dungeon.removeKeyDoors(rom)
# patches.reduceRNG.slowdownThreeOfAKind(rom) # patches.reduceRNG.slowdownThreeOfAKind(rom)
patches.reduceRNG.fixHorseHeads(rom) patches.reduceRNG.fixHorseHeads(rom)
patches.bomb.onlyDropBombsWhenHaveBombs(rom) patches.bomb.onlyDropBombsWhenHaveBombs(rom)
if ap_settings['music_change_condition'] == MusicChangeCondition.option_always: if world.options.music_change_condition == MusicChangeCondition.option_always:
patches.aesthetics.noSwordMusic(rom) patches.aesthetics.noSwordMusic(rom)
patches.aesthetics.reduceMessageLengths(rom, rnd) patches.aesthetics.reduceMessageLengths(rom, world.random)
patches.aesthetics.allowColorDungeonSpritesEverywhere(rom) patches.aesthetics.allowColorDungeonSpritesEverywhere(rom)
if settings.music == 'random': if world.ladxr_settings.music == 'random':
patches.music.randomizeMusic(rom, rnd) patches.music.randomizeMusic(rom, world.random)
elif settings.music == 'off': elif world.ladxr_settings.music == 'off':
patches.music.noMusic(rom) patches.music.noMusic(rom)
if settings.noflash: if world.ladxr_settings.noflash:
patches.aesthetics.removeFlashingLights(rom) patches.aesthetics.removeFlashingLights(rom)
if settings.hardmode == "oracle": if world.ladxr_settings.hardmode == "oracle":
patches.hardMode.oracleMode(rom) patches.hardMode.oracleMode(rom)
elif settings.hardmode == "hero": elif world.ladxr_settings.hardmode == "hero":
patches.hardMode.heroMode(rom) patches.hardMode.heroMode(rom)
elif settings.hardmode == "ohko": elif world.ladxr_settings.hardmode == "ohko":
patches.hardMode.oneHitKO(rom) patches.hardMode.oneHitKO(rom)
if settings.superweapons: if world.ladxr_settings.superweapons:
patches.weapons.patchSuperWeapons(rom) patches.weapons.patchSuperWeapons(rom)
if settings.textmode == 'fast': if world.ladxr_settings.textmode == 'fast':
patches.aesthetics.fastText(rom) patches.aesthetics.fastText(rom)
if settings.textmode == 'none': if world.ladxr_settings.textmode == 'none':
patches.aesthetics.fastText(rom) patches.aesthetics.fastText(rom)
patches.aesthetics.noText(rom) patches.aesthetics.noText(rom)
if not settings.nagmessages: if not world.ladxr_settings.nagmessages:
patches.aesthetics.removeNagMessages(rom) patches.aesthetics.removeNagMessages(rom)
if settings.lowhpbeep == 'slow': if world.ladxr_settings.lowhpbeep == 'slow':
patches.aesthetics.slowLowHPBeep(rom) patches.aesthetics.slowLowHPBeep(rom)
if settings.lowhpbeep == 'none': if world.ladxr_settings.lowhpbeep == 'none':
patches.aesthetics.removeLowHPBeep(rom) patches.aesthetics.removeLowHPBeep(rom)
if 0 <= int(settings.linkspalette): if 0 <= int(world.ladxr_settings.linkspalette):
patches.aesthetics.forceLinksPalette(rom, int(settings.linkspalette)) patches.aesthetics.forceLinksPalette(rom, int(world.ladxr_settings.linkspalette))
if args.romdebugmode: if args.romdebugmode:
# The default rom has this build in, just need to set a flag and we get this save. # The default rom has this build in, just need to set a flag and we get this save.
rom.patch(0, 0x0003, "00", "01") rom.patch(0, 0x0003, "00", "01")
# Patch the sword check on the shopkeeper turning around. # Patch the sword check on the shopkeeper turning around.
if settings.steal == 'never': if world.ladxr_settings.steal == 'never':
rom.patch(4, 0x36F9, "FA4EDB", "3E0000") rom.patch(4, 0x36F9, "FA4EDB", "3E0000")
elif settings.steal == 'always': elif world.ladxr_settings.steal == 'always':
rom.patch(4, 0x36F9, "FA4EDB", "3E0100") rom.patch(4, 0x36F9, "FA4EDB", "3E0100")
if settings.hpmode == 'inverted': if world.ladxr_settings.hpmode == 'inverted':
patches.health.setStartHealth(rom, 9) patches.health.setStartHealth(rom, 9)
elif settings.hpmode == '1': elif world.ladxr_settings.hpmode == '1':
patches.health.setStartHealth(rom, 1) patches.health.setStartHealth(rom, 1)
patches.inventory.songSelectAfterOcarinaSelect(rom) patches.inventory.songSelectAfterOcarinaSelect(rom)
if settings.quickswap == 'a': if world.ladxr_settings.quickswap == 'a':
patches.core.quickswap(rom, 1) patches.core.quickswap(rom, 1)
elif settings.quickswap == 'b': elif world.ladxr_settings.quickswap == 'b':
patches.core.quickswap(rom, 0) patches.core.quickswap(rom, 0)
patches.core.addBootsControls(rom, ap_settings['boots_controls']) patches.core.addBootsControls(rom, world.options.boots_controls)
world_setup = logic.world_setup world_setup = world.ladxr_logic.world_setup
JUNK_HINT = 0.33 JUNK_HINT = 0.33
RANDOM_HINT= 0.66 RANDOM_HINT= 0.66
# USEFUL_HINT = 1.0 # USEFUL_HINT = 1.0
# TODO: filter events, filter unshuffled keys # TODO: filter events, filter unshuffled keys
all_items = multiworld.get_items() all_items = world.multiworld.get_items()
our_items = [item for item in all_items if item.player == player_id and item.location and item.code is not None and item.location.show_in_spoiler] our_items = [item for item in all_items
if item.player == world.player
and item.location
and item.code is not None
and item.location.show_in_spoiler]
our_useful_items = [item for item in our_items if ItemClassification.progression in item.classification] our_useful_items = [item for item in our_items if ItemClassification.progression in item.classification]
def gen_hint(): def gen_hint():
chance = rnd.uniform(0, 1) chance = world.random.uniform(0, 1)
if chance < JUNK_HINT: if chance < JUNK_HINT:
return None return None
elif chance < RANDOM_HINT: elif chance < RANDOM_HINT:
location = rnd.choice(our_items).location location = world.random.choice(our_items).location
else: # USEFUL_HINT else: # USEFUL_HINT
location = rnd.choice(our_useful_items).location location = world.random.choice(our_useful_items).location
if location.item.player == player_id: if location.item.player == world.player:
name = "Your" name = "Your"
else: else:
name = f"{multiworld.player_name[location.item.player]}'s" name = f"{world.multiworld.player_name[location.item.player]}'s"
if isinstance(location, LinksAwakeningLocation): if isinstance(location, LinksAwakeningLocation):
location_name = location.ladxr_item.metadata.name location_name = location.ladxr_item.metadata.name
@@ -277,8 +287,8 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
location_name = location.name location_name = location.name
hint = f"{name} {location.item} is at {location_name}" hint = f"{name} {location.item} is at {location_name}"
if location.player != player_id: if location.player != world.player:
hint += f" in {multiworld.player_name[location.player]}'s world" hint += f" in {world.multiworld.player_name[location.player]}'s world"
# Cap hint size at 85 # Cap hint size at 85
# Realistically we could go bigger but let's be safe instead # Realistically we could go bigger but let's be safe instead
@@ -286,7 +296,7 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
return hint return hint
hints.addHints(rom, rnd, gen_hint) hints.addHints(rom, world.random, gen_hint)
if world_setup.goal == "raft": if world_setup.goal == "raft":
patches.goal.setRaftGoal(rom) patches.goal.setRaftGoal(rom)
@@ -299,7 +309,7 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
# Patch the generated logic into the rom # Patch the generated logic into the rom
patches.chest.setMultiChest(rom, world_setup.multichest) patches.chest.setMultiChest(rom, world_setup.multichest)
if settings.overworld not in {"dungeondive", "random"}: if world.ladxr_settings.overworld not in {"dungeondive", "random"}:
patches.entrances.changeEntrances(rom, world_setup.entrance_mapping) patches.entrances.changeEntrances(rom, world_setup.entrance_mapping)
for spot in item_list: for spot in item_list:
if spot.item and spot.item.startswith("*"): if spot.item and spot.item.startswith("*"):
@@ -318,15 +328,16 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
patches.core.addFrameCounter(rom, len(item_list)) patches.core.addFrameCounter(rom, len(item_list))
patches.core.warpHome(rom) # Needs to be done after setting the start location. patches.core.warpHome(rom) # Needs to be done after setting the start location.
patches.titleScreen.setRomInfo(rom, auth, seed_name, settings, player_name, player_id) patches.titleScreen.setRomInfo(rom, world.multi_key, world.multiworld.seed_name, world.ladxr_settings,
if ap_settings["ap_title_screen"]: world.player_name, world.player)
if world.options.ap_title_screen:
patches.titleScreen.setTitleGraphics(rom) patches.titleScreen.setTitleGraphics(rom)
patches.endscreen.updateEndScreen(rom) patches.endscreen.updateEndScreen(rom)
patches.aesthetics.updateSpriteData(rom) patches.aesthetics.updateSpriteData(rom)
if args.doubletrouble: if args.doubletrouble:
patches.enemies.doubleTrouble(rom) patches.enemies.doubleTrouble(rom)
if ap_settings["text_shuffle"]: if world.options.text_shuffle:
buckets = defaultdict(list) buckets = defaultdict(list)
# For each ROM bank, shuffle text within the bank # For each ROM bank, shuffle text within the bank
for n, data in enumerate(rom.texts._PointerTable__data): for n, data in enumerate(rom.texts._PointerTable__data):
@@ -336,20 +347,20 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
for bucket in buckets.values(): for bucket in buckets.values():
# For each bucket, make a copy and shuffle # For each bucket, make a copy and shuffle
shuffled = bucket.copy() shuffled = bucket.copy()
rnd.shuffle(shuffled) world.random.shuffle(shuffled)
# Then put new text in # Then put new text in
for bucket_idx, (orig_idx, data) in enumerate(bucket): for bucket_idx, (orig_idx, data) in enumerate(bucket):
rom.texts[shuffled[bucket_idx][0]] = data rom.texts[shuffled[bucket_idx][0]] = data
if ap_settings["trendy_game"] != TrendyGame.option_normal: if world.options.trendy_game != TrendyGame.option_normal:
# TODO: if 0 or 4, 5, remove inaccurate conveyor tiles # TODO: if 0 or 4, 5, remove inaccurate conveyor tiles
room_editor = RoomEditor(rom, 0x2A0) room_editor = RoomEditor(rom, 0x2A0)
if ap_settings["trendy_game"] == TrendyGame.option_easy: if world.options.trendy_game == TrendyGame.option_easy:
# Set physics flag on all objects # Set physics flag on all objects
for i in range(0, 6): for i in range(0, 6):
rom.banks[0x4][0x6F1E + i -0x4000] = 0x4 rom.banks[0x4][0x6F1E + i -0x4000] = 0x4
@@ -360,7 +371,7 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
# Add new conveyor to "push" yoshi (it's only a visual) # Add new conveyor to "push" yoshi (it's only a visual)
room_editor.objects.append(Object(5, 3, 0xD0)) room_editor.objects.append(Object(5, 3, 0xD0))
if int(ap_settings["trendy_game"]) >= TrendyGame.option_harder: if world.options.trendy_game >= TrendyGame.option_harder:
""" """
Data_004_76A0:: Data_004_76A0::
db $FC, $00, $04, $00, $00 db $FC, $00, $04, $00, $00
@@ -374,12 +385,12 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
TrendyGame.option_impossible: (3, 16), TrendyGame.option_impossible: (3, 16),
} }
def speed(): def speed():
return rnd.randint(*speeds[ap_settings["trendy_game"]]) return world.random.randint(*speeds[world.options.trendy_game])
rom.banks[0x4][0x76A0-0x4000] = 0xFF - speed() rom.banks[0x4][0x76A0-0x4000] = 0xFF - speed()
rom.banks[0x4][0x76A2-0x4000] = speed() rom.banks[0x4][0x76A2-0x4000] = speed()
rom.banks[0x4][0x76A6-0x4000] = speed() rom.banks[0x4][0x76A6-0x4000] = speed()
rom.banks[0x4][0x76A8-0x4000] = 0xFF - speed() rom.banks[0x4][0x76A8-0x4000] = 0xFF - speed()
if int(ap_settings["trendy_game"]) >= TrendyGame.option_hardest: if world.options.trendy_game >= TrendyGame.option_hardest:
rom.banks[0x4][0x76A1-0x4000] = 0xFF - speed() rom.banks[0x4][0x76A1-0x4000] = 0xFF - speed()
rom.banks[0x4][0x76A3-0x4000] = speed() rom.banks[0x4][0x76A3-0x4000] = speed()
rom.banks[0x4][0x76A5-0x4000] = speed() rom.banks[0x4][0x76A5-0x4000] = speed()
@@ -403,10 +414,10 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
for channel in range(3): for channel in range(3):
color[channel] = color[channel] * 31 // 0xbc color[channel] = color[channel] * 31 // 0xbc
if ap_settings["warp_improvements"]: if world.options.warp_improvements:
patches.core.addWarpImprovements(rom, ap_settings["additional_warp_points"]) patches.core.addWarpImprovements(rom, world.options.additional_warp_points)
palette = ap_settings["palette"] palette = world.options.palette
if palette != Palette.option_normal: if palette != Palette.option_normal:
ranges = { ranges = {
# Object palettes # Object palettes
@@ -472,8 +483,8 @@ def generateRom(args, settings, ap_settings, auth, seed_name, logic, rnd=None, m
SEED_LOCATION = 0x0134 SEED_LOCATION = 0x0134
# Patch over the title # Patch over the title
assert(len(auth) == 12) assert(len(world.multi_key) == 12)
rom.patch(0x00, SEED_LOCATION, None, binascii.hexlify(auth)) rom.patch(0x00, SEED_LOCATION, None, binascii.hexlify(world.multi_key))
for pymod in pymods: for pymod in pymods:
pymod.postPatch(rom) pymod.postPatch(rom)

View File

@@ -19,7 +19,7 @@ class DroppedKey(ItemInfo):
extra = 0x01F8 extra = 0x01F8
super().__init__(room, extra) super().__init__(room, extra)
def patch(self, rom, option, *, multiworld=None): def patch(self, rom, option, *, multiworld=None):
if (option.startswith(MAP) and option != MAP) or (option.startswith(COMPASS) and option != COMPASS) or option.startswith(STONE_BEAK) or (option.startswith(NIGHTMARE_KEY) and option != NIGHTMARE_KEY )or (option.startswith(KEY) and option != KEY): if (option.startswith(MAP) and option != MAP) or (option.startswith(COMPASS) and option != COMPASS) or (option.startswith(STONE_BEAK) and option != STONE_BEAK) or (option.startswith(NIGHTMARE_KEY) and option != NIGHTMARE_KEY) or (option.startswith(KEY) and option != KEY):
if option[-1] == 'P': if option[-1] == 'P':
print(option) print(option)
if self._location.dungeon == int(option[-1]) and multiworld is None and self.room not in {0x166, 0x223}: if self._location.dungeon == int(option[-1]) and multiworld is None and self.room not in {0x166, 0x223}:

View File

@@ -1,7 +1,9 @@
from dataclasses import dataclass
import os.path import os.path
import typing import typing
import logging import logging
from Options import Choice, Option, Toggle, DefaultOnToggle, Range, FreeText from Options import Choice, Toggle, DefaultOnToggle, Range, FreeText, PerGameCommonOptions, OptionGroup
from collections import defaultdict from collections import defaultdict
import Utils import Utils
@@ -14,7 +16,7 @@ class LADXROption:
def to_ladxr_option(self, all_options): def to_ladxr_option(self, all_options):
if not self.ladxr_name: if not self.ladxr_name:
return None, None return None, None
return (self.ladxr_name, self.name_lookup[self.value].replace("_", "")) return (self.ladxr_name, self.name_lookup[self.value].replace("_", ""))
@@ -32,9 +34,10 @@ class Logic(Choice, LADXROption):
option_hard = 2 option_hard = 2
option_glitched = 3 option_glitched = 3
option_hell = 4 option_hell = 4
default = option_normal default = option_normal
class TradeQuest(DefaultOffToggle, LADXROption): class TradeQuest(DefaultOffToggle, LADXROption):
""" """
[On] adds the trade items to the pool (the trade locations will always be local items) [On] adds the trade items to the pool (the trade locations will always be local items)
@@ -43,11 +46,14 @@ class TradeQuest(DefaultOffToggle, LADXROption):
display_name = "Trade Quest" display_name = "Trade Quest"
ladxr_name = "tradequest" ladxr_name = "tradequest"
class TextShuffle(DefaultOffToggle): class TextShuffle(DefaultOffToggle):
""" """
[On] Shuffles all the text in the game [On] Shuffles all the text in the game
[Off] (default) doesn't shuffle them. [Off] (default) doesn't shuffle them.
""" """
display_name = "Text Shuffle"
class Rooster(DefaultOnToggle, LADXROption): class Rooster(DefaultOnToggle, LADXROption):
""" """
@@ -57,16 +63,19 @@ class Rooster(DefaultOnToggle, LADXROption):
display_name = "Rooster" display_name = "Rooster"
ladxr_name = "rooster" ladxr_name = "rooster"
class Boomerang(Choice): class Boomerang(Choice):
""" """
[Normal] requires Magnifying Lens to get the boomerang. [Normal] requires Magnifying Lens to get the boomerang.
[Gift] The boomerang salesman will give you a random item, and the boomerang is shuffled. [Gift] The boomerang salesman will give you a random item, and the boomerang is shuffled.
""" """
display_name = "Boomerang"
normal = 0 normal = 0
gift = 1 gift = 1
default = gift default = gift
class EntranceShuffle(Choice, LADXROption): class EntranceShuffle(Choice, LADXROption):
""" """
[WARNING] Experimental, may fail to fill [WARNING] Experimental, may fail to fill
@@ -75,19 +84,20 @@ class EntranceShuffle(Choice, LADXROption):
If random start location and/or dungeon shuffle is enabled, then these will be shuffled with all the non-connector entrance pool. If random start location and/or dungeon shuffle is enabled, then these will be shuffled with all the non-connector entrance pool.
Note, some entrances can lead into water, use the warp-to-home from the save&quit menu to escape this.""" Note, some entrances can lead into water, use the warp-to-home from the save&quit menu to escape this."""
#[Advanced] Simple, but two-way connector caves are shuffled in their own pool as well. # [Advanced] Simple, but two-way connector caves are shuffled in their own pool as well.
#[Expert] Advanced, but caves/houses without items are also shuffled into the Simple entrance pool. # [Expert] Advanced, but caves/houses without items are also shuffled into the Simple entrance pool.
#[Insanity] Expert, but the Raft Minigame hut and Mamu's cave are added to the non-connector pool. # [Insanity] Expert, but the Raft Minigame hut and Mamu's cave are added to the non-connector pool.
option_none = 0 option_none = 0
option_simple = 1 option_simple = 1
#option_advanced = 2 # option_advanced = 2
#option_expert = 3 # option_expert = 3
#option_insanity = 4 # option_insanity = 4
default = option_none default = option_none
display_name = "Experimental Entrance Shuffle" display_name = "Experimental Entrance Shuffle"
ladxr_name = "entranceshuffle" ladxr_name = "entranceshuffle"
class DungeonShuffle(DefaultOffToggle, LADXROption): class DungeonShuffle(DefaultOffToggle, LADXROption):
""" """
[WARNING] Experimental, may fail to fill [WARNING] Experimental, may fail to fill
@@ -96,13 +106,16 @@ class DungeonShuffle(DefaultOffToggle, LADXROption):
display_name = "Experimental Dungeon Shuffle" display_name = "Experimental Dungeon Shuffle"
ladxr_name = "dungeonshuffle" ladxr_name = "dungeonshuffle"
class APTitleScreen(DefaultOnToggle): class APTitleScreen(DefaultOnToggle):
""" """
Enables AP specific title screen and disables the intro cutscene Enables AP specific title screen and disables the intro cutscene
""" """
display_name = "AP Title Screen" display_name = "AP Title Screen"
class BossShuffle(Choice): class BossShuffle(Choice):
display_name = "Boss Shuffle"
none = 0 none = 0
shuffle = 1 shuffle = 1
random = 2 random = 2
@@ -110,15 +123,18 @@ class BossShuffle(Choice):
class DungeonItemShuffle(Choice): class DungeonItemShuffle(Choice):
display_name = "Dungeon Item Shuffle"
option_original_dungeon = 0 option_original_dungeon = 0
option_own_dungeons = 1 option_own_dungeons = 1
option_own_world = 2 option_own_world = 2
option_any_world = 3 option_any_world = 3
option_different_world = 4 option_different_world = 4
#option_delete = 5 # option_delete = 5
#option_start_with = 6 # option_start_with = 6
alias_true = 3 alias_true = 3
alias_false = 0 alias_false = 0
ladxr_item: str
class ShuffleNightmareKeys(DungeonItemShuffle): class ShuffleNightmareKeys(DungeonItemShuffle):
""" """
@@ -132,6 +148,7 @@ class ShuffleNightmareKeys(DungeonItemShuffle):
display_name = "Shuffle Nightmare Keys" display_name = "Shuffle Nightmare Keys"
ladxr_item = "NIGHTMARE_KEY" ladxr_item = "NIGHTMARE_KEY"
class ShuffleSmallKeys(DungeonItemShuffle): class ShuffleSmallKeys(DungeonItemShuffle):
""" """
Shuffle Small Keys Shuffle Small Keys
@@ -143,6 +160,8 @@ class ShuffleSmallKeys(DungeonItemShuffle):
""" """
display_name = "Shuffle Small Keys" display_name = "Shuffle Small Keys"
ladxr_item = "KEY" ladxr_item = "KEY"
class ShuffleMaps(DungeonItemShuffle): class ShuffleMaps(DungeonItemShuffle):
""" """
Shuffle Dungeon Maps Shuffle Dungeon Maps
@@ -155,6 +174,7 @@ class ShuffleMaps(DungeonItemShuffle):
display_name = "Shuffle Maps" display_name = "Shuffle Maps"
ladxr_item = "MAP" ladxr_item = "MAP"
class ShuffleCompasses(DungeonItemShuffle): class ShuffleCompasses(DungeonItemShuffle):
""" """
Shuffle Dungeon Compasses Shuffle Dungeon Compasses
@@ -167,6 +187,7 @@ class ShuffleCompasses(DungeonItemShuffle):
display_name = "Shuffle Compasses" display_name = "Shuffle Compasses"
ladxr_item = "COMPASS" ladxr_item = "COMPASS"
class ShuffleStoneBeaks(DungeonItemShuffle): class ShuffleStoneBeaks(DungeonItemShuffle):
""" """
Shuffle Owl Beaks Shuffle Owl Beaks
@@ -179,6 +200,7 @@ class ShuffleStoneBeaks(DungeonItemShuffle):
display_name = "Shuffle Stone Beaks" display_name = "Shuffle Stone Beaks"
ladxr_item = "STONE_BEAK" ladxr_item = "STONE_BEAK"
class ShuffleInstruments(DungeonItemShuffle): class ShuffleInstruments(DungeonItemShuffle):
""" """
Shuffle Instruments Shuffle Instruments
@@ -195,6 +217,7 @@ class ShuffleInstruments(DungeonItemShuffle):
option_vanilla = 100 option_vanilla = 100
alias_false = 100 alias_false = 100
class Goal(Choice, LADXROption): class Goal(Choice, LADXROption):
""" """
The Goal of the game The Goal of the game
@@ -207,7 +230,7 @@ class Goal(Choice, LADXROption):
option_instruments = 1 option_instruments = 1
option_seashells = 2 option_seashells = 2
option_open = 3 option_open = 3
default = option_instruments default = option_instruments
def to_ladxr_option(self, all_options): def to_ladxr_option(self, all_options):
@@ -216,6 +239,7 @@ class Goal(Choice, LADXROption):
else: else:
return LADXROption.to_ladxr_option(self, all_options) return LADXROption.to_ladxr_option(self, all_options)
class InstrumentCount(Range, LADXROption): class InstrumentCount(Range, LADXROption):
""" """
Sets the number of instruments required to open the Egg Sets the number of instruments required to open the Egg
@@ -226,6 +250,7 @@ class InstrumentCount(Range, LADXROption):
range_end = 8 range_end = 8
default = 8 default = 8
class NagMessages(DefaultOffToggle, LADXROption): class NagMessages(DefaultOffToggle, LADXROption):
""" """
Controls if nag messages are shown when rocks and crystals are touched. Useful for glitches, annoying for everyone else. Controls if nag messages are shown when rocks and crystals are touched. Useful for glitches, annoying for everyone else.
@@ -233,6 +258,7 @@ class NagMessages(DefaultOffToggle, LADXROption):
display_name = "Nag Messages" display_name = "Nag Messages"
ladxr_name = "nagmessages" ladxr_name = "nagmessages"
class MusicChangeCondition(Choice): class MusicChangeCondition(Choice):
""" """
Controls how the music changes. Controls how the music changes.
@@ -243,6 +269,8 @@ class MusicChangeCondition(Choice):
option_sword = 0 option_sword = 0
option_always = 1 option_always = 1
default = option_always default = option_always
# Setting('hpmode', 'Gameplay', 'm', 'Health mode', options=[('default', '', 'Normal'), ('inverted', 'i', 'Inverted'), ('1', '1', 'Start with 1 heart'), ('low', 'l', 'Low max')], default='default', # Setting('hpmode', 'Gameplay', 'm', 'Health mode', options=[('default', '', 'Normal'), ('inverted', 'i', 'Inverted'), ('1', '1', 'Start with 1 heart'), ('low', 'l', 'Low max')], default='default',
# description=""" # description="""
# [Normal} health works as you would expect. # [Normal} health works as you would expect.
@@ -267,10 +295,12 @@ class Bowwow(Choice):
[Normal] BowWow is in the item pool, but can be logically expected as a damage source. [Normal] BowWow is in the item pool, but can be logically expected as a damage source.
[Swordless] The progressive swords are removed from the item pool. [Swordless] The progressive swords are removed from the item pool.
""" """
display_name = "BowWow"
normal = 0 normal = 0
swordless = 1 swordless = 1
default = normal default = normal
class Overworld(Choice, LADXROption): class Overworld(Choice, LADXROption):
""" """
[Dungeon Dive] Create a different overworld where all the dungeons are directly accessible and almost no chests are located in the overworld. [Dungeon Dive] Create a different overworld where all the dungeons are directly accessible and almost no chests are located in the overworld.
@@ -284,9 +314,10 @@ class Overworld(Choice, LADXROption):
# option_shuffled = 3 # option_shuffled = 3
default = option_normal default = option_normal
#Setting('superweapons', 'Special', 'q', 'Enable super weapons', default=False,
# Setting('superweapons', 'Special', 'q', 'Enable super weapons', default=False,
# description='All items will be more powerful, faster, harder, bigger stronger. You name it.'), # description='All items will be more powerful, faster, harder, bigger stronger. You name it.'),
#Setting('quickswap', 'User options', 'Q', 'Quickswap', options=[('none', '', 'Disabled'), ('a', 'a', 'Swap A button'), ('b', 'b', 'Swap B button')], default='none', # Setting('quickswap', 'User options', 'Q', 'Quickswap', options=[('none', '', 'Disabled'), ('a', 'a', 'Swap A button'), ('b', 'b', 'Swap B button')], default='none',
# description='Adds that the select button swaps with either A or B. The item is swapped with the top inventory slot. The map is not available when quickswap is enabled.', # description='Adds that the select button swaps with either A or B. The item is swapped with the top inventory slot. The map is not available when quickswap is enabled.',
# aesthetic=True), # aesthetic=True),
# Setting('textmode', 'User options', 'f', 'Text mode', options=[('fast', '', 'Fast'), ('default', 'd', 'Normal'), ('none', 'n', 'No-text')], default='fast', # Setting('textmode', 'User options', 'f', 'Text mode', options=[('fast', '', 'Fast'), ('default', 'd', 'Normal'), ('none', 'n', 'No-text')], default='fast',
@@ -329,7 +360,7 @@ class BootsControls(Choice):
option_bracelet = 1 option_bracelet = 1
option_press_a = 2 option_press_a = 2
option_press_b = 3 option_press_b = 3
class LinkPalette(Choice, LADXROption): class LinkPalette(Choice, LADXROption):
""" """
@@ -352,6 +383,7 @@ class LinkPalette(Choice, LADXROption):
def to_ladxr_option(self, all_options): def to_ladxr_option(self, all_options):
return self.ladxr_name, str(self.value) return self.ladxr_name, str(self.value)
class TrendyGame(Choice): class TrendyGame(Choice):
""" """
[Easy] All of the items hold still for you [Easy] All of the items hold still for you
@@ -370,6 +402,7 @@ class TrendyGame(Choice):
option_impossible = 5 option_impossible = 5
default = option_normal default = option_normal
class GfxMod(FreeText, LADXROption): class GfxMod(FreeText, LADXROption):
""" """
Sets the sprite for link, among other things Sets the sprite for link, among other things
@@ -380,7 +413,7 @@ class GfxMod(FreeText, LADXROption):
normal = '' normal = ''
default = 'Link' default = 'Link'
__spriteDir: str = Utils.local_path(os.path.join('data', 'sprites','ladx')) __spriteDir: str = Utils.local_path(os.path.join('data', 'sprites', 'ladx'))
__spriteFiles: typing.DefaultDict[str, typing.List[str]] = defaultdict(list) __spriteFiles: typing.DefaultDict[str, typing.List[str]] = defaultdict(list)
extensions = [".bin", ".bdiff", ".png", ".bmp"] extensions = [".bin", ".bdiff", ".png", ".bmp"]
@@ -389,16 +422,15 @@ class GfxMod(FreeText, LADXROption):
name, extension = os.path.splitext(file) name, extension = os.path.splitext(file)
if extension in extensions: if extension in extensions:
__spriteFiles[name].append(file) __spriteFiles[name].append(file)
def __init__(self, value: str): def __init__(self, value: str):
super().__init__(value) super().__init__(value)
def verify(self, world, player_name: str, plando_options) -> None: def verify(self, world, player_name: str, plando_options) -> None:
if self.value == "Link" or self.value in GfxMod.__spriteFiles: if self.value == "Link" or self.value in GfxMod.__spriteFiles:
return return
raise Exception(f"LADX Sprite '{self.value}' not found. Possible sprites are: {['Link'] + list(GfxMod.__spriteFiles.keys())}") raise Exception(
f"LADX Sprite '{self.value}' not found. Possible sprites are: {['Link'] + list(GfxMod.__spriteFiles.keys())}")
def to_ladxr_option(self, all_options): def to_ladxr_option(self, all_options):
if self.value == -1 or self.value == "Link": if self.value == -1 or self.value == "Link":
@@ -407,10 +439,12 @@ class GfxMod(FreeText, LADXROption):
assert self.value in GfxMod.__spriteFiles assert self.value in GfxMod.__spriteFiles
if len(GfxMod.__spriteFiles[self.value]) > 1: if len(GfxMod.__spriteFiles[self.value]) > 1:
logger.warning(f"{self.value} does not uniquely identify a file. Possible matches: {GfxMod.__spriteFiles[self.value]}. Using {GfxMod.__spriteFiles[self.value][0]}") logger.warning(
f"{self.value} does not uniquely identify a file. Possible matches: {GfxMod.__spriteFiles[self.value]}. Using {GfxMod.__spriteFiles[self.value][0]}")
return self.ladxr_name, self.__spriteDir + "/" + GfxMod.__spriteFiles[self.value][0] return self.ladxr_name, self.__spriteDir + "/" + GfxMod.__spriteFiles[self.value][0]
class Palette(Choice): class Palette(Choice):
""" """
Sets the palette for the game. Sets the palette for the game.
@@ -430,18 +464,19 @@ class Palette(Choice):
option_pink = 4 option_pink = 4
option_inverted = 5 option_inverted = 5
class Music(Choice, LADXROption): class Music(Choice, LADXROption):
""" """
[Vanilla] Regular Music [Vanilla] Regular Music
[Shuffled] Shuffled Music [Shuffled] Shuffled Music
[Off] No music [Off] No music
""" """
display_name = "Music"
ladxr_name = "music" ladxr_name = "music"
option_vanilla = 0 option_vanilla = 0
option_shuffled = 1 option_shuffled = 1
option_off = 2 option_off = 2
def to_ladxr_option(self, all_options): def to_ladxr_option(self, all_options):
s = "" s = ""
if self.value == self.option_shuffled: if self.value == self.option_shuffled:
@@ -450,55 +485,97 @@ class Music(Choice, LADXROption):
s = "off" s = "off"
return self.ladxr_name, s return self.ladxr_name, s
class WarpImprovements(DefaultOffToggle): class WarpImprovements(DefaultOffToggle):
""" """
[On] Adds remake style warp screen to the game. Choose your warp destination on the map after jumping in a portal and press B to select. [On] Adds remake style warp screen to the game. Choose your warp destination on the map after jumping in a portal and press B to select.
[Off] No change [Off] No change
""" """
display_name = "Warp Improvements"
class AdditionalWarpPoints(DefaultOffToggle): class AdditionalWarpPoints(DefaultOffToggle):
""" """
[On] (requires warp improvements) Adds a warp point at Crazy Tracy's house (the Mambo teleport spot) and Eagle's Tower [On] (requires warp improvements) Adds a warp point at Crazy Tracy's house (the Mambo teleport spot) and Eagle's Tower
[Off] No change [Off] No change
""" """
display_name = "Additional Warp Points"
links_awakening_options: typing.Dict[str, typing.Type[Option]] = { ladx_option_groups = [
'logic': Logic, OptionGroup("Goal Options", [
Goal,
InstrumentCount,
]),
OptionGroup("Shuffles", [
ShuffleInstruments,
ShuffleNightmareKeys,
ShuffleSmallKeys,
ShuffleMaps,
ShuffleCompasses,
ShuffleStoneBeaks
]),
OptionGroup("Warp Points", [
WarpImprovements,
AdditionalWarpPoints,
]),
OptionGroup("Miscellaneous", [
TradeQuest,
Rooster,
TrendyGame,
NagMessages,
BootsControls
]),
OptionGroup("Experimental", [
DungeonShuffle,
EntranceShuffle
]),
OptionGroup("Visuals & Sound", [
LinkPalette,
Palette,
TextShuffle,
APTitleScreen,
GfxMod,
Music,
MusicChangeCondition
])
]
@dataclass
class LinksAwakeningOptions(PerGameCommonOptions):
logic: Logic
# 'heartpiece': DefaultOnToggle, # description='Includes heart pieces in the item pool'), # 'heartpiece': DefaultOnToggle, # description='Includes heart pieces in the item pool'),
# 'seashells': DefaultOnToggle, # description='Randomizes the secret sea shells hiding in the ground/trees. (chest are always randomized)'), # 'seashells': DefaultOnToggle, # description='Randomizes the secret sea shells hiding in the ground/trees. (chest are always randomized)'),
# 'heartcontainers': DefaultOnToggle, # description='Includes boss heart container drops in the item pool'), # 'heartcontainers': DefaultOnToggle, # description='Includes boss heart container drops in the item pool'),
# 'instruments': DefaultOffToggle, # description='Instruments are placed on random locations, dungeon goal will just contain a random item.'), # 'instruments': DefaultOffToggle, # description='Instruments are placed on random locations, dungeon goal will just contain a random item.'),
'tradequest': TradeQuest, # description='Trade quest items are randomized, each NPC takes its normal trade quest item, but gives a random item'), tradequest: TradeQuest # description='Trade quest items are randomized, each NPC takes its normal trade quest item, but gives a random item'),
# 'witch': DefaultOnToggle, # description='Adds both the toadstool and the reward for giving the toadstool to the witch to the item pool'), # 'witch': DefaultOnToggle, # description='Adds both the toadstool and the reward for giving the toadstool to the witch to the item pool'),
'rooster': Rooster, # description='Adds the rooster to the item pool. Without this option, the rooster spot is still a check giving an item. But you will never find the rooster. Any rooster spot is accessible without rooster by other means.'), rooster: Rooster # description='Adds the rooster to the item pool. Without this option, the rooster spot is still a check giving an item. But you will never find the rooster. Any rooster spot is accessible without rooster by other means.'),
# 'boomerang': Boomerang, # 'boomerang': Boomerang,
# 'randomstartlocation': DefaultOffToggle, # 'Randomize where your starting house is located'), # 'randomstartlocation': DefaultOffToggle, # 'Randomize where your starting house is located'),
'experimental_dungeon_shuffle': DungeonShuffle, # 'Randomizes the dungeon that each dungeon entrance leads to'), experimental_dungeon_shuffle: DungeonShuffle # 'Randomizes the dungeon that each dungeon entrance leads to'),
'experimental_entrance_shuffle': EntranceShuffle, experimental_entrance_shuffle: EntranceShuffle
# 'bossshuffle': BossShuffle, # 'bossshuffle': BossShuffle,
# 'minibossshuffle': BossShuffle, # 'minibossshuffle': BossShuffle,
'goal': Goal, goal: Goal
'instrument_count': InstrumentCount, instrument_count: InstrumentCount
# 'itempool': ItemPool, # 'itempool': ItemPool,
# 'bowwow': Bowwow, # 'bowwow': Bowwow,
# 'overworld': Overworld, # 'overworld': Overworld,
'link_palette': LinkPalette, link_palette: LinkPalette
'warp_improvements': WarpImprovements, warp_improvements: WarpImprovements
'additional_warp_points': AdditionalWarpPoints, additional_warp_points: AdditionalWarpPoints
'trendy_game': TrendyGame, trendy_game: TrendyGame
'gfxmod': GfxMod, gfxmod: GfxMod
'palette': Palette, palette: Palette
'text_shuffle': TextShuffle, text_shuffle: TextShuffle
'shuffle_nightmare_keys': ShuffleNightmareKeys, shuffle_nightmare_keys: ShuffleNightmareKeys
'shuffle_small_keys': ShuffleSmallKeys, shuffle_small_keys: ShuffleSmallKeys
'shuffle_maps': ShuffleMaps, shuffle_maps: ShuffleMaps
'shuffle_compasses': ShuffleCompasses, shuffle_compasses: ShuffleCompasses
'shuffle_stone_beaks': ShuffleStoneBeaks, shuffle_stone_beaks: ShuffleStoneBeaks
'music': Music, music: Music
'shuffle_instruments': ShuffleInstruments, shuffle_instruments: ShuffleInstruments
'music_change_condition': MusicChangeCondition, music_change_condition: MusicChangeCondition
'nag_messages': NagMessages, nag_messages: NagMessages
'ap_title_screen': APTitleScreen, ap_title_screen: APTitleScreen
'boots_controls': BootsControls, boots_controls: BootsControls
}

View File

@@ -1,4 +1,4 @@
import settings
import worlds.Files import worlds.Files
import hashlib import hashlib
import Utils import Utils
@@ -32,7 +32,7 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
def get_base_rom_path(file_name: str = "") -> str: def get_base_rom_path(file_name: str = "") -> str:
options = Utils.get_options() options = settings.get_settings()
if not file_name: if not file_name:
file_name = options["ladx_options"]["rom_file"] file_name = options["ladx_options"]["rom_file"]
if not os.path.exists(file_name): if not os.path.exists(file_name):

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