Compare commits

...

376 Commits

Author SHA1 Message Date
NewSoupVi
930f627794 Release workflow as well 2025-03-10 14:32:09 +01:00
NewSoupVi
d4fc90410c Update build.yml 2025-03-10 14:09:20 +01:00
NewSoupVi
484c5f2671 Band-aid Linux Build breaking with the release of PyGObject 3.52.1 2025-03-10 14:06:59 +01:00
Silvris
04771fa4f0 Core: fix pickling plando texts (#4711) 2025-03-09 20:00:00 +01:00
jamesbrq
2639796255 MLSS: Add new goal + Update basepatch to standalone equivalent (#4409)
* Item groups + small changes

* Add alternate goal

* New Locations and Logic Updates + Basepatch

* Update basepatch.bsdiff

* Update Basepatch

* Update basepatch.bsdiff

* Update bowsers castle logic with emblem hunt

* Update Archipelago Unittests.run.xml

* Update Archipelago Unittests.run.xml

* Fix for overlapping ROM addresses

* Update Rom.py

* Update __init__.py

* Update basepatch.bsdiff

* Update Rom.py

* Update client with new helper function

* Update basepatch.bsdiff

* Update worlds/mlss/__init__.py

Co-authored-by: qwint <qwint.42@gmail.com>

* Update worlds/mlss/__init__.py

Co-authored-by: qwint <qwint.42@gmail.com>

* Review Refactor

* Review Refactor

---------

Co-authored-by: qwint <qwint.42@gmail.com>
2025-03-09 11:37:15 -04:00
Jérémie Bolduc
4ebabc1208 Stardew Valley: Move filler pool generation out of the world class (#4372)
* merge group options so specific handling is not needed when generating filler pool

* fix

* remove unneeded imports

* self review

* remove unneeded imports

* looks like typing was missing woopsi
2025-03-08 12:13:33 -05:00
josephwhite
ce34b60712 Super Mario 64: ItemData class and tables (#4321)
* sm64ex: use item data class

* rearrange imports

* Dict to dict

* remove optional typing

* bonus item descriptions since we can also add stuff for webworld easily

* remove item descriptions (rip) and decrease verbosity for classifications

* formatting
2025-03-08 12:07:50 -05:00
Trevor L
54094c6331 Blasphemous: Restrict right half of map start locations to hard difficulty only (#4002)
* Start locations, location name

* Fix tests
2025-03-08 11:59:35 -05:00
Bryce Wilson
3986f6f11a Pokemon Emerald: Randomize rock smash encounters (#3912)
* Pokemon Emerald: WIP add rock smash encounter randomization

* Pokemon Emerald: Refactor encounter data on maps

* Pokemon Emerald: Remove unused import

* Pokemon Emerald: Swap StrEnum for regular Enum and use .value
2025-03-08 11:57:16 -05:00
sgrunt
5662da6f7d Timespinner: Support new flags and settings from the randomizer (#4559)
* Timespinner: Add "no hell spiders" enemy rando option that is present in upstream settings

* Timespinner: Prism Break support tweaks (including tracker support)

* Timespinner: Add support for upstream Lock Key Amadeus flag

* Timespinner: Add support for upstream Risky Warps flag

* Timespinner: Add support for upstream Pyramid Start flag

* Timespinner: fix error in lab connectivity logic

* Timespinner: use has_all to simplify one check

Per PR suggestion.

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

* Timespinner: fix apparent logic error inherited from in-rando logic

* Timespinner: adjust "Origins" location logic slightly further to account for a Risky Warps case

* Timespinner: remove the backward compat options for the recent flag additions

* Timespinner: add newly added Gate Keep option from rando

* Timespinner: adjust the laser access colours in the tracker

* Timespinner: fix an item description in the tracker

* Timespinner: based on testing feedback, put Laser Access items in their own category

* Timespinner: add support for new upstream flag Royal Roadblock

* Timespinner: also ensure the new flag gets put in slot data

* Timespinner: fix bug in universal tracker support indicating castle basement is accessible at the lower Rising Tides flooding level

* Timespinner: exclude Talaria Attachment and Timespinner Wheel from pyramid start starter progression items

* Timespinner: fix region logic for the left pyramid warp

* Timespinner: fix main Gyre access logic when Risky Warps warps you behind the lasers

* Timespinner: apply suggested spacing fix

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

---------

Co-authored-by: sgrunt <sgrunt1987@gmail.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-03-08 11:54:23 -05:00
Scipio Wright
33a75fb2cb TUNIC: Breakable Shuffle (#4489)
* Starting out

* Rules for breakable regions

* make the rest of it work, it's pr ready, boom

* Make it work in not pot shuffle

* Fix after merge

* Fix item id overlap

* Move breakable, grass, and local fill options in yaml

* Fix groups getting overwritten

* Rename, add new breakables

* Rename more stuff

* Time to rename them again

* Make it actually default for breakable shuffle

* Burn the signs down

* Fix west courtyard pot regions

* Fix fortress courtyard and beneath the fortress loc groups again

* More missing loc group conversions

* Replace instances of world.player with player, same for multiworld

* Update worlds/tunic/__init__.py

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

* Remove unused import
2025-03-08 11:25:47 -05:00
Jérémie Bolduc
ee9bcb84b7 Stardew Valley: Move progressive tool options handling in features (#4374)
* create tool progression feature and unwrap option

* replace option usage with calling feature

* add comment explaining why some logic is a weird place

* replace item creation logic with feature

* self review and add unit tests

* rename test cuz I named them too long

* add a test for the trash can useful stuff cuz I thought there was a bug but turns out it works

* self review again

* remove price_multiplier, turns out it's unused during generation

* damn it 3.11 why are you like this

* use blacksmith region when checking vanilla tools

* fix rule

* move can mine using in tool logic

* remove changes to performance test

* properly set the option I guess

* properly set options 2

* that's what happen when you code too late
2025-03-08 11:19:29 -05:00
Kaito Sinclaire
b5269e9aa4 id Tech Games: Customizable ammo capacity (#3565)
* Doom, Doom 2, Heretic: customizable ammo capacity

* Do not progression balance capacity up items

* Prog fill still doesn't agree, just go with our original idea

* Clean up the new options a bit

- Gave all options a consistent and easily readable naming scheme
  (`max_ammo_<type>` and `added_ammo_<type>`)
- Don't show the new options in the spoiler log,
  as they do not affect logic
- Fix the Doom games' Split Backpack option accidentally referring to
  Heretic's Bag of Holding

The logging change across all three games is incidental, as at some
point I did run into that condition by happenstance and it turns out
that it throws an exception due to bad formatting if it's reached

* Do the visibility change for Heretic as well

* Update required client version

* Remove spoiler log restriction on options

* Remove Visibility import now made redundant
2025-03-08 10:37:54 -05:00
Bryce Wilson
00a6ac3a52 BizHawkClient: Store seed name sent by the server for clients to check (#4702) 2025-03-08 16:14:25 +01:00
Bryce Wilson
ea8a14b003 Pokemon Emerald: Some dexsanity locations contribute evolution items (#3187)
* Pokemon Emerald: Change some dexsanity vanilla items to evo items

If a species evolves via item use (Fire Stone, Metal Coat, etc.), use that as it's vanilla item instead of a ball

* Pokemon Emerald: Remove accidentally added print

* Pokemon Emerald: Update changelog

* Pokemon Emerald: Adjust changelog

* Pokemon Emerald: Remove unnecessary else

* Pokemon Emerald: Fix changelog
2025-03-08 10:13:58 -05:00
CaitSith2
414ab86422 LttP: Fix dungeon counter options. (#4704) 2025-03-08 16:13:32 +01:00
Scipio Wright
d4e2698ae0 TUNIC: Add exception handling to deal with duplicate apworlds (#4634)
* Add exception handling to deal with duplicate apworlds

* Update worlds/tunic/__init__.py
2025-03-08 09:56:29 -05:00
JaredWeakStrike
3f8e3082c0 KH2: Client Optimizations and some QoL (#4547)
* adding qwints suggestions

* add stat increase protection and ingame yml stuff

* idk how I forgot these

* reword things

* Update worlds/kh2/Client.py

Co-authored-by: qwint <qwint.42@gmail.com>

* 3.12 compat

* too long of a line

* why didnt I do this before lol

* reading is hard

* missed one

* forgot the self

* fix crash if you get datapackage that isnt kh2

* update to main?

* update to use 0.10 as base and fix violet's base 0 on hex values

* reverting this because I'm bad at my job

---------

Co-authored-by: qwint <qwint.42@gmail.com>
2025-03-08 08:58:59 -05:00
Justus Lind
0f738935ee Muse Dash: Update song list to Cosmic Radio. (#4554)
* MSR Anthology Vol.2 update

* Missing new line.

* Update to Cosmic Radio 2024
2025-03-08 08:58:26 -05:00
kbranch
9c57976252 LADX: Autotracker improvements (#4445)
* Expand and validate the RAM cache

* Part way through location improvement

* Fixed location tracking

* Preliminary entrance tracking support

* Actually send entrance messages

* Store found entrances on the server

* Bit of cleanup

* Added rupee count, items linked to checks

* Send Magpie a handshAck

* Got my own version wrong

* Remove the Beta name

* Only send slot_data if there's something in it

* Ask the server for entrance updates

* Small fix to stabilize Link's location when changing rooms

* Oops, server storage is shared between worlds

* Deal with null responses from the server

* Added UNUSED_KEY item
2025-03-08 13:32:45 +01:00
NewSoupVi
3e08acf381 The Witness: Move local_items code earlier #4696 2025-03-08 12:26:59 +01:00
Exempt-Medic
113259bc15 Update links (#4690)
* Update links

* Update two more
2025-03-07 20:17:45 -05:00
Natalie Weizenbaum
61afe76eae DS3: Remove the outdated French translation of the setup docs (#4700)
This was causing confusion and Discord support requests because the
instructions there are no longer compatible with the latest version of
Archipelago.

This also lists me as the primary author of the new setup guide.
2025-03-08 01:45:52 +01:00
NewSoupVi
08b3b3ecf5 The Witness: The Secret Feature (#4370)
* Secret Feature

* Fixes

* Fixes and unit tests

* renaming some variables

* Fix the thing

* unit test for elevator egg

* Docstring

* reword

* Fix duplicate locations I think?

* Remove debug thing

* Add the tests back lol

* Make it so that you can exclude an egg to disable it

* Improve hint text for easter eggs

* Update worlds/witness/options.py

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

* Update worlds/witness/player_logic.py

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

* Update worlds/witness/options.py

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

* Update worlds/witness/player_logic.py

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

* Update worlds/witness/rules.py

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

* Update test_easter_egg_shuffle.py

* This was actually not necessary, since this is the Egg requirements, nothing to do with location names

* Move one of them

* Improve logic

* Lol

* Moar

* Adjust unit tests

* option docstring adjustment

* Recommend door shuffle

* Don't overlap IDs

* Option description idk

* Change the way the difficulties work to reward playing higher modes

* Fix merge

* add some stuff to generate_data_file (this file is not imported during gen, don't review it :D)

* oop

* space

* This can be earlier than I thought, apparently.

* buffer

* Comment

* Make sure the option is VERY visible

* Some mypy stuff

* apparently ruff wants this

* .

* durinig

* Update options.py

* Explain the additional effects of each difficulty

* Fix logic of flood room secret

* Add Southern Peninsula Area

* oop

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-03-08 01:44:06 +01:00
Silent
bc61221ec6 TUNIC: Expanded hexagon quest options (#4076)
* More hex quest updates

- Implement page ability shuffle for hex quest
- Fix keys behind bosses if hex goal is less than 3
- Added check to fix conflicting hex quest options
- Add option to slot data

* Change option comparison

* Change option checking and fix some stuff

- also keep prayer first on low hex counts

* Update option defaulting

* Update option checking

* Fix option assignment again

* Show player name in option warning

* Add new option to universal tracker stuff

* Update __init__.py

* Make helper method for getting total hexagons in itempool

* Update options.py

* Update option value passthrough

* Change ability shuffle to default on

* Check for hexagons option when writing spoiler
2025-03-08 01:43:02 +01:00
threeandthreee
2f0b81e12c LADX: tarins gift improvement (#3970)
* add groups and a preset

* formatting

* pull zig's tarin's gift improvements

* typing

* alias groups for progressive items

* change tarins gift option a bit

* add bush breakers item group

* fix typo

* bush_breaker option, respect non_local_items

* review suggestions

* cleaner
thx exempt

* Update worlds/ladx/__init__.py

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

* fix gen failures for dungeon shuffle

* exclude shovel based on entrance mapping

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-03-08 01:24:58 +01:00
threeandthreee
bb9a6bcd2e LADX: more marin joke text (#3966)
* marin text

* Adds lots of Marin Flavour Text (#32)

* Updates of Splash text 24-09-18

* Re-Adds '

* use pkgutil

* Adds all community suggestions up until 20/09/2024 (#33)

* Adds all community suggestions up until 20/09/2024

* cutting deathlink jokes

---------

Co-authored-by: Alex Nordstrom <a.l.nordstrom@gmail.com>

* drop piracy-adjacent jokes

* marin text was too long

* more submissions

* no longer looking for new maintainer

---------

Co-authored-by: palex00 <32203971+palex00@users.noreply.github.com>
2025-03-08 01:19:51 +01:00
Jérémie Bolduc
c8b7ef1016 Stardew Valley: Fix a logic bug where the Tea Sapling would be considered available without having the recipe (#4703) 2025-03-08 00:14:10 +01:00
Silent
e00467c2a2 TUNIC: Update logic for chest in fortress dark area (#4691)
* Update logic for beneath the vault chest

* use helper method instead

so that it checks the lanternless option
2025-03-06 00:18:27 +01:00
Silent
0eb6150e95 TUNIC: Fix rule for some grass in West Garden (#4682) 2025-03-06 00:17:27 +01:00
Fabian Dill
91d977479d Tests: test that collect and remove have expected behaviour. (#2062)
---------

Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-03-05 23:48:03 +01:00
BadMagic100
cd761db170 Core: Do GER speculative sweep membership checks against a set #4698 2025-02-27 19:21:48 +01:00
Aaron Wagener
026011323e The Messenger: Fix 0 Required Power Seals (#4692) 2025-02-27 11:42:41 -05:00
Silvris
adc5f3a07d MM2: Fix Shuffled Weaknesses Seed Bleed (#4689) 2025-02-27 11:13:37 -05:00
BadMagic100
69940374e1 Core: Only consider requested exits during ER placement and speculative sweep #4684 2025-02-27 17:12:35 +01:00
Scipio Wright
6dc461609b Noita: Fix bug with Traps disabled in 1-player games #4651 2025-02-23 17:27:05 +01:00
threeandthreee
58d460678e LADX: drop rupee farm condition (#4189)
* drop rupee farm condition

* cleanup

* rupee farm backup for all spending checks

* not power bracelet

* oops
2025-02-23 17:11:24 +01:00
Scipio Wright
0f7fd48cdd TUNIC: Add some more rules for Monastery connections (#4564)
* Move a couple locations to monastery

* Connect Quarry Back to Monastery

* Quarry Back -> Monastery with laurels, Monastery -> Monastery Back with wand/sword

* Add Monastery Back region

* Move a couple non-ER locations to monastery back

* Monastery front -> back with sword, wand, or laurels zip

* also laurels zip for non-ER
2025-02-23 17:02:30 +01:00
Natalie Weizenbaum
18de035b4d DS3: Update setup documentation (#4437) 2025-02-22 08:33:58 -05:00
Fabian Dill
11fa43f0a4 Factorio: prevent players from getting stuck from Teleport Traps (#4537) 2025-02-20 00:17:19 +01:00
black-sliver
91a8fc91d6 CI: fix native tests toolchain on windows (#4668)
* CI: ctest: fix trigger on CMakeLists change

* CI: ctest: update cmake version

this removes a warning
and matches gtest

* CI: ctest: remove explicit build mode for MSVC

gtest switched to dynamic libc (/MD), which is default, so this just works now
2025-02-19 13:50:25 +01:00
Fabian Dill
15bde56551 Factorio: prevent invalid starting items count (#4658) 2025-02-17 18:58:38 +01:00
NewSoupVi
d744e086ef MultiServer: Fix hinting an item that someone else already hinted in their slot not resolving correctly (#4655)
* Fix get_hint not checking for finding_player

* Fix using the wrong variable for slot lookup
2025-02-17 15:16:18 +01:00
Scipio Wright
378fa5d5c4 Fix gun missing from combat_items, add new for combat logic cache, very slight refactor of check_combat_reqs to let it do the changeover in a less complicated fashion, fix area being a boss area rather than non-boss area for a check (#4657) 2025-02-17 01:30:40 +01:00
black-sliver
8349774c5c customserver: ignore static datapackage optimization for old games (#4650) 2025-02-16 23:51:36 +01:00
qwint
34795b598a GER: Use Itempool Count for Minimal handling (#4649)
* uses itempool count vs unfilled location count instead of counting prog_items values which could have custom counters

* move unfilled location check to before can_reach

* add tests for successful minimal GER call with extra collect override prog_items in the pool to regression test issue fixed in this PR
2025-02-16 20:21:09 +01:00
JoshuaEagles
efd5004330 Docs: Update SA2B Linux and Steam Deck Setup Guide + Add Celeste 64 Linux Setup Guide (#4593)
* Update Linux and Steam Deck setup guide for sa2b

* Add Linux and Steam Deck setup guide for Celeste 64
2025-02-12 17:47:43 +01:00
Matthew Wells
c799531105 Docs: Add missing plural in faq (#4622) 2025-02-12 17:47:17 +01:00
threeandthreee
5c1ded1fe9 LADX: bomb as logical bush breaker #4636 2025-02-12 17:46:43 +01:00
qwint
b2162bb8e6 Docs: clean up create_item/event example (#4596)
* eyes

* remove line wraps where unnecessary
2025-02-12 17:46:07 +01:00
agilbert1412
f1769a8d00 Stardew Valley: Fixed Powdermelon and option inconsistencies (#4632)
* - Fixed powdermelon season

* - Improve cohesion in presets

* - Update several tooltips to be more consistent and accurate
2025-02-12 17:45:03 +01:00
qwint
f520c1d9f2 Launcher: Allow for --nogui client launches (#4549) 2025-02-10 19:34:27 +01:00
PinkSwitch
910369a7f8 Bizhawk Client: Display Err (#4532)
Co-authored-by: Bryce Wilson
2025-02-10 19:27:10 +01:00
qwint
dbf6b6f935 CC: don't try to reconnect on invalid version (#4606) 2025-02-10 19:23:58 +01:00
qwint
e9c463c897 CC: Force Text Client to always connect with empty game (#4607) 2025-02-10 19:23:09 +01:00
qwint
f4e43ca9e0 LttP: mock world.random in adjuster (#4623) 2025-02-10 19:22:06 +01:00
Fabian Dill
a298be9c41 Core: change HINT_FOUND to 40 and HINT_UNSPECIFIED to 0 (#4620) 2025-02-10 19:19:00 +01:00
Fabian Dill
18bcaa85a2 Test: ensure get_all_state() does not error in between steps (#4612) 2025-02-10 19:18:14 +01:00
Scipio Wright
359f45d50f TUNIC: Combat logic fix (#4589)
* Potential fix for attack issue

* also put the lazy version of the swamp fix in for good measure

* fix extra line

* now it is good

* Add the test, roll the other PR into this one

* Make the test exception more useful

* Remove debug print

* Combat logic fixed?

* Move a few areas to before well instead of east forest

* Put in qwint's suggestions in test

* Implement qwint's suggestions in combat_logic.py

* Implement qwint's suggestions for combat_logic.py

* Fix typo

* Remove experimental from combat logic description

* Remove copy_mixin again

* Add comment about copy_mixin

* Use a more proper random

* Some optimizations from Vi's comments
2025-02-09 19:12:17 +01:00
qwint
f5c574c37a Settings: add format handling to yaml exception marks for readability (#4531) 2025-02-09 12:11:27 +01:00
NewSoupVi
f75a1ae117 KH2: Fix lambda capture issue with weapon slot logic (#4604)
* KH2: Fix lambda capture issue with weapon slot logic

* Update Rules.py

* Improved by JaredWeakStrike (#4605)

* Apparently this wasn't meant to be indented

---------

Co-authored-by: JaredWeakStrike <96694163+JaredWeakStrike@users.noreply.github.com>
2025-02-08 00:06:04 +01:00
Kory Dondzila
768ccffe72 Shivers: Update shivers links and guides (#4592) 2025-02-07 21:06:06 +01:00
Martmists
f6668997e6 [AHIT] Fix small options issue (#4615) 2025-02-07 21:02:37 +01:00
shananas
db11c620a7 KH2 Doc Update #4609
Mod Manager Version Number
2025-02-04 17:09:02 +01:00
Jouramie
da48af60dc Stardew Valley: add assert_can_reach_region_* for better tests (#4556)
* add assert_reach_region_*; refactor existing assert_reach_location_* to allow string

* rename asserts
2025-02-04 08:27:23 +01:00
massimilianodelliubaldini
19faaa4104 Core: Fix #4595 by using first type's docstring in a union type (#4600)
* Fix #4595: use first type's docstring in a union type.

* Reuse existing import.
2025-02-04 01:49:07 +01:00
Scipio Wright
628252896e TUNIC: Call Combat Logic experimental (#4594)
* Update options.py

* Update options.py
2025-02-03 15:53:56 +01:00
Mysteryem
f28aff6f9a Core: Replace generator creation/iteration in CollectionState methods (#4587)
* Core: Replace generator creation/iteration in CollectionState methods

Using generators in these functions incurs overhead to create the new
generator instance, call the `any`/`all`/`sum` function and have the
`any`/`all`/`sum` function iterate the generator, which in turn iterates
the iterable.

Replacing the use of generators with for loops is faster.

Getting `self.prog_items[player]` once in advance also improves
performance of iterating longer iterables.

* Add comment on the choice of for loops instead of any()/all()/sum()
2025-02-02 15:25:34 +01:00
Fabian Dill
894732be47 kvui: set home folder to non-default (#4590)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-02-02 02:53:16 +01:00
Jouramie
051518e72a Stardew Valley: Fix unresolved reference warning and unused imports (#4360)
* fix unresolved reference warning and unused imports

* revert stuff

* just a commit to rerun the tests cuz messenger fail
2025-02-01 22:07:08 +01:00
Spineraks
b7b78dead3 LADX: Fix generation error on minimal accessibility (#4281)
* [LADX] Fix minimal accessibility

* allow_partial for minimal accessibility

* create the correct partial_all_state

* skip our prefills rather than removing after

* dont rebuild our prefill list

---------

Co-authored-by: threeandthreee <a.l.nordstrom@gmail.com>
2025-02-01 22:03:49 +01:00
Jarno
d1167027f4 Core: Make csv options output ignore hidden options (#4539)
* Core: Make csv options output ignore hidden options

* Update Options.py

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

---------

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
2025-02-01 02:26:59 +01:00
qwint
445c9b22d6 Settings: Handle empty Groups (#4576)
* export empty groups as an empty dict instead of crashing

* Update settings.py

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

* check instance values from self as well

* Apply suggestions from code review

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

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-02-01 02:11:04 +01:00
black-sliver
67e8877143 Docs: fix lower limit of valid IDs in network protocol.md (#4579) 2025-01-31 08:38:17 +01:00
agilbert1412
1fe8024b43 Stardew valley: Add Mod Recipes tests (#4580)
* `- Add Craftsanity Mod tests

* - Add the same test for cooking

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-01-30 09:19:06 +01:00
agilbert1412
8e14e463e4 Stardew Valley: Radioactive slot machine should be a ginger island check (#4578) 2025-01-30 09:05:51 +01:00
Jouramie
b8666b2562 Stardew Valley: Remove weird magic trap test? (#4570) 2025-01-29 13:56:50 -05:00
Felix R
57afdfda6f meritous: move completion_condition to set_rules (#4567) 2025-01-29 02:03:37 +01:00
black-sliver
738c21c625 Tests: massively improve the memory leak test performance (#4568)
* Tests: massively improve the memory leak test performance

With the growing number of worlds, GC becomes the bottleneck and slows down the test.

* Tests: fix typing in general/test_memory
2025-01-29 01:52:01 +01:00
black-sliver
41898ed640 MultiServer: implement NoText and deprecate uncompressed Websocket connections (#4540)
* MultiServer: add NoText tag and handling

* MultiServer: deprecate and warn for uncompressed connections

* MultiServer: fix missing space in no compression warning
2025-01-29 01:42:46 +01:00
agilbert1412
1ebc9e2ec0 Stardew Valley: Tests: Restructure the tests that validate Mods + ER together, improved performance (#4557)
* - Unrolled and improved the structure of the test for Mods + ER, to improve total performance and performance on individual tests for threading purposes

* Use | instead of Union[]

Co-authored-by: Jouramie <16137441+Jouramie@users.noreply.github.com>

* - Remove unused using

---------

Co-authored-by: Jouramie <16137441+Jouramie@users.noreply.github.com>
2025-01-28 23:19:20 +01:00
Silvris
9466d5274e MM2: fix plando and weakness special cases (#4561) 2025-01-28 21:45:28 +01:00
NewSoupVi
a53bcb4697 KH2: Use int(..., 0) in Client #4562 2025-01-27 23:13:10 +01:00
Exempt-Medic
8c5592e406 KH2: Fix determinism by using tuples instead of sets (#4548) 2025-01-27 11:06:10 -05:00
Bryce Wilson
41055cd963 Pokemon Emerald: Update changelog (#4551) 2025-01-27 17:01:18 +01:00
Scipio Wright
43874b1d28 Noita: Add clarification to check option descriptions (#4553) 2025-01-27 10:27:43 -05:00
Bryce Wilson
b570aa2ec6 Pokemon Emerald: Clean up free fly blacklist (#4552) 2025-01-27 10:25:31 -05:00
Bryce Wilson
c43233120a Pokemon Emerald: Clarify death link and start inventory descriptions (#4517) 2025-01-27 10:24:26 -05:00
Silvris
57a571cc11 KDL3: Fix world access on non-strict open world (#4543)
* Update rules.py

* lambda capture
2025-01-27 01:52:02 +01:00
Fabian Dill
8622cb6204 Factorio: Inventory Spill Traps (#4457) 2025-01-26 22:14:39 +01:00
qwint
90417e0022 CommonClient: Expand on make_gui docstring (#4449)
* adds docstring to make_gui describing what things you might want to change without dealing with kivy/kvui directly (there are better places to document those)

* Update CommonClient.py

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

* Update CommonClient.py

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

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-01-26 13:06:27 +01:00
josephwhite
96b941ed35 Super Mario 64: Add Star Costs to Spoiler (#4544) 2025-01-25 09:36:23 -05:00
Bryce Wilson
1832bac1a3 BizHawkClient: Update README for get_memory_size (#4511) 2025-01-25 09:35:42 -05:00
qwint
86641223c1 Shivers: Stop using get_all_state cache to fix timing issue #4522
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-01-25 00:35:54 +01:00
black-sliver
cc770418f2 MultiServer: optimize PrintJSON for !release (#4545)
* MultiServer: optimize PrintJSON for !release

* MultiServer: safer comparison

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

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-01-24 23:22:33 +01:00
Scipio Wright
513e361764 TUNIC: Fix UT create_item classification (#4514)
Co-authored-by: Silent <110704408+silent-destroyer@users.noreply.github.com>
2025-01-24 17:10:58 -05:00
Silent
ddf7fdccc7 TUNIC: Add Torch Item (#4538)
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-01-24 16:57:23 -05:00
Silent
3df2dbe051 TUNIC: Add ability shuffle information to spoiler log (#4498) 2025-01-24 16:55:49 -05:00
Jasper den Brok
3d1d6908c8 Pokemon Emerald: Add Free Fly Blacklist (#4165)
Co-authored-by: Jasper den Brok <jasper.den.brok@gmail.com>
2025-01-24 16:30:21 -05:00
qwint
7474c27372 Core: Add launch function to call launch_subprocess only if multiprocessing is actually necessary (#4237)
* skips opening a subprocess if kivy (and thus the launcher gui) hasn't been loaded so stdin can function as expected on --nogui and similar

* this exists lol

* keep old function around and use new function for CC component

* fix name=None typing
2025-01-24 19:52:12 +01:00
Scipio Wright
bb0948154d TUNIC: Make the standard entrances get made with tuples instead of sets (#4546)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-01-24 12:42:31 -05:00
CookieCat
fa2816822b AHIT: Fix broken link in setup guide (#4524)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-01-23 16:45:11 -05:00
NewSoupVi
5a42c70675 Core: Fix worlds that rely on other worlds having their Entrances connected before connect_entrances, add unit test (#4530)
* unit test that get all state is called with partial entrances before connect_entrances

* fix the two worlds doing it

* lol

* unused import

* Update test/general/test_entrances.py

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

* Update test_entrances.py

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-01-22 14:00:47 +01:00
JaredWeakStrike
949527f9cb KH2: Bug fixes and game update future proofing (#4075)
Co-authored-by: qwint <qwint.42@gmail.com>
2025-01-21 17:28:33 -05:00
Scipio Wright
1a1b7e9cf4 TUNIC: Reduce range end for local_fill option #4534 2025-01-21 18:39:08 +01:00
Fabian Dill
edacb17171 Factorio: remove debug print (#4533) 2025-01-21 16:12:53 +01:00
qwint
33fd9de281 Core: Add Retry to Priority Fill (#4477)
* adds a retry to priority fill in case the one item per player optimization would cause the priority fill to fail to find valid placements

* Update Fill.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>
2025-01-21 00:56:20 +01:00
qwint
a126dee068 HK: some stuff ruff and pycodestyle complained about (#4523) 2025-01-20 23:42:12 +01:00
qwint
e2b942139a HK: Save GrubHuntGoal by value (#4521) 2025-01-20 19:10:29 +01:00
Scipio Wright
823b17c386 TUNIC: Make grass go in the regular location name group too (#4504)
* Make grass go in the normal loc group too

* Make it not overwrite old groups
2025-01-20 17:44:39 +01:00
Chris J.
05d1b2129a Docs: Update ID Overlapping Docs (#4447) 2025-01-20 11:18:09 -05:00
NewSoupVi
436c0a4104 Core: Add connect_entrances world step/stage (#4420)
* Add connect_entrances

* update ER docs

* fix that test, but also ew

* Add a test that asserts the new finalization

* Rewrite test a bit

* rewrite some more

* blank line

* rewrite rewrite rewrite

* rewrite rewrite rewrite

* RE. WRITE.

* oops

* Bruh

* I guess, while we're at it

* giga oops

* It's been a long day

* Switch KH1 over to this design with permission of GICU

* Revert

* Oops

* Bc I like it

* Update locations.py
2025-01-20 16:07:15 +01:00
Scipio Wright
96f469c737 TUNIC: Fix hero relics not being prog if hex quest is on in combat logic #4509 2025-01-20 16:04:39 +01:00
Scipio Wright
4f77abac4f TUNIC: Fix failure in 1-player grass (#4520)
* Fix failure in 1-player grass

* Update worlds/tunic/__init__.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>
2025-01-20 15:53:30 +01:00
massimilianodelliubaldini
d5cd95c7fb Docs: Clarify usage of slot data for trackers in World API doc (#3986)
* Clarify usage of slot data for trackers in world API.

* Typo.

* Update docs/world api.md

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* Update docs/world api.md

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* Update docs/world api.md

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* Update docs/world api.md

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

* Keep to 120 char lines.

---------

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-01-20 09:01:45 +01:00
Exempt-Medic
a2fbf856ff SMZ3: Change locality options earlier (#4424) 2025-01-19 23:07:01 -05:00
Exempt-Medic
4fa8c43266 FFMQ: Fix collect_item (#4433)
* Fix FFMQ collect_item
2025-01-19 23:06:09 -05:00
qwint
992841a951 CommonClient: abstract url handling so it's importable (#4068)
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
Co-authored-by: Jouramie <16137441+Jouramie@users.noreply.github.com>
2025-01-20 02:18:36 +01:00
Exempt-Medic
eb3c3d6bf2 FFMQ: Adds Items Accessibility (#4322) 2025-01-19 20:12:44 -05:00
Fabian Dill
39847c5502 WebHost: sort slots by player_id in api blueprint (#4354) 2025-01-20 02:05:07 +01:00
NewSoupVi
130232b457 Core: Make log time an optional arg & setting for Generate.py as well #4312 2025-01-20 01:56:37 +01:00
Doug Hoskisson
ca8ffe583d Zillion: Priority Dead Ends Feature (#4220) 2025-01-19 18:31:09 -05:00
Doug Hoskisson
563794ab83 Zillion: Use Useful Item Classification (#4179) 2025-01-19 18:29:13 -05:00
Mysteryem
9443861849 Zillion: Finalize item locations in either generate_output or fill_slot_data (#4121)
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-01-19 18:20:45 -05:00
Nicholas Saylor
cbf4bbbca8 OoT Adjuster: Remove per_slot_randoms (#4264) 2025-01-19 18:17:31 -05:00
Silvris
9e353ebb8e SMZ3: Fix Itemlinks with link_replacement #4099 2025-01-19 07:17:12 -05:00
Bryce Wilson
9183e8f9c9 BizHawkClient: Use built-ins for typing (#4508) 2025-01-19 10:23:06 +01:00
Bryce Wilson
0bb657d2c8 Pokemon Emerald: Use new check_locations helper (#4518) 2025-01-19 10:21:54 +01:00
Jouramie
992f192529 Stardew Valley: Improve generation performance by around 11% by moving calculating from rule evaluation to collect (#4231) 2025-01-18 20:36:01 -05:00
Fabian Dill
1c9409cac9 CommonClient: implement check_locations to send missing locations only (#4484)
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-01-19 00:26:42 +01:00
NewSoupVi
005a143e3e MultiServer: Add slot to SetReply packets (#3747)
* Add slot to datastorage set response

* update docs as well
2025-01-18 19:59:26 +01:00
CarlosBor
8732974857 ALttP: update Spanish Setup Docs (#2670)
Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
2025-01-17 21:38:59 -05:00
black-sliver
1ac8349bd4 CI: update pyright (#4506) 2025-01-17 21:30:18 +01:00
qwint
2b9fa89050 Bizhawk: adds typing to bizhawk component launch (#4505) 2025-01-17 21:22:36 +01:00
Doug Hoskisson
23ea3c0efc Core: some low-hanging fruit on the strict type check (#3416)
* Core: some low-hanging fruit on the strict type check

* bump pyright version

* bump pyright version

* bump pyright and remove file that's no longer easy
2025-01-17 20:14:21 +01:00
Pierre-Alain BESSERO
698d27aada OoT: Allow Crowd Control support for Ocarina of Time (Bizhawk) #4501
Changed the name of the default "receive" function in order to work with Crowd Control
2025-01-17 20:06:20 +01:00
Ishigh1
3a46c9fd3e LADX: Closing the client window closes the window (#4350) 2025-01-17 20:05:02 +01:00
black-sliver
9507300939 SoE: update to v050 (#4497)
* Cuts some cutscenes
* Adds meta data for tracker to detect settings
2025-01-17 18:53:29 +01:00
Scipio Wright
0d6db291de TUNIC: Reorder options (#4491)
* Reorder options

* Also make ability shuffling on by default
2025-01-17 18:30:00 +01:00
digiholic
d218dec826 MMBN3: Logic and Bug Fixes, New Checks (#3646)
* PMDs now check to make sure you have enough unlockers for all of them before any are in logic, to avoid softlocks

* Adds Humor and BlckMnd to the pool and sets logic for Villain and Comedian. Patch not yet updated to remove starting inventory

* Adds Serenade as a check

* Fixes hide and seek completion to use proper Yoka Zoo map. Updates bsdiff patch to 1.2

* Adds option for excluding Secret Area, and item/location groups for further customization

* Update worlds/mmbn3/Locations.py

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

* Update worlds/mmbn3/Regions.py

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

* Update worlds/mmbn3/__init__.py

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

* Update worlds/mmbn3/__init__.py

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

* Update worlds/mmbn3/__init__.py

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

* Replaces can_reach generic with can_reach_region or can_reach_location, where applciable

* Unlocker is now a progression item, Excluded Locations is now a Set

* Missed a merge marker

* Excluded locations is no longer a set since you can't append to a set with +=

* Excluded locations is now a set again since you apparent can append to a set with |=

* Replaces more lists with sets. Fixes wording in option descriptions

* Update worlds/mmbn3/__init__.py

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-01-17 08:41:12 -05:00
Aaron Wagener
3d5c277c31 Core: don't log warnings for plando_items and missing lttp options (#3606)
* Core: don't log a warning for the "options" that are valid in a game section but not on the options system

* don't rebuild a set every loop
2025-01-17 08:39:41 -05:00
Mysteryem
a9435dc6bb KH2: Reduce unnecessary packets sent/requested by the client (#4035) 2025-01-16 22:00:29 -05:00
Mysteryem
8f307c226b Core: Fix the distribution of Options.Range.triangular() (#4283) 2025-01-16 21:59:38 -05:00
threeandthreee
4b8f990960 LADX: Swap out invalid characters in item names (#4495) 2025-01-16 21:59:19 -05:00
threeandthreee
3a5a4b89ee LADX: improved warps across unexplored tiles (#4111) 2025-01-16 21:58:49 -05:00
JaredWeakStrike
1485882642 KH2: Fixes abilities overflowing into items and crashing the game (#4384)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-01-16 21:57:41 -05:00
Scipio Wright
2e4f5a64b3 TUNIC: Make the local_fill option load in a specific number of locations (#4488)
* Make it load in a specific number of locations

* TunicLocation -> Location

* Actually shuffle the list
2025-01-17 03:13:37 +01:00
Mysteryem
90f80ce1c1 AHiT: Various logic fixes (#4492)
* Fix Director boss photo logic

The rules were being added to for the "Director" boss in
`set_enemy_rules()`, which didn't exist because the boss created was
called "Conductor" instead.

The name of the boss has been changed to "Director", to match, because
it is more accurate due to DJ Grooves possibly being the boss instead of
The Conductor.

The missing logic was the `Hookshot Badge` requirement, however, the
boss events are only used as part of the `Camera Tourist - All Clear`
location, which requires every boss event to be reachable, and the
Toxic Flower boss also has a `Hookshot Badge` requirement, so the
missing `Hookshot Badge` for the Director boss had no effect on logic.

The boss event locations are hidden from spoiler output, so to get a
spoiler showing the Director boss event accessed before having
`Hookshot Badge`, spoiler output had to be modified to also show the
hidden locations. Example sphere from playthrough that should not be
possible because it gets the `Hookshot Badge` and the `Conductor` event
(now renamed to `Director`) in the same sphere:

```
5: {
  Act Completion (Time Rift - Dead Bird Studio): Relic (Crayon Box)
  Conductor - Dead Bird Studio Basement: Conductor
  Dead Bird Studio (Rift) - Page: Behind Cardboard Planet: Time Piece
  Dead Bird Studio (Rift) - Page: Near Time Rift Gate: Hookshot Badge
  Picture Perfect - Hats Buy Building: Metro Ticket - Blue
  Snatcher - Your Contract has Expired: Snatcher
}
```

* Add missing Hookshot + Painting logic for Toilet boss picture

Includes the Hard logic of crossing the gap with a cherry bridge instead
of hookshot and the expert logic of being able to skip the boss firewall
with a cherry hover.

* Fix Alpine Skyline - Goat Outpost Horn region

`Alpine Skyline - Goat Outpost Horn` is accessible from The Illness has
Spread, but was being added to the region that is only accessible from
Alpine Free Roam. `Alpine Skyline - Goat Outpost Horn` has been moved to
the region that is accessible from both The Illness has Spread and
Alpine Free Roam.

* Add missing HitType.umbrella logic for Top of HQ Coin in Beat the Heat

Like Heating up Mafia Town, the cannon to the Mafia HQ area only opens
once all the faucets have been turned off by hitting them. This requires
the Umbrella when umbrella logic is enabled, but the Snatcher Coin on
top of Mafia HQ was missing this requirement when accessed from Beat the
Heat.

* Add missing Main Objective requirement for auto-completed Bonus Stamps

When a Main Objective is not excluded, but the bonuses are excluded, the
bonuses auto-complete once the Main Objective is completed. The
requirement to complete the Main Objective was missing, so the logic was
incorrectly awarding bonus stamps as soon as a Contract was unlocked,
even when it was not possible to complete the Main Objective of that
Contract.

* Add missing Hookshot requirement for The Arctic Cruise - Toilet from Bon Voyage!

`The Arctic Cruise - Toilet` is accessed from the `Cruise Ship` region,
but it is only present in the Ship Shape and Bon Voyage! acts.

Ship Shape and Rock the Boat can access `Cruise Ship` without any items,
but Bon Voyage! requires the Hookshot Badge to reach `Cruise Ship`.

With how the logic was set up, it was incorrectly giving access to
`The Arctic Cruise - Toilet` if the player had access to Bon Voyage!
but only had access to `Cruise Ship` through Rock the Boat.

* Fix Expert logic Rush Hour-only ticket skips

The code was checking `if not world.options.NoTicketSkips:`, but that
would only be `True` for `False`. For "rush_hour" (for Rush Hour-only
ticket skips), it would be `False`, causing Rush Hour-only ticket skips
to act as if ticket skips were disabled.

* Remove Mystifying Time Mesa: Zipline gaining Hookshot requirement in moderate logic

Alpine Skyline - Mystifying Time Mesa: Zipline does not normally
require Hookshot Badge because it is an implied requirement due to only
being accessible from Alpine Free Roam which does require Hookshot
Badge. In normal logic difficulty, the location does not have an
explicit Hookshot Badge requirement, but moderate logic was adding a
Hookshot Badge requirement. This extraneous Hookshot Badge requirement
has been removed.

* Fix Act Completion (Queen Vanessa's Manor) not being accessible with Dweller Mask/Brewing Hat

It was logically requiring the Umbrella hit type only, whereas all the
other locations in Queen Vanessa's Manor require the Dweller Bell hit
type which additionally allows Dweller Mask or Brewing Hat.

* Remove Dweller Mask requirement for Subcon Forest - Tall Tree Hookshot Swing

The Dweller Mask is not used in the intended vanilla route to get this
item, so this requirement seems to have been a mistake.

* Remove unused SDJ option for Subcon Forest - Long Tree Climb Chest

Hard logic can already reach this location with nothing (other than
paintings), so the "or" logic of being able to perform an SDJ was
unused.

* Require any non-HUMT Mafia Town act for Hot Air Balloon with nothing

Two buckets/beach balls are required to bucket/ball hover, but there is
only a single beach ball accessible in Heating Up Mafia Town, and
no accessible buckets.

There is an alternative strategy for Top of Lighthouse that only
requires a single beach ball, so that location can still be reached with
nothing from Heating Up Mafia Town.

* Use `get_difficulty()` helper in `set_enemy_rules`

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

---------

Co-authored-by: Exempt-Medic <60412657+exempt-medic@users.noreply.github.com>
2025-01-17 03:10:41 +01:00
black-sliver
78904151b0 Test: fix typo in pytest.ini (#4502)
The typo disabled a bunch of tests :S
2025-01-17 02:10:48 +01:00
black-sliver
9d4bd6eebd pytest: only check tests/ and worlds/ (#4500)
This allows having failing tests in CI in worlds_disabled
and allows moving worlds there to disable tests.
2025-01-17 01:53:50 +01:00
black-sliver
5c56dc0357 SoE: fix logic for drain cave with OoB (#4496)
Also adds py3.13 compat and missing hash for sdist
2025-01-17 01:27:36 +01:00
NewSoupVi
c7810823e8 Core: Fix crash when trying to log an exception (#4313)
* Fix crash when trying to log an exception

In https://github.com/ArchipelagoMW/Archipelago/pull/3028, we added a new logging filter which checked `record.msg`. 

However, you can pass whatever you want into a logging call. In this case, what we missed was ecc3094c70/MultiServer.py (L530C1-L530C37), where we pass an Exception object as the message. This currently causes a crash with the new filter.

The logging module supports this. It has no typing and can handle passing objects as messages just fine.

What you're supposed to use, as far as I understand it, is `record.getMessage()` instead of `record.msg`.

* Update Utils.py

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

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-01-16 18:35:07 +01:00
threeandthreee
902d03d447 LADX: Stabilize Item Pool Option (#3935)
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-01-15 21:42:19 -05:00
Scipio Wright
b7621a0923 TLoZ: Fix typo in setup guide (#4486) 2025-01-16 00:52:12 +01:00
Silent
b7baaed391 TUNIC: Grass Randomizer (#3913)
* Fix certain items not being added to slot data

* Change where items get added to slot data

* Add initial grass randomizer stuff

* Fix rules

* Update grass.py

Improve location names

* Remove wand and gun from logic

* Update __init__.py

* Fix logic for two pieces of grass in atoll

* Make early bushes only contain grass

* Backport changes to grass rando (#20)

* Backport changes to grass rando

* add_rule instead of set_rule for the special cases, add special cases for back of swamp laurels area cause I should've made a new region for the swamp upper entrance

* Remove item name group for grass

* Update grass rando option descriptions

- Also ignore grass fill for single player games

* Ignore grass fill option for solo rando

* Update er_rules.py

* Fix pre fill issue

* Remove duplicate option

* Add excluded grass locations back

* Hide grass fill option from simple ui options page

* Check for start with sword before setting grass rules

* Update worlds/tunic/options.py

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

* Exclude grass from get_filler_item_name

- non-grass rando games were accidentally seeing grass items get shuffled in as filler, which is funny but probably shouldn't happen

* Update worlds/tunic/__init__.py

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

* Apply suggestions from code review

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

* change the rest of grass_fill to local_fill

* Filter out grass from filler_items

* remove -> discard

* Update worlds/tunic/__init__.py

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

* change has_stick to has_melee

* Update grass list with combat logic regions

* More fixes from combat logic merge

* Fix some dumb stuff (#21)

* Reorganize pre fill for grass

* Update option value passthrough

* Update __init__.py

* Fix region name

* Make separate pools for the grass and non-grass fills (#22)

* Make separate pools for the grass and non-grass fills

* Update worlds/tunic/__init__.py

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

* Fix those things in the PR (#23)

* Use excludable property

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

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-01-16 00:17:07 +01:00
Fabian Dill
9dac7d9cc3 MultiServer: update InvalidPacket text for location scouts (#4485) 2025-01-15 21:50:20 +01:00
Star Rauchenberger
1eefe23f11 Lingo: Add speed boost mode (#3989)
* Add speed boost mode

* Update generated.dat

* Modify the actual trap weights option when speed boost mode is on

* EOF newline

* Update generated.dat
2025-01-15 21:13:29 +01:00
Exempt-Medic
207a76d1b5 OoT: Two Bugfixes (#4389) 2025-01-14 16:39:13 -05:00
Fabian Dill
01df35f215 Factorio: fix Evolution Trap crashing bound server (#4366) 2025-01-14 22:24:46 +01:00
NewSoupVi
bedf746f1d MultiServer: Revert hints being created for already found locations #4367 2025-01-14 21:37:10 +01:00
Exempt-Medic
b91a7ac6fb LADX: Move Locality Changes Earlier (#4478) 2025-01-14 13:52:58 -05:00
agilbert1412
79e6beeec3 Stardew Valley: Update Mod Content (#4416) 2025-01-14 12:47:12 -05:00
Exempt-Medic
dae9d4c575 LTTP: Fix Itemlinks (#4479) 2025-01-14 12:34:40 -05:00
Nicholas Saylor
04928bd83d DKC3: Remove unused variables and imports #4302 2025-01-14 10:49:30 +01:00
Scipio Wright
0f3818e711 Utils: Visualize Regions showing the reachable regions in color (#4436)
* Utils with coloring

* Update example use

* Update Utils.py

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

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-01-14 10:45:59 +01:00
threeandthreee
0f1dc6e19c Codeowners: @threeandthreee as LADX maintainer #4216 2025-01-14 01:35:29 +01:00
Exempt-Medic
ffd0c8b341 Blasphemous: Move Locality Changes Earlier (#4422) 2025-01-13 19:34:56 -05:00
Exempt-Medic
6220963195 Tests: No Creating Items/Locations/Regions in __init__ (#4474) 2025-01-13 18:35:44 -05:00
Exempt-Medic
20119e3162 Faxanadu: Fix generations with itemlinks (#4395) 2025-01-13 18:35:01 -05:00
Louis M
4cb8fa3cdd Aquaria: Fixing itemlink not working (#4473) 2025-01-13 20:09:39 +01:00
qwint
93e8613da7 HK: Abstract and default grub counts (#4336) 2025-01-13 11:08:46 -05:00
Aaron Wagener
f9cc19e150 Fill: Crash if there are remaining unfilled locations (#2830) 2025-01-13 10:52:10 -05:00
Sam Merritt
0f1c119c76 Factorio: improve error message for config validation (#4421) 2025-01-13 09:52:21 +01:00
Alchav
4c734b467f LTTP: Shop and Arrow fixes (#4067)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-01-13 08:32:59 +01:00
Silvris
1f966ee705 BizhawkClient: set metadata from patch file (#4346) 2025-01-12 19:01:16 +01:00
Nicholas Saylor
172ad4e57d Adventure: Optimize imports (#4300) 2025-01-12 19:00:20 +01:00
Justus Lind
3f935aac13 Muse Dash: Change Data storage from a .txt file to a .py file and Filter Webhost Song Lists correctly (#4234) 2025-01-12 18:59:16 +01:00
qwint
9928639ce2 Docs: Fix Typo in Rich Text Options Flag Documentation (#4462) 2025-01-12 11:01:42 -05:00
Jouramie
0fc722cb28 Stardew Valley: Remove seasonal farming event, use regions instead (#4379) 2025-01-12 11:01:02 -05:00
Bryce Wilson
4edca0ce54 BizHawkClient: Add command to get size of memory domain (#4439)
* Mega Man 2: Remove mm2 commands from client if rom size too small
2025-01-12 08:03:31 +01:00
Bryce Wilson
70942eda8c BizHawkClient: Fix version warning not falling through to regular execution (#4463) 2025-01-12 07:54:48 +01:00
NewSoupVi
adcb2f59ca MultiServer: Correct tying of Context.groups (#4460) 2025-01-11 22:16:01 +01:00
Alchav
29b34ca9fd Pokémon R/B: Fix Route 11-E to Route-12-W logic (#4435) 2025-01-11 01:31:29 +01:00
Fabian Dill
d97ee5d209 Core: update certifi (#4453) 2025-01-10 23:28:57 +01:00
Fabian Dill
c2bd9df0f7 Subnautica: fix typo and remove no longer used logger (#4456) 2025-01-10 23:28:38 +01:00
Scipio Wright
112bfe0933 TUNIC: Logic for Beneath the Vault Bridge Switch #4432 2025-01-10 22:48:15 +01:00
Alchav
96b500679d LTTP: Add missing GT Pre-Moldorm Bomb Wall Logic (#4440) 2025-01-10 22:40:50 +01:00
Scipio Wright
258ea10c52 TUNIC: Modify UT support to make a better pattern (#3860)
* Modify UT support to make a better pattern

* Handle keyerror for logic_rules option

* Missed self.passthrough value setting

* Less laziness for passthrough

* Remove extra newline

* Fix missing using_ut = True, also remove now unnecessary try except since 0.5.1 is out

* New UT thing, it goes in this PR because it's been open for 5 months for a very very tiny change
2025-01-10 21:49:13 +01:00
lordlou
043ba418ec SM generate without rom (#3460)
* - SM now displays message when getting an item outside for someone else (fills ROM item table)

This is dependant on modifications done to sm_randomizer_rom project

* First working MultiWorld SM

* some missing things:

- player name inject in ROM and get in client
- end game get from ROM in client
- send self item to server
- add player names table in ROM

* replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better)

* - reenabled balancing

* post rebase fixes

* updated SmClient.py

* + added VariaRandomizer LICENSE

* + added sm_randomizer_rom project (which builds sm.ips)

* Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning

* properly revert change made to CollectionState and more cleaning

* Fixed multiworld support patch not working with VariaRandomizer's

* missing file commit

* Fixed syntax error in unused code to satisfy Linter

* Revert "Fixed multiworld support patch not working with VariaRandomizer's"

This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b.

* many fixes and improovement

- fixed seeded generation
- fixed broken logic when more than one SM world
- added missing rules for inter-area transitions
- added basic patch presence for logic
- added DoorManager init call to reflect present patches for logic
- moved CollectionState addition out of BaseClasses into SM world
- added condition to apply progitempool presorting only if SM world is present
- set Bosses item id to None to prevent them going into multidata
- now use get_game_players

* first working (most of the time) progression generation for SM using VariaRandomizer's rules, items, locations and accessPoint (as regions)

* first working single-world randomized SM rom patches

* - SM now displays message when getting an item outside for someone else (fills ROM item table)

This is dependant on modifications done to sm_randomizer_rom project

* First working MultiWorld SM

* some missing things:

- player name inject in ROM and get in client
- end game get from ROM in client
- send self item to server
- add player names table in ROM

* replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better)

* - reenabled balancing

* post rebase fixes

* updated SmClient.py

* + added VariaRandomizer LICENSE

* + added sm_randomizer_rom project (which builds sm.ips)

* Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning

* properly revert change made to CollectionState and more cleaning

* Fixed multiworld support patch not working with VariaRandomizer's

* missing file commit

* Fixed syntax error in unused code to satisfy Linter

* Revert "Fixed multiworld support patch not working with VariaRandomizer's"

This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b.

* many fixes and improovement

- fixed seeded generation
- fixed broken logic when more than one SM world
- added missing rules for inter-area transitions
- added basic patch presence for logic
- added DoorManager init call to reflect present patches for logic
- moved CollectionState addition out of BaseClasses into SM world
- added condition to apply progitempool presorting only if SM world is present
- set Bosses item id to None to prevent them going into multidata
- now use get_game_players

* Fixed multiworld support patch not working with VariaRandomizer's

Added stage_fill_hook to set morph first in progitempool
Added back VariaRandomizer's standard patches

* + added missing files from variaRandomizer project

* + added missing variaRandomizer files (custom sprites)

+ started integrating VariaRandomizer options (WIP)

* Some fixes for player and server name display

- fixed player name of 16 characters reading too far in SM client
- fixed 12 bytes SM player name limit (now 16)
- fixed server name not being displayed in SM when using server cheat ( now displays RECEIVED FROM ARCHIPELAGO)
- request: temporarly changed default seed names displayed in SM main menu to OWTCH

* Fixed Goal completion not triggering in smClient

* integrated VariaRandomizer's options into AP (WIP)

- startAP is working
- door rando is working
- skillset is working

* - fixed itemsounds.ips crash by always including nofanfare.ips into multiworld.ips (itemsounds is now always applied and "itemsounds" preset must always be "off")

* skillset are now instanced per player instead of being a singleton class

* RomPatches are now instanced per player instead of being a singleton class

* DoorManager is now instanced per player instead of being a singleton class

* - fixed the last bugs that prevented generation of >1 SM world

* fixed crash when no skillset preset is specified in randoPreset (default to "casual")

* maxDifficulty support and itemsounds removal

- added support for maxDifficulty
- removed itemsounds patch as its always applied from multiworld patch for now

* Fixed bad merge

* Post merge adaptation

* fixed player name length fix that got lost with the merge

* fixed generation with other game type than SM

* added default randoPreset json for SM in playerSettings.yaml

* fixed broken SM client following merge

* beautified json skillset presets

* Fixed ArchipelagoSmClient not building

* Fixed conflict between mutliworld patch and beam_doors_plms patch

- doorsColorsRando now working

* SM generation now outputs APBP

- Fixed paths for patches and presets when frozen

* added missing file and fixed multithreading issue

* temporarily set data_version = 0

* more work

- added support for AP starting items
- fixed client crash with gamemode being None
- patch.py "compatible_version" is now 3

* commited missing asm files

fixed start item reserve breaking game (was using bad write offset when patching)

* Nothing item are now handled game-side. the game will now skip displaying a message box for received Nothing item (but the client will still receive it).

fixed crash in SMClient when loosing connection to SNI

* fixed No Energy Item missing its ID

fixed Plando

* merge post fixes

* fixed start item Grapple, XRay and Reserve HUD, as well as graphic beams (except ice palette color)

* fixed freeze in blue brinstar caused by Varia's custom PLM not being filled with proper Multiworld PLM address (altLocsAddresses)

* fixed start item x-ray HUD display

* Fixed start items being sent by the server (is all handled in ROM)

Start items are now not removed from itempool anymore
Nothing Item is now local_items so no player will ever pickup Nothing. Doing so reduces contribution of this world to the Multiworld the more Nothing there is though.
Fixed crash (and possibly passing but broken) at generation where the static list of IPSPatches used by all SM worlds was being modified

* fixed settings that could be applied to any SM players

* fixed auth to server only using player name (now does as ALTTP to authenticate)

* - fixed End Credits broken text

* added non SM item name display

* added all supported SM options in playerSettings.yaml

* fixed locations needing a list of parent regions (now generate a region for each location with one-way exits to each (previously) parent region

did some cleaning (mainly reverts on unnecessary core classes

* minor setting fixes and tweaks

- merged Area and lightArea settings
- made missileQty, superQty and powerBombQty use value from 10 to 90 and divide value by float(10) when generating
- fixed inverted layoutPatch setting

* added option start_inventory_removes_from_pool

fixed option names formatting
fixed lint errors
small code and repo cleanup

* Hopefully fixed ROR2 that could not send any items

* - fixed missing required change to ROR2

* fixed 0 hp when respawning without having ever saved (start items were not updating the save checksum)

* fixed typo with doors_colors_rando

* fixed checksum

* added custom sprites for off-world items (progression or not)

the original AP sprite was made with PierRoulette's SM Item Sprite Utility by ijwu

* - added missing change following upstream merge

- changed patch filename extension from apbp to apm3 so patch can be used with the new client

* added morph placement options: early means local and sphere 1

* fixed failing unit tests

* - fixed broken custom_preset options

* - big cleanup to remove unnecessary or unsupported features

* - more cleanup

* - moved sm_randomizer_rom and all always applied patches into an external project that outputs basepatch.ips

- small cleanup

* - added comment to refer to project for generating basepatch.ips (https://github.com/lordlou/SMBasepatch)

* fixed g4_skip patch that can be not applied if hud is enabled

* - fixed off world sprite that can have broken graphics (restricted to use only first 2 palette)

* - updated basepatch to reflect g4_skip removal

- moved more asm files to SMBasepatch project

* - tourian grey doors at baby metroid are now always flashing (allowing to go back if needed)

* fixed wrong path if using built as exe

* - cleaned exposed maxDifficulty options

- removed always enabled Knows

* Merged LttPClient and SMClient into SNIClient

* added varia_custom Preset Option that fetch a preset (read from a new varia_custom_preset Option) from varia's web service

* small doc precision

* - added death_link support

- fixed broken Goal Completion
- post merge fix

* - removed now useless presets

* - fixed bad internal mapping with maxDiff

- increases maxDiff if only Bosses is preventing beating the game

* - added support for lowercase custom preset sections (knows, settings and controller)

- fixed controller settings not applying to ROM

* - fixed death loop when dying with Door rando, bomb or speed booster as starting items

- varia's backup save should now be usable (automatically enabled when doing door rando)

* -added docstring for generated yaml

* fixed bad merge

* fixed broken infinity max difficulty

* commented debug prints

* adjusted credits to mark progression speed and difficulty as Non Available

* added support for more than 255 players (will print Archipelago for higher player number)

* fixed missing cleanup

* added support for 65535 different player names in ROM

* fixed generations failing when only bosses are unreachable

* - replaced setting maxDiff to infinity with a bool only affecting boss logics if only bosses are left to finish

* fixed failling generations when using 'fun' settings

Accessibility checks are forced to 'items' if restricted locations are used by VARIA following usage of 'fun' settings

* fixed debug logger

* removed unsupported "suits_restriction" option

* fixed generations failing when only bosses are unreachable (using a less intrusive approach for AP)

* - fixed deathlink emptying reserves

- added death_link_survive option that lets player survive when receiving a deathlink if the have non-empty reserves

* - merged death_link and death_link_survive options

* fixed death_link

* added a fallback default starting location instead of failing generation if an invalid one was chosen

* added Nothing and NoEnergy as hint blacklist

added missing NoEnergy as local items and removed it from progression

* SM Varia can now generate without ROM

* removed stage_assert_generate
2025-01-10 21:46:17 +01:00
Fabian Dill
894a8571ee kvui: add autocompleting new hint text input (#3535)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
Co-authored-by: Silvris <58583688+Silvris@users.noreply.github.com>
2025-01-10 20:21:02 +01:00
ruby0b
874197d940 Linux: move the user home Archipelago dir to $XDG_DATA_HOME (#4347)
This affects builds with non-writable installation directories.
Instead of saving data in ~/Archipelago we now use $XDG_DATA_HOME/Archipelago
(defaulting to ~/.local/share/Archipelago).
If ~/Archipelago still exists we move it to the new location and link ~/Archipelago to it.

Motivation: This follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/)
to at least some degree and doesn't clutter the user's home directory.
2025-01-10 01:27:49 +01:00
agilbert1412
d3ed40cd4d Stardew Valley: Hide the Mods from the simple options page (#4446) 2025-01-08 08:13:32 +01:00
Aaron Wagener
a29ba4a6c4 The Messenger: reduce strictness of output path check (#4442) 2025-01-07 23:11:26 +01:00
Fabian Dill
fe06fe075e Factorio: add fluid mining technology to logic requirements (#4385) 2025-01-07 23:06:48 +01:00
qwint
de58cb03da Core: Pickle hints by value (#4441) 2025-01-07 22:24:19 +01:00
TheLX5
3204680662 SNIClient: Let clients based on SNIClient monitor packages via on_package method (#3093) 2025-01-07 00:10:23 +01:00
shananas
07e896508c KH2: Doc Updates (#4434) 2025-01-06 14:02:04 -05:00
Scipio Wright
2d3faea713 Core: Include unfilled locations in error when there are not enough locations for progression items (#4285)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-01-06 09:52:33 -05:00
eudaimonistic
7c89a83d19 Docs: Clarify !alias commands in commands_en.md (#4426)
Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
2025-01-06 09:42:18 -05:00
qwint
16f8b41cb9 Core: add docstrings for launcher components (#4148)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-01-06 09:35:37 -05:00
qwint
7d506990f5 HK: add location counts to option descriptions (#4083) 2025-01-06 09:35:12 -05:00
qwint
aadcb4c903 HK: use rich_text_options_doc to make webhost formatting look better (#4079) 2025-01-06 09:21:44 -05:00
coveleski
daf94fcdb2 Pokemon RB: Fixing misnamed locations (#4404) 2025-01-04 08:27:41 -05:00
Kory Dondzila
1cef659b78 Shivers: Fix spelling error in naming (#4425) 2025-01-04 07:42:34 -05:00
Scipio Wright
25381ef2c2 Core: Make the error for a missing option display the player name (#4430) 2025-01-04 07:29:30 -05:00
Mysteryem
5927926314 Blasphemous: Fix starting_location: random affecting all Blasphemous worlds (#4428)
Option resolution for the `StartingLocation` option (the only
`ChoiceIsRandom` subclass) was writing to the `randomized` attribute on
the class instead of on the instance, meaning that
`self.options.starting_location.randomized` would be `True` for all
Blasphemous players in the multiworld if any one of the players set
their `StartingLocation` option to `"random"`.

This patch fixes the issue by writing to the `randomized` attribute on
the new instance instead of on the class.
2025-01-03 07:03:30 -05:00
CaitSith2
2a11d9fec3 try again to award the starting items post cutscene if needed. (#4408) 2025-01-02 19:45:32 -08:00
Nicholas Saylor
82c44aaa22 FFMQ: Fix encoding issue with Game Page (#4299) 2025-01-02 22:03:07 -05:00
Kory Dondzila
a7b483e4b7 Shivers: Adds ixupi captures priority option (#4403) 2025-01-02 10:12:00 -05:00
Fabian Dill
917335ec54 Core: it's 2025 (#4417) 2025-01-01 02:02:18 +01:00
Mysteryem
6e59ee2926 Zork Grand Inquisitor: Precollect Start with Hotspot Items in deterministic order (#4412) 2024-12-31 09:16:29 -05:00
Mysteryem
3c9270d802 FFMQ: Create itempool in deterministic order (#4413) 2024-12-31 09:02:02 -05:00
Mysteryem
c4bbcf9890 TUNIC: Add relics and abilities to the item pool in deterministic order (#4411) 2024-12-30 23:57:09 -05:00
NewSoupVi
8dbecf3d57 The Witness: Make location order in the spoiler log deterministic (#3895)
* Fix location order

* Update worlds/witness/data/static_logic.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-12-30 00:50:39 +01:00
Fabian Dill
0de1369ec5 Factorio: hide hidden vanilla techs in factoriopedia too (#4332) 2024-12-29 11:56:41 -08:00
Fabian Dill
fa95ae4b24 Factorio: require version that fixes a randomizer exploit (#4391) 2024-12-29 11:55:40 -08:00
CaitSith2
2065246186 Factorio: Make it possible to use rocket part in blueprint parameterization. (#4396)
This allows for example, making a blueprint of your rocket silo with requester chests specifying a request for the 2-8 rocket part ingredients needed to build the rocket.
2024-12-29 20:13:34 +01:00
Kory Dondzila
ca1b3df45b Shivers: Follow on PR to cleanup options #4401 2024-12-27 23:38:01 +01:00
Kory Dondzila
3bcc86f539 Shivers: Add events and fix require puzzle hints logic (#4018)
* Adds some events, renames things, fails for many players.

* Adds entrance rules for requires hints.

* Cleanup and add goal item.

* Cleanup.

* Add additional rule.

* Event and regions additions.

* Updates from merge.

* Adds collect behavior option.

* Fix missing generator location.

* Fix whitespace and optimize imports.

* Switch location order back.

* Add name replacement for storage.

* Fix test failure.

* Improve puzzle hints required.

* Add missing locations and cleanup indirect conditions.

* Fix naming.

* PR feedback.

* Missed comment.

* Cleanup imports, use strings for option equivalence, and update option description.

* Fix rule.

* Create rolling buffer goal items and remove goal items and location from default options.

* Cleanup.

* Removes dateutil.

* Fixes Subterranean World information plaque.
2024-12-27 21:07:55 +01:00
BadMagic100
218f28912e Core: Generic Entrance Rando (#2883)
* Initial implementation of Generic ER

* Move ERType to Entrance.Type, fix typing imports

* updates based on testing (read: flailing)

* Updates from feedback

* Various bug fixes in ERCollectionState

* Use deque instead of queue.Queue

* Allow partial entrances in collection state earlier, doc improvements

* Prevent early loops in region graph, improve reusability of ER stage code

* Typos, grammar, PEP8, and style "fixes"

* use RuntimeError instead of bare Exceptions

* return tuples from connect since it's slightly faster for our purposes

* move the shuffle to the beginning of find_pairing

* do er_state placements within pairing lookups to remove code duplication

* requested adjustments

* Add some temporary performance logging

* Use CollectionState to track available exits and placed regions

* Add a method to automatically disconnect entrances in a coupled-compliant way

 Update docs and cleanup todos

* Make find_placeable_exits deterministic by sorting blocked_connections set

* Move EntranceType out of Entrance

* Handle minimal accessibility, autodetect regions, and improvements to disconnect

* Add on_connect callback to react to succeeded entrance placements

* Relax island-prevention constraints after a successful run on minimal accessibility; better error message on failure

* First set of unit tests for generic ER

* Change on_connect to send lists, add unit tests for EntranceLookup

* Fix duplicated location names in tests

* Update tests after merge

* Address review feedback, start docs with diagrams

* Fix rendering of hidden nodes in ER doc

* Move most docstring content into a docs article

* Clarify when randomize_entrances can be called safely

* Address review feedback

* Apply suggestions from code review

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

* Docs on ERPlacementState, add coupled/uncoupled handling to deadend detection

* Documentation clarifications

* Update groups to allow any hashable

* Restrict groups from hashable to int

* Implement speculative sweeping in stage 1, address misc review comments

* Clean unused imports in BaseClasses.py

* Restrictive region/speculative sweep test

* sweep_for_events->advancement

* Remove redundant __str__

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

* Allow partial entrances in auto indirect condition sweep

* Treat regions needed for logic as non-dead-end regardless of if they have exits, flip order of stage 3 and 4 to ensure there are enough exits for the dead ends

* Typing fixes suggested by mypy

* Remove erroneous newline 

Not sure why the merge conflict editor is different and worse than the normal editor. Crazy

* Use modern typing for ER

* Enforce the use of explicit indirect conditions

* Improve doc on required indirect conditions

---------

Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: alwaysintreble <mmmcheese158@gmail.com>
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2024-12-27 21:04:02 +01:00
Exempt-Medic
b9642a482f KH2: Using fast_fill instead of fill_restrictive (#4227) 2024-12-26 17:04:21 -05:00
Mysteryem
33ae68c756 DS3: Convert post_fill to stage_post_fill for better performance (#4122)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-12-26 08:50:18 -05:00
NewSoupVi
62942704bd The Witness: Add info about which door items exist in the pool to slot data (#3583)
* This feature is just broken lol

* simplify

* mypy

* Expand the unit test for forbidden doors
2024-12-25 21:55:15 +01:00
NewSoupVi
fe81053521 Core: Give the option to worlds to have a remaining fill that respects excluded locations (#3738)
* Give the option to worlds to have a remaining fill that respects excluded

* comment
2024-12-25 21:53:05 +01:00
NewSoupVi
222c8aa0ae Core: Reword item classification definitions to allow for progression + useful (#3925)
* Core: Reword item classification definitions to allow for progression + useful

* Update network protocol.md

* Update world api.md

* Update Fill.py

* Docstrings

* Update BaseClasses.py

* Update advanced_settings_en.md

* Update advanced_settings_en.md

* Update advanced_settings_en.md

* space
2024-12-25 21:47:51 +01:00
NewSoupVi
845000d10f Docs: Make an actual LogicMixin spec & explanation (#3975)
* Docs: Make an actual LogicMixin spec & explanation

* Update world api.md

* Update world api.md

* Update world api.md

* Update world api.md

* Update world api.md

* Update world api.md

* Update world api.md

* Update world api.md

* Update world api.md

* Update world api.md

* Update docs/world api.md

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

* Update docs/world api.md

* Update world api.md

* Code corrections / actually follow own spec

* Update docs/world api.md

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

* Update world api.md

* Update world api.md

* Reorganize / Rewrite the parts about optimisations a bit

* Update world api.md

* Write a big motivation paragraph

* Update world api.md

* Update world api.md

* line break issues

* Update docs/world api.md

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

* Update docs/world api.md

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

* Update docs/world api.md

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

* Update world api.md

* Update docs/world api.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-12-25 21:47:17 +01:00
NewSoupVi
b05f81b4b4 The Witness: Fix bridge/elevator items being progression when they shouldn't be #4392 2024-12-25 10:58:27 +01:00
Mysteryem
6c1dc5f645 Landstalker: Fix paths Lantern logic affecting other Landstalker worlds (#4394)
The data from `WORLD_PATHS_JSON` is supposed to be constant logic data
shared by all Landstalker worlds, but `add_path_requirements()` was
modifying this data such that after adding a `Lantern` requirement for a
dark region, subsequent Landstalker worlds to have their logic set could
also be affected by this `Lantern` requirement and previous Landstalker
worlds without damage boosting logic could also be affected by this
`Lantern` requirement because they could all be using the same list
instances. This issue would only occur for paths that have
`"requiredItems"` because all paths without required items would create
a new empty list, avoiding the problem.

The items in `data["itemsPlacedWhenCrossing"]` were also getting added
once for each Landstalker player, but there are no paths that have both
`"itemsPlacedWhenCrossing"` and `"requiredItems"`, so all such cases
would start from a new empty list of required items and avoid modifying
`WORLD_PATHS_JSON`.
2024-12-24 20:44:47 -05:00
Dinopony
5578ccd578 Landstalker: Fix issues on generation (#4345) 2024-12-24 14:08:03 -05:00
Mysteryem
78637c96a7 Tests: Add spheres test for missing indirect conditions (#3924)
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-12-24 12:38:46 -05:00
Richard Snider
f3ec82962e Core: Add JSONMessagePart for Hint Status (Hint Priority) (#4387)
* add hint_status JSONMessagePart handling

* add docs for hint_status JSONMessagePart

* fix link ordering

* Rename hint_status type in docs

Co-authored-by: Emily <35015090+EmilyV99@users.noreply.github.com>

* Remove redundant explanation of hint_status field

Co-authored-by: Emily <35015090+EmilyV99@users.noreply.github.com>

* Fix formatting on hint status docs again

Co-authored-by: Emily <35015090+EmilyV99@users.noreply.github.com>

---------

Co-authored-by: Emily <35015090+EmilyV99@users.noreply.github.com>
2024-12-22 19:05:43 +01:00
DrBibop
4f590cdf7b Inscryption: Implement new game (#3621)
* Worked locally before that so this is a lot of work . So, initial push

* Changes in init with better create_regions (Thanks to Phar on discord). Add a rule for victory. Change the regions list to remove menu in the destination.

* Added tests for location rules and changed rule locations to lists instead of sets

* Fixed game var in InscryptionLocation

* Fixed location access by using the same system from The Messenger

* Remove unuse rules in init and add region rules. Add all the act 2 locations and items.

* Add locations rule for the left of the bridge in act 2

* Added test for bridge requirement and added a dash to locationfor clarity

* Added more act 2 rules and removed completion rule

* Created docs for website, added Salmon Card item, marked multiple items as "progression", renamed tomb checks, added more location rules, re-added completion rule

* Renamed tower bath check to "Tentacle", added monocle as requirement for some checks, adjusted setup doc a bit

* Added tentacle to monocle test

* Added forest burrow chest rule

* Switch the two clock location because the id was swapped and screwed with the logic

* Added Ancient Obol rule and adjusted docs

* Added act 3 locations/items/rules/tests

* Added drone & battery to trader rules

* Fixed tutorial docs, added more act 3 rules, renamed holo pelt locations

* Add an option for the optional death card feature

* Added well check and quill item, added rules and tests

* Renamed Gems module and Gems drone

* Added slot data options

* Added rule for act 3 middle pelt

* Added option for randomize ability and uptade the randomize deck option to fit the new setup

* Added randomize ability in slot data

* Added more requirements for mycologists boss since it's pretty much an impossible fight early on

* Finished the french translation of the installation guide

* Changed the french title in the guide

* Added goal option and tests associated to it + fixed goal requirement missing quill

* Added goal option to docs and removed references to the now discarded API mod. Fixed some french translations.

* Added ourobot item + renamed some goal settings

* Fixed locations and items for act 1 goal

* Added skip tutorial option. Cleanup and rename of some options. Added tower requirement for Mycologist Key check. Fixed missing comma in act 2 locations oopsies.

* Added missing rules for Extra Battery, Nano Armor and Goobert's painting

* Added act 1 deathlink behaviour and epitaph pieces randomization options + made pieces progressive + adjusted docs

* Fixed some docs typos

* Added act 3 clock rule. Paintings 2, 3 and Goobert's painting can no longer contain progression items.

* New options system and fixed act 1 goal option breaking

* Added skip epilogue and painting checks balancing options. Renamed randomize abilities to randomize sigils. Fixed generation issue with epitaph pieces randomization. Goobert's painting no longer forces filler. Removed traps option for now. Reworded some option descriptions.

* Attempting type fix for python 3.8

* Attempting type fix for python 3.8 again

* Added starting only option for randomize deck

* Fixed arbitrary rule error

* Import fix attempt

* Migrated to DeathLinkMixin instead of creating a custom DeathLink option, cleaned up imports, renamed Death Link related options to include "death_link" instead of "deathlink", replaced numeral values for option checking into class attributes for readability, slight optimization to tower rule, fixed typo in codes option description.

* Added bug report page to web class, condensed pelt rules to one function, added items/locations count in game docs and adjusted some sections

* Added Inscryption to CODEOWNERS

* Implemented a bunch of suggestions: Better handling of painting option, options as dict for slot data, remove redundant auto_display_name, use of has_all, better goal tests, demote skink card to filler if goal is act 1 and force filler on paintings

* Makes clover plant and squirrel head progression items if paintings are balanced + fixed other issues

* filler items, start inventory from pool, '->"

* Fix bleeding issue

* Copy the list instead

* Fixed bleeding using proper deep copy

* Remove unnecessary for loops in tests

* Add defaults to choice options

---------

Co-authored-by: Benjamin Gregoire <benjamingregoire@outlook.com>
Co-authored-by: Exempt-Medic <ExemptMedic@Gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-12-21 23:12:35 +01:00
Kaito Sinclaire
46613adceb SMZ3: Fix minimal logic considering SM boss tokens unnecessary (#4377) 2024-12-21 20:39:38 +01:00
threeandthreee
e1a1cd1067 LADX: Open Mabe Option (#3964)
* open mabe option
swaps east mabe rocks for bushes

* add open mabe to slot data

* use upstream overworld option
Instead of a standalone option, use upstream's "overworld" option, which we don't use yet but it leaves better space for the future

* use ladxr_setting for consistency

* newline
2024-12-20 07:55:32 -05:00
Scipio Wright
7c8d102c17 TUNIC: Logic for bushes in guard house 2 upper and belltower (#4371)
* Logic for bushes in guard house 2 upper

* Fix typo

* also do it for forest belltower

* i love the dumb ice grapples
2024-12-19 23:45:29 -05:00
threeandthreee
35d30442f7 LADX: fix for syntax warning (#4376)
* init

* whitespace

* raw string instead
2024-12-19 22:53:58 -05:00
threeandthreee
4f71073d17 LADX: correct in-game check counter
LADX: correct in-game check counter
2024-12-19 22:17:41 -05:00
threeandthreee
e142283e64 LADX: enable upstream options (#3962)
* enable some upstream settings

* flashing just disabled, no setting

* just enable fast text

* noflash and textmode as hidden options

* typo

* drop whitespace changes

* add hard mode to slot data

* textmode adjustments
fast text default (fixing mistake)
remove no text option (its buggy)

* unhide options

* Update worlds/ladx/Options.py

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

* adjustments
2024-12-19 21:19:00 -05:00
palex00
de3707af4a Core/Docs: Adding apostrophe quotes around variables in printed error messages (#3914)
* Also indents plando_connections properly

* Adding apostrophe quotes around item, location, entrance/exit and boss names to make errors more readable

* Update plando_en.md

* Fixing test in Lufia II
2024-12-19 20:47:33 -05:00
Scipio Wright
2e0769c90e Noita: Make greed die a trap (#4382)
Noita make greed die a trap
2024-12-19 20:30:41 -05:00
Louis M
1ded7b2fd4 Aquaria: Replacing the release link to the latest link (#4381)
* Replacing the release link to the latest link

* The fr link was not working
2024-12-19 20:17:56 -05:00
Bryce Wilson
cacab68b77 Pokemon Emerald: Remove unnecessary code (#4364) 2024-12-16 09:06:48 +01:00
NewSoupVi
728d249202 Core: Add some more world convenience methods (#3021)
* Add some more convenience methods

* Typing stuff

* Rename the method

* beauxq's suggestions

* Back to Push Precollected
2024-12-15 23:30:35 +01:00
qwint
d1823a21ea HK: add random handling to plandocharmcosts (#4327) 2024-12-15 22:48:44 +01:00
Scipio Wright
6282efb13c TUNIC: Additional Combat Logic Option (#3658) 2024-12-15 22:40:36 +01:00
Benjamin S Wolf
0fdc14bc42 Core: Deduplicate exception output (#4036)
When running Generate.py, uncaught exceptions are logged once to a file and twice to the console due to keeping the original excepthook. We can avoid this by filtering the file log out of the stream handler.
2024-12-15 22:29:56 +01:00
Mysteryem
0370e669e5 Pokemon Emerald: Add Mr Briney's House indirect conditions (#4154)
The `REGION_DEWFORD_TOWN/MAIN -> REGION_ROUTE109/BEACH` and
`REGION_ROUTE109/BEACH -> REGION_DEWFORD_TOWN/MAIN` entrances require
access to the
`REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN -> REGION_DEWFORD_TOWN/MAIN`
entrance in their access rules, so require indirect conditions for the
parent_region of the entrance: `REGION_ROUTE104_MR_BRINEYS_HOUSE/MAIN`.
2024-12-15 22:28:51 +01:00
threeandthreee
ccea6bcf51 LADX: Improve icon guesses for foreign items (#2201)
* synonyms to new file, many added

* handle singular rupee

* remove redundant map and compass entries

* automatic pluralization

* add guardian acorn and piece of power

* move phrases to ItemIconGuessing.py

* organize, comment

* fix tab spacing

* fix

* add tunic and noita synonyms

* remove triangle instrument synonym

* reorganize, add some matches

* add tunic lucky up

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

* Update worlds/ladx/ItemIconGuessing.py

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

* handle camelCase and single rupee

* add indicate_progression option
Adds alternative system for foreign item icons that simply indicates whether or not the item is a progression item.

* improve splitting
drops some more characters, and also dont bother with rejoined stuff in name_cache because our splitting is better

* the witness stuff

* forbid more

* remove boost and surge

* Update worlds/ladx/ItemIconGuessing.py

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>

* match by game name
look at the name of the foreign game and only use game-specific entries for that game

* show message for all key drops

* updates from async test

* vi suggestions

* Adding FNAFW suggestions from @lolz1190 (#40)

* Adding FNAFW suggestions from @lolz1190

* missing comma

---------

Co-authored-by: threeandthreee <a.l.nordstrom@gmail.com>

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
Co-authored-by: palex00 <32203971+palex00@users.noreply.github.com>
2024-12-13 22:49:30 +01:00
qwint
8d9454ea3b Core: cast all the settings values so they don't try to get pickled later #4362 2024-12-12 21:36:56 +01:00
qwint
1ca8d3e4a8 Docs: add description of Indirect Condition problem (#4295)
* 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.

* Actually copy in the text

* 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: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* 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: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* 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>

* Update docs/apworld_dev_faq.md

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

* Update docs/apworld_dev_faq.md

Co-authored-by: qwint <qwint.42@gmail.com>

* Update docs/apworld_dev_faq.md

Co-authored-by: qwint <qwint.42@gmail.com>

* Update apworld_dev_faq.md

* Update docs/apworld_dev_faq.md

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

* Update apworld_dev_faq.md

* 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: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>

* Update docs/apworld_dev_faq.md

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

* Update apworld_dev_faq.md

* Update docs/apworld_dev_faq.md

Co-authored-by: qwint <qwint.42@gmail.com>

* Update docs/apworld_dev_faq.md

Co-authored-by: qwint <qwint.42@gmail.com>

* fix the last couple of wording issues I have with the indirect condition section to apworld dev faq doc

* I didn't like that wording

* Apply suggestions from code review

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

* Apply suggestions from code review

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

* Update docs/apworld_dev_faq.md

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

* Update docs/apworld_dev_faq.md

* Update docs/apworld_dev_faq.md

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

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2024-12-12 21:24:38 +01:00
qwint
9815306875 Docs: Use ModuleUpdate.py #3785 2024-12-12 20:30:49 +01:00
NewSoupVi
d7736950cd The Witness: Panel Hunt Plando (#3549)
* Add panel hunt plando option

* Keys are strs

* oops

* better message

* ,

* this doesn ot need to be here

* don't replace pre picked panels

* Update options.py

* rebase error

* rebase error

* oops

* Mypy

* ruff

* another rebase error

* actually this is a stupid change too

* bring over that change™️

* Update entity_hunt.py

* Update entity_hunt.py

* Update entity_hunt.py
2024-12-12 19:42:14 +01:00
Mysteryem
f5e3677ef1 Pokemon Emerald: Fix invalid escape sequence warnings (#4328)
Generation on Python 3.12 would print SyntaxWarnings due to invalid '\d'
escape sequences added in #3832.

Use raw strings to avoid `\` being used to escape characters.
2024-12-12 19:04:27 +01:00
josephwhite
144d612c52 Super Mario 64: Rework logic for 100 Coins (#4131)
* sm64ex: Rework logic for 100 Coins

* sm64ex: 100 Coins Vanilla Option

* sm64ex: Avoiding raw int comparisons for 100 coin option

* sm64ex: Change 100 coin option from toggle to choice

* sm64ex: use snake_case for 100 coin option

* just use "vanilla" for option comparison (exempt-medic feedback)

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

* sm64ex: remove vanilla 100 coins from item pool to remove overfilling stars

* yeah

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

* Remove range condition (35 is the min for total stars)

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-12-12 14:50:48 +01:00
LiquidCat64
3acbe9ece1 Castlevania: Circle of the Moon - Implement New Game (#3299)
* Add the cotm package with working seed playthrough generation.

* Add the proper event flag IDs for the Item codes.

* Oooops. Put the world completion condition in!

* Adjust the game name and abbreviations.

* Implement more settings.

* Account for too many start_inventory_from_pool cards with Halve DSS Cards Placed.

* Working (albeit very sloooooooooooow) ROM patching.

* Screw you, bsdiff! AP Procedure Patch for life!

* Nuke stage_assert_generate as the ROM is no longer needed for that.

* Working item writing and position adjusting.

* Fix the magic item graphics in Locations wherein they can be fixed.

* Enable sub-weapon shuffle

* Get the seed display working.

* Get the enemy item drop randomization working. Phew!

* Enemy drop rando and seed display fixes.

* Functional Countdown + Early Double setting

* Working multiworld (yay!)

* Fix item links and demo shenanigans.

* Add Wii U VC hash and a docs section explaining the rereleases.

* Change all client read/writes to EWRAM instead of Combined WRAM.

* Custom text insertion foundations.

* Working text converter and word wrap detector.

* More refinements to the text wrap system.

* Well and truly working sent/received messages.

* Add DeathLink and Battle Arena goal options.

* Add tracker stuff, unittests, all locations countdown, presets.

* Add to README, CODEOWNERS, and inno_setup

* Add to README, CODEOWNERS, and inno_setup

* Address some suggestions/problems.

* Switch the Items and Locations to using dataclasses.

* Add note about the alternate classes to the Game Page.

* Oooops, typo!

* Touch up the Options descriptions.

* Fix Battle Arena flag being detected incorrectly on connection and name the locked location/item pairs better.

* Implement option groups

* Swap the Lizard-man Locations into their correct Regions.

* Local start inventory, better DeathLink message handling, handle receiving over 255 of an item.

* Update the PopTracker pack links to no longer point to the Releases page.

* Add Skip Dialogues option.

* Update the presets for the accessibility rework.

* Swap the choices in the accessibility preset options.

* Uhhhhhhh...just see the apworld v4 changelog for this one.

* Ooops, typo!

* .

* Bunch of small stuff

* Correctly change "Fake" to "Breakable" in this comment.

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

* Make can_touch_water one line.

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

* Make broke_iron_maidens one line.

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

* Fix majors countdown and make can_open_ceremonial_door one line.

* Make the Trap AP Item less obvious.

* Add Progression + Useful stuff, patcher handling for incompatible versions, and fix some mypy stuff.

* Better option groups.

* Change Early Double to Early Escape Item.

* Update DeathLink description and ditch the Menu region.

* Fix the Start Broken choice for Iron Maiden Behavior

* Remove the forced option change with Arena goal + required All Bosses and Arena.

* Update the Game Page with the removal of the forced option combination change.

* Fix client potential to send packets nonstop.

* More review addressing.

* Fix the new select_drop code.

* Fix the new select_drop code for REAL this time.

* Send another LocationScout if we send Location checks without having the Location info.

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: Exempt-Medic <ExemptMedic@Gmail.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-12-12 14:47:47 +01:00
Scipio Wright
7d0b701a2d TUNIC: Change rule for heir access in non-hex quest #4365 2024-12-12 12:54:03 +01:00
Justus Lind
f91537fb48 Muse Dash: Remove bad option defaults. #4340 2024-12-12 09:18:19 +01:00
Jouramie
3c5ec49dbe Stardew Valley: Fix potential incompletable seed when starting winter (#4361)
* make moss available with any season except winter

* add tool and region requirement for moss
2024-12-12 09:17:19 +01:00
NewSoupVi
9a37a136a1 The Witness: Add more panels to the "doors: panels" mode (#2916)
* Add more panels that should be panels

* Make it so the caves panel items don't exist in early caves

* Remove unused import

* oops

* Remove Jungle to Monastery Garden from usefulification list

* Add a basic test

* ruff

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2024-12-10 21:13:45 +01:00
NewSoupVi
54a0a5ac00 The Witness: Put progression + useful on some items. (#4027)
* proguseful

* ruff

* variable rename

* variable rename

* Better (?) comment

* Better way to do this? I guess

* sure

* ruff

* Eh, it's not worth it. Here's the much simpler version

* don't need this now

* Improve some classification checks while we're at it

* Only proguseful obelisk keys if eps are individual
2024-12-10 21:06:06 +01:00
Exempt-Medic
704f14ffcd Core: Add toggles_as_bools to options.as_dict (#3770)
* Add toggles_as_bools to options.as_dict

* Update Options.py

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

* Add param to docstring

* if -> elif

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2024-12-10 20:37:54 +01:00
Star Rauchenberger
925fb967d3 Lingo: Fix number hunt issues on panels mode (#4342) 2024-12-10 20:36:38 +01:00
NewSoupVi
5dd19fccd0 MultiServer/CommonClient: We forgot about Item Links again (Hint Priority) (#4314)
* Vi don't forget about itemlinks challenge difficulty impossible

* People other than Vi also don't forget about ItemLinks challenge difficulty impossible
2024-12-10 20:35:36 +01:00
Jouramie
781100a571 CI: remove version restriction on pytest-subtests (#4356)
This reverts commit e3b5451672.
2024-12-10 20:26:33 +01:00
black-sliver
3fb0b57d19 Core: fix exceptions coming from LocationStore (#4358)
* Speedups: add instructions for ASAN

* Speedups: move typevars out of classes

* Speedups, NetUtils: raise correct exceptions

* Speedups: double-check malloc

* Tests: more LocationStore tests
2024-12-10 20:09:36 +01:00
Fabian Dill
f79657b41a WebHost: disable abbreviations for argparse (#4352) 2024-12-10 19:53:42 +01:00
black-sliver
4a5ba756b6 WebHost: Set Generator memory limit to 4GiB (#4319)
* WebHost: Set Generator memory limit to 4GiB

* WebHost: make generator memory limit configurable, better naming

* Update WebHostLib/__init__.py

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>

* Update docs/webhost configuration sample.yaml

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2024-12-10 02:44:41 +01:00
black-sliver
0b3d34ab24 CI: update scan-build to v19 (#4338) 2024-12-10 02:25:09 +01:00
Jouramie
aa22b62b41 Stardew Valley: Force deactivation of Mr. Qi's special orders when ginger island is deactivated (#4348) 2024-12-09 21:17:25 +01:00
Jouramie
51c4fe8f67 Stardew Valley: Fix a bug where walnutsanity would get deactivated even tho ginger island got forced activated (and move some files) (#4311) 2024-12-09 03:00:30 +01:00
Louis M
26f9720e69 Aquaria: mega refactoring (#3810)
This PR is mainly refactoring. Here is what changed:
- Changing item names so that each words are capitalized (`Energy Form` instead of `Energy form`)
- Removing duplication of string literal by using:
  - Constants for items and locations,
  - Region's name attribute for entrances,
- Clarify some documentations,
- Adding some region to be more representative of the game and to remove listing of locations in the rules (prioritize entrance rules over individual location rules).

This is the other minor modifications that are not refactoring:
- Adding an early bind song option since that can be used to exit starting area.
- Changing Sun God to Lumerean God to be coherent with the other gods.
- Changing Home Water to Home Waters and Open Water to Open Waters to be coherent with the game.
- Removing a rules to have an attack to go in Mithalas Cathedral since you can to get some checks in it without an attack.
- Adding some options to slot data to be used with Poptracker.
- Fixing a little but still potentially logic breaking bug.
2024-12-09 02:18:00 +01:00
qwint
1f712d9a87 Various Worlds: use / explicitly for pkgutil (#4232) 2024-12-09 01:59:40 +01:00
Scipio Wright
5b4d7c7526 TUNIC: Add Shield to Ladder Storage logic (#4146) 2024-12-09 01:58:49 +01:00
Mysteryem
a948697f3a Raft: Place locked items in create_items and fix get_pre_fill_items (#4250)
* Raft: Place locked items in create_items and fix get_pre_fill_items

`pre_fill` runs after item plando, and item plando could place an item
at a location where Raft was intending to place a locked item, which
would crash generation.

This patch moves the placement of these locked items earlier, into
`create_items`.

Setting items into `multiworld.raft_frequencyItemsPerPlayer` for each
player has been replaced with passing `frequencyItems` to the new
`place_frequencyItems` function.

`setLocationItem` and `setLocationItemFromRegion` have been moved into
the new `place_frequencyItems` function so that they can capture the
`frequencyItems` argument variable.

The `get_pre_fill_items` function could return a list of all previously
placed items across the entire multiworld which was not correct. It
should have returned the items in
`multiworld.raft_frequencyItemsPerPlayer[self.player]`. Now that these
items are placed in `create_items` instead of `pre_fill`,
`get_pre_fill_items` is no longer necessary and has been removed.

* self.multiworld.get_location -> self.get_location

Changed the occurences in the modified code.
2024-12-09 01:57:34 +01:00
qwint
e3b5451672 CI: cap pytest-subtest version (#4344) 2024-12-08 20:43:16 +01:00
black-sliver
6c69f590cf WebHost: fix host room not updating (ports in) slot table (#4308) 2024-12-08 02:22:56 +01:00
LeonarthCG
c9625e1b35 Saving Princess: implement new game (#3238)
* Saving Princess: initial commit

* settings -> options

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

* settings -> options

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

* replace RegionData class with List[str]

RegionData was only wrapping a List[str], so we can directly use List[str]

* world: MultiWorld -> multiworld: MultiWorld

* use world's random instead of multiworld's

* use state's has_any and has_all where applicable

* remove unused StartInventory import

* reorder PerGameCommonOptions

* fix relative AutoWorld import

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

* clean up double spaces

* local commands -> Local Commands

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* remove redundant which items section

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* game info rework

* clean up item count redundancy

* add game to readme and codeowners

* fix get_region_entrance return type

* world.multiworld.get -> world.get

* add more events

added events for the boss kills that open the gate, as well as for system power being restored

these only apply if expanded pool is not selected

* add client/autoupdater to launcher

* reorder commands in game info

* update docs with automated installation info

* add quick links to doc

* Update setup_en.md

* remove standalone saving princess client

* doc fixes

* code improvements and redundant default removal

as suggested by @Exempt-Medic
this includes the removal of events from the item/location name to id, as well as checking for the player name being ASCII

* add option to change launch coammnd

the LaunchCommand option is filled to either the executable or wine with the necessary arguments based on Utils.is_windows

* simplify valid install check

* mod installer improvements

now deletes possible existing files before installing the mod

* add option groups and presets

* add required client version

* update docs about cheat items pop-ups

items sent directly by the server (such as with starting inventory) now have pop-ups just like any other item

* add Steam Input issue to faq

* Saving Princess: BRAINOS requires all weapons

* Saving Princess: Download dll and patch together

Previously, gm-apclientpp.dll was downloaded from its own repo
With this update, the dll is instead extracted from the same zip as the game's patch

* Saving Princess: Add URI launch support

* Saving Princess: goal also requires all weapons

given it's past brainos

* Saving Princess: update docs

automatic connection support was added, docs now reflect this

* Saving Princess: extend([item]) -> append(item)

* Saving Princess: automatic connection validation

also parses the slot, password and host:port into parameters for the game

* Saving Princess: change subprocess .run to .Popen

This keeps the game from freezing the launcher while it is running

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-12-07 11:29:27 +01:00
Nicholas Saylor
ced93022b6 Adventure: Remove unused variables (#4301)
* Remove unused variables

* Provide old parameters to comment
2024-12-06 07:15:26 +01:00
Bryce Wilson
f4b926ebbe Pokemon Emerald: Exclude sacred ash post champion (#4207)
* Pokemon Emerald: Exclude sacred ash post champion

* Pokemon Emerald: Remove .value from toggle option check
2024-12-05 16:33:21 +01:00
threeandthreee
203d89d1d3 LADX: upstream logic updates (#3963)
* Fully updates requirements.py to live LADXR (#19)

* Updates dungeon2.py to LADXR-Live (#20)

No logic changes or bugfix are in this file. It is only code cleanup.

* Update dungeon1.py (#21)

- The Three of a Kind with Bomb is moved from Normal to Hard Logic

The rest is code cleanup.

lines 22-25 | 22-26 & 33 | 34 remain different in AP | Upstream with no effective difference

* Fully updates dungeon3.py to LADXR-live (#22)

Logic Changes:
- Hard mode now considers killing the enemies in the top room with pot

Everything else is cleanup.

* Fully update dungeon4.py to LADXR-live logic (#23)

Logic Changes:
- Hard Logic: Removes Feather requirement from grabbing the Pit Key
- Hell logic: new hookshot clip (line 64)
- Hell logic: hookshot spam over the first pit of crossroads, then buffer down (line 69)
- Hell logic: push block left of keyblock up, then shaq jump off the left wall and pause buffer to land on keyblock.
- Hell logic: split zol for more entities, and clip through the block left of keyblock by hookshot spam

The rest is code cleanup

* Updates dungeon5.py mostly to LADXR-Live Logic (#24)

Logic Changes:
- Hell logic: use zoomerang dashing left to get an unclipped boots superjump off the right wall over the block. reverse is push block (line 69)

The rest is cleanup.

The upstream splits the post_gohma region into pre_gohma, gohma and post_gohma. I did not implement this yet as I do not know the implications. To port this the following lines need to be changed (AP | LADXR):
18 | 18-20;
55 | 58;
65 | 68-69

* Fully update dungeon6.py logic (#25)

Logic Changes:
- Hard logic: allow damage boosting past the mini thwomps
- Glitched logic: bomb triggering elephants in two cases

Everything else is cleanup

* Fully update dungeon7.py to LADXR-live logic (#26)

Logic Changes:
- Hard logic: Three of a Kind is now possible with bombs only

Everything else is code cleanup

* Fully updates dungeon8.py to LADXR-live (#27)

Logic change:
- Hard logic: allows to drop the Gibdos into holes as a way to kill them
- Glitched logic: underground section with fire balls jumping up out of lava. Use boots superjump off left wall to jump over the pot blocking the way


The rest is code cleanup

* Fully update dungeonColor.py to LADXR-live (#28)

Logic changes:
- Normal logic: Karakoros now need power bracelet to put them into their holes
- Hard logic: Karakoros without power bracelet but with weapon
- Hell logic: Karakoros with only bombs

Everything else is code cleanup

* Updating overworld.py (#29)

* Updating overworld.py

This tries to update all logic of the Overworld.

Logic changes include:
- Normal logic: requires hookshot or shield to traverse Armos Cave
- Hard logic: Traverse Armos Cave with nothing (formerly normal logic)
- Hard logic: get the animal village bomb cave check with jump and boomerang
- Hard logic: use rooster to go to D7
- Lots of Jesus Rooster Jumps

I stopped counting and need to go over this again.

Also, please investigate line 474 AP because it's removed in LADXR-Upstream and I don't know why.

* remove featherless fisher under bridge from hard

it was moved to hell upstream and its already present in our code

---------

Co-authored-by: Alex Nordstrom <a.l.nordstrom@gmail.com>

* fixes

* add test messages

* Adds Pegasus Boots to the test (#31)

* Fix d6 boss_key logic (#30)

* restore hardmode logic

* higher logic fixes

* add bush requirement to the raft
in case the player needs to farm rupees to play again

---------

Co-authored-by: palex00 <32203971+palex00@users.noreply.github.com>
2024-12-05 16:32:45 +01:00
threeandthreee
4d42814f5d LADX: more item groups, location groups, keysanity preset (#3936)
* add groups and a preset

* formatting

* typing

* alias groups for progressive items

* add bush breakers item group

* fix typo

* some manual location groups

* drop dummy dungeon items from groups
2024-12-05 12:06:52 +01:00
threeandthreee
d80069385d LADX: tweak in-game hints (#3920)
* dont show local player name in hint

* add option to disable hints

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-12-05 12:03:16 +01:00
threeandthreee
85a0d59f73 LADX: text shuffle exclusions (#3919)
* text shuffle exclusions
Exclude owl statues, library books, goal sign, signpost maze, and various rupee prices from text shuffle

* clearer variable name
2024-12-05 10:23:26 +01:00
nmorale5
58f2205304 Pokemon RB: Fix Incorrect Hidden Item Location in Seafoam Islands B2F (#4304) 2024-12-05 07:48:33 +01:00
Nicholas Saylor
769fbc55a9 HK: Remove unused variables and imports (#4303)
* Remove unused variables and imports

* Accidental duplication
2024-12-04 08:51:56 +01:00
NewSoupVi
f43fa612d5 The Witness: Another small access rule optimisation #4256 2024-12-04 05:39:29 +01:00
Exempt-Medic
5b0de6b6c7 FFMQ: No Longer Allow Inaccessible Useful Items (#4323)
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2024-12-03 22:51:58 +01:00
threeandthreee
ac8a206d46 LADX: combine warp options (#4325)
* combine warp options

* fix

* fix typo

* mark old options as removed
2024-12-03 06:59:55 +01:00
Jouramie
6896d631db Stardew Valley: Fix a bug in equals between Or and And rules #4326 2024-12-03 06:23:13 +01:00
Nicholas Saylor
6f2e1c2a7e Lingo: Optimize imports and remove unused parameter (#4305) 2024-12-03 03:02:18 +01:00
Fabian Dill
ffe0221deb Core: log process ID (#4290) 2024-12-03 03:00:56 +01:00
Bryce Wilson
18e8d50768 Pokemon Emerald: Clean up dexsanity spoiler and hints (#3832)
* Pokemon Emerald: Clean up dexsanity spoiler and hints

* Pokemon Emerald: Add +, do less hacks

* Pokemon Emerald: Update changelog

* Pokemon Emerald: Replace arrow with word in changelog

* Pokemon Emerald: Fix changelog
2024-12-03 02:52:20 +01:00
Mysteryem
81b9a53a37 KH2: Add missing indirect conditions for Final region access (#3923)
* KH2: Add missing indirect conditions for Final region access

Entrances to the Final region require being able to reach any one of a
number of locations, but for a location to be reachable, its parent
region must also be reachable, so indirect conditions must be added for
these regions.

* Use World.get_location

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

* Use World.get_location, for real this time

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-12-03 02:51:10 +01:00
Star Rauchenberger
b6ab91fe4b LADX: Remove duplicate Magnifying Lens item (#3684)
* LADX: Magnifying Glass fixes

Removed the duplicate item (Magnifying Lens), and made the real one a filler item.

* Update worlds/ladx/Items.py

Co-authored-by: threeandthreee <alex@3and3.dev>

---------

Co-authored-by: threeandthreee <alex@3and3.dev>
2024-12-03 02:50:30 +01:00
Emily
f26cda07db Core: Hint Priority fixes (#4315)
* Update hint priority docs

* Update network protocol.md

* Add error on `UpdateHint` trying to change to `HINT_FOUND`

* Update network protocol.md

* fix: precollected hint priority
2024-12-01 15:16:36 +01:00
Fabian Dill
ecc3094c70 Launcher: launch without delay on URI without choice (#4279) 2024-12-01 08:33:43 +01:00
Benjamin S Wolf
17b3ee6eaf Core: warn if a yaml is empty (#4117)
* Core: warn if a yaml is empty

* WebHost: ignore empty yaml

Generate: log which yaml documents are empty

* Actually remove empty yamls from weight_cache

* More verbose variable name

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-12-01 05:18:00 +01:00
Exempt-Medic
284e7797c5 Adventure: create_item AttributeError -> KeyError #4219 2024-12-01 05:10:43 +01:00
Exempt-Medic
62ce42440b Super Metroid: KeyError on invalid item name #4222 2024-12-01 05:03:13 +01:00
Natalie Weizenbaum
7b755408fa DS3: Clarify location names for Yoel and Yuria items (#3881)
* DS3: Clarify location names for Yoel and Yuria items

* Fix encodings for `detailed_location_descriptions.py`

* Fix one more typo
2024-12-01 05:00:06 +01:00
Alex Nordstrom
ed721dd0c1 LADX: Implement various upstream adjustments (#3829)
* magnifying lens changes

https://github.com/daid/LADXR/pull/156

* restore enemy visibility in mermaid statue cave

https://github.com/daid/LADXR/pull/155

* mermaid statue scale bugfix

https://github.com/daid/LADXR/pull/163

* restore vanilla map when rooster is an item

https://github.com/daid/LADXR/pull/132

* fix

* fixes to magnifying lens changes

* load marin singing even if you have marin date
4feb3099a3

* Revert "load marin singing even if you have marin date"

This reverts commit a7a546ed3f.

* always patch tradequest
not upstream, but included in this PR because it touches the same parts of the code. https://discord.com/channels/731205301247803413/1227373762412937347

* marin date fix

* fix logic
2024-12-01 04:58:10 +01:00
Benjamin S Wolf
1a5d22ca78 Core: Add new error message for item count when defined as a set instead of a dict (#4100)
* Core: New error message if item count is a set

* Apply suggestion for error message

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

* Apply item count error suggestion

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
2024-12-01 04:51:26 +01:00
josephwhite
21dbfd2472 Multiserver: Add argument for timestamping STDOUT (#4266)
* core: add server arg for timestamping STDOUT

* Multiserver: Implicitly use default write_mode arg in init_logging

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-12-01 04:33:36 +01:00
Jarno
472d2d5406 Timespinner: Implemented support for universal tracker (#3771)
* Implemented slot data interpretation

* Fixed talaria attached to be taken into logic
2024-12-01 04:11:45 +01:00
Kaito Sinclaire
3af2b1dc66 id Tech 1 games: Add command line instructions/info (#3757) 2024-12-01 04:10:43 +01:00
Eric Newport
6cfc3a4667 Docs: Improved sm64ex advanced setup docs (#3741)
* Improved sm64ex advanced setup docs

This edit clarifies some things that are not obvious in the version that is currently live on the site.

This should prevent others from needing to go spelunking in Discord chat history to figure out how to do advanced builds.

* Update worlds/sm64ex/docs/setup_en.md

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* copyediting

---------

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
2024-12-01 04:10:00 +01:00
Rensen3
992657750c YGO06: add Item groups (#3737)
* YGO06: adds item groups

* YGO06: Change lists to sets

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

* YGO06: fix imports

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2024-12-01 04:09:22 +01:00
Jouramie
a67688749f Stardew Valley: Refactor skill progression to use new feature system (#3662)
* create a first draft of the feature

* use feature in items and locations

* add content to more places

* use feature in logic

* replace option check by feature

* remove unused code

* remove weird white space

* some import nitpicking

* flip negative if
2024-12-01 03:52:07 +01:00
Kaito Sinclaire
f735416bda id Tech 1: Clean up difficulty options (#4298) 2024-12-01 03:46:34 +01:00
palex00
e5374eb8b8 [PKMN RB] Make Encounters in one location unique (#3994)
* Makes encounters in a location generate unique Pokémon

* vyneras actually got it to work

* V5 Update Fix Part 1

* Part 2

* final puzzle piece
2024-12-01 03:22:02 +01:00
black-sliver
b83b48629d Core: rework python version check (#4294)
* Docs: update min required version

and add comment about security.

* Core: rework python version check

* CI: set min micro update for build and release
2024-11-30 17:23:28 +01:00
Exempt-Medic
ca6792a8a7 Blasphemous: Add start_inventory_from_pool (#4217) 2024-11-30 16:08:41 +01:00
qwint
7cbd50a2e6 HK: add item group for dream nail(s) (#4069) 2024-11-30 16:02:32 +01:00
Fabian Dill
d6da3bc899 Factorio: add Atomic Cliff Remover Trap (#4282) 2024-11-30 06:53:28 +01:00
Fabian Dill
9eaca95277 WebHost: add a page to manage session cookie (#4173) 2024-11-30 04:11:28 +01:00
Fabian Dill
c1b27f79ac Core: cull events from multidata spheres (#3623)
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2024-11-30 04:11:03 +01:00
Fabian Dill
0705f6e6c0 Factorio: option groups (#4293) 2024-11-30 04:08:17 +01:00
qwint
a537d8eb65 Launcher: support Component icons inside apworlds (#3629)
* Add kivy overrides to allow AsyncImage source paths of the format ap:worlds.module/subpath/to/data.png that use pkgutil to load files from within an apworld

* Apply suggestions from code review

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

* Apply suggestions from code review

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

* change original-load variable name for clarity per review

* add comment to record pkgutil format

* remove dependency on PIL

* i hate typing

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2024-11-30 03:58:52 +01:00
qwint
845a604955 MultiServer: !status shows Ready status (#3598)
* Makes !status show a note if the slot is in Status Ready

* update variable name for better clarity
2024-11-30 03:40:14 +01:00
NewSoupVi
7adb673a80 Core: "Fix" Priority Fill (#3592)
* Priority fill -> Don't use one item per player

* fix unit test thing

* Ok, I think this should do it properly
2024-11-30 03:37:08 +01:00
lordlou
72e88bb493 SMZ3: generate without rom (#3461)
* - SM now displays message when getting an item outside for someone else (fills ROM item table)

This is dependant on modifications done to sm_randomizer_rom project

* First working MultiWorld SM

* some missing things:

- player name inject in ROM and get in client
- end game get from ROM in client
- send self item to server
- add player names table in ROM

* replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better)

* - reenabled balancing

* post rebase fixes

* updated SmClient.py

* + added VariaRandomizer LICENSE

* + added sm_randomizer_rom project (which builds sm.ips)

* Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning

* properly revert change made to CollectionState and more cleaning

* Fixed multiworld support patch not working with VariaRandomizer's

* missing file commit

* Fixed syntax error in unused code to satisfy Linter

* Revert "Fixed multiworld support patch not working with VariaRandomizer's"

This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b.

* many fixes and improovement

- fixed seeded generation
- fixed broken logic when more than one SM world
- added missing rules for inter-area transitions
- added basic patch presence for logic
- added DoorManager init call to reflect present patches for logic
- moved CollectionState addition out of BaseClasses into SM world
- added condition to apply progitempool presorting only if SM world is present
- set Bosses item id to None to prevent them going into multidata
- now use get_game_players

* first working (most of the time) progression generation for SM using VariaRandomizer's rules, items, locations and accessPoint (as regions)

* first working single-world randomized SM rom patches

* - SM now displays message when getting an item outside for someone else (fills ROM item table)

This is dependant on modifications done to sm_randomizer_rom project

* First working MultiWorld SM

* some missing things:

- player name inject in ROM and get in client
- end game get from ROM in client
- send self item to server
- add player names table in ROM

* replaced CollectionState inheritance from SMBoolManager with a composition of an array of it (required to generation more than one SM world, which is still fails but is better)

* - reenabled balancing

* post rebase fixes

* updated SmClient.py

* + added VariaRandomizer LICENSE

* + added sm_randomizer_rom project (which builds sm.ips)

* Moved VariaRandomizer and sm_randomizer_rom projects inside worlds/sm and done some cleaning

* properly revert change made to CollectionState and more cleaning

* Fixed multiworld support patch not working with VariaRandomizer's

* missing file commit

* Fixed syntax error in unused code to satisfy Linter

* Revert "Fixed multiworld support patch not working with VariaRandomizer's"

This reverts commit fb3ca18528bb331995e3d3051648c8f84d04c08b.

* many fixes and improovement

- fixed seeded generation
- fixed broken logic when more than one SM world
- added missing rules for inter-area transitions
- added basic patch presence for logic
- added DoorManager init call to reflect present patches for logic
- moved CollectionState addition out of BaseClasses into SM world
- added condition to apply progitempool presorting only if SM world is present
- set Bosses item id to None to prevent them going into multidata
- now use get_game_players

* Fixed multiworld support patch not working with VariaRandomizer's

Added stage_fill_hook to set morph first in progitempool
Added back VariaRandomizer's standard patches

* + added missing files from variaRandomizer project

* + added missing variaRandomizer files (custom sprites)

+ started integrating VariaRandomizer options (WIP)

* Some fixes for player and server name display

- fixed player name of 16 characters reading too far in SM client
- fixed 12 bytes SM player name limit (now 16)
- fixed server name not being displayed in SM when using server cheat ( now displays RECEIVED FROM ARCHIPELAGO)
- request: temporarly changed default seed names displayed in SM main menu to OWTCH

* Fixed Goal completion not triggering in smClient

* integrated VariaRandomizer's options into AP (WIP)

- startAP is working
- door rando is working
- skillset is working

* - fixed itemsounds.ips crash by always including nofanfare.ips into multiworld.ips (itemsounds is now always applied and "itemsounds" preset must always be "off")

* skillset are now instanced per player instead of being a singleton class

* RomPatches are now instanced per player instead of being a singleton class

* DoorManager is now instanced per player instead of being a singleton class

* - fixed the last bugs that prevented generation of >1 SM world

* fixed crash when no skillset preset is specified in randoPreset (default to "casual")

* maxDifficulty support and itemsounds removal

- added support for maxDifficulty
- removed itemsounds patch as its always applied from multiworld patch for now

* Fixed bad merge

* Post merge adaptation

* fixed player name length fix that got lost with the merge

* fixed generation with other game type than SM

* added default randoPreset json for SM in playerSettings.yaml

* fixed broken SM client following merge

* beautified json skillset presets

* Fixed ArchipelagoSmClient not building

* Fixed conflict between mutliworld patch and beam_doors_plms patch

- doorsColorsRando now working

* SM generation now outputs APBP

- Fixed paths for patches and presets when frozen

* added missing file and fixed multithreading issue

* temporarily set data_version = 0

* more work

- added support for AP starting items
- fixed client crash with gamemode being None
- patch.py "compatible_version" is now 3

* commited missing asm files

fixed start item reserve breaking game (was using bad write offset when patching)

* Nothing item are now handled game-side. the game will now skip displaying a message box for received Nothing item (but the client will still receive it).

fixed crash in SMClient when loosing connection to SNI

* fixed No Energy Item missing its ID

fixed Plando

* merge post fixes

* fixed start item Grapple, XRay and Reserve HUD, as well as graphic beams (except ice palette color)

* fixed freeze in blue brinstar caused by Varia's custom PLM not being filled with proper Multiworld PLM address (altLocsAddresses)

* fixed start item x-ray HUD display

* Fixed start items being sent by the server (is all handled in ROM)

Start items are now not removed from itempool anymore
Nothing Item is now local_items so no player will ever pickup Nothing. Doing so reduces contribution of this world to the Multiworld the more Nothing there is though.
Fixed crash (and possibly passing but broken) at generation where the static list of IPSPatches used by all SM worlds was being modified

* fixed settings that could be applied to any SM players

* fixed auth to server only using player name (now does as ALTTP to authenticate)

* - fixed End Credits broken text

* added non SM item name display

* added all supported SM options in playerSettings.yaml

* fixed locations needing a list of parent regions (now generate a region for each location with one-way exits to each (previously) parent region

did some cleaning (mainly reverts on unnecessary core classes

* minor setting fixes and tweaks

- merged Area and lightArea settings
- made missileQty, superQty and powerBombQty use value from 10 to 90 and divide value by float(10) when generating
- fixed inverted layoutPatch setting

* added option start_inventory_removes_from_pool

fixed option names formatting
fixed lint errors
small code and repo cleanup

* Hopefully fixed ROR2 that could not send any items

* - fixed missing required change to ROR2

* fixed 0 hp when respawning without having ever saved (start items were not updating the save checksum)

* fixed typo with doors_colors_rando

* fixed checksum

* added custom sprites for off-world items (progression or not)

the original AP sprite was made with PierRoulette's SM Item Sprite Utility by ijwu

* - added missing change following upstream merge

- changed patch filename extension from apbp to apm3 so patch can be used with the new client

* added morph placement options: early means local and sphere 1

* fixed failing unit tests

* - fixed broken custom_preset options

* - big cleanup to remove unnecessary or unsupported features

* - more cleanup

* - moved sm_randomizer_rom and all always applied patches into an external project that outputs basepatch.ips

- small cleanup

* - added comment to refer to project for generating basepatch.ips (https://github.com/lordlou/SMBasepatch)

* fixed g4_skip patch that can be not applied if hud is enabled

* - fixed off world sprite that can have broken graphics (restricted to use only first 2 palette)

* - updated basepatch to reflect g4_skip removal

- moved more asm files to SMBasepatch project

* - tourian grey doors at baby metroid are now always flashing (allowing to go back if needed)

* fixed wrong path if using built as exe

* - cleaned exposed maxDifficulty options

- removed always enabled Knows

* Merged LttPClient and SMClient into SNIClient

* added varia_custom Preset Option that fetch a preset (read from a new varia_custom_preset Option) from varia's web service

* small doc precision

* - added death_link support

- fixed broken Goal Completion
- post merge fix

* - removed now useless presets

* - fixed bad internal mapping with maxDiff

- increases maxDiff if only Bosses is preventing beating the game

* - added support for lowercase custom preset sections (knows, settings and controller)

- fixed controller settings not applying to ROM

* - fixed death loop when dying with Door rando, bomb or speed booster as starting items

- varia's backup save should now be usable (automatically enabled when doing door rando)

* -added docstring for generated yaml

* fixed bad merge

* fixed broken infinity max difficulty

* commented debug prints

* adjusted credits to mark progression speed and difficulty as Non Available

* added support for more than 255 players (will print Archipelago for higher player number)

* fixed missing cleanup

* added support for 65535 different player names in ROM

* fixed generations failing when only bosses are unreachable

* - replaced setting maxDiff to infinity with a bool only affecting boss logics if only bosses are left to finish

* fixed failling generations when using 'fun' settings

Accessibility checks are forced to 'items' if restricted locations are used by VARIA following usage of 'fun' settings

* fixed debug logger

* removed unsupported "suits_restriction" option

* fixed generations failing when only bosses are unreachable (using a less intrusive approach for AP)

* - fixed deathlink emptying reserves

- added death_link_survive option that lets player survive when receiving a deathlink if the have non-empty reserves

* - merged death_link and death_link_survive options

* fixed death_link

* added a fallback default starting location instead of failing generation if an invalid one was chosen

* added Nothing and NoEnergy as hint blacklist

added missing NoEnergy as local items and removed it from progression

* now doesnt require ROM for generation

* removed stage_assert_generate

* fixed conflict with main and small cleanup
2024-11-30 03:36:00 +01:00
NewSoupVi
089b3f17a7 The Witness: Add "Panel Keys" and "Obelisk Keys" item groups #4026 2024-11-30 02:16:52 +01:00
NewSoupVi
ad30e3264a The Witness: Turn off default tests on a test that is prone to swap fails #4261 2024-11-30 02:15:50 +01:00
Jouramie
e262c8be9c Stardew Valley: Fix a bug where locations in logic would disappear from universal tracker as items get sent (#4230)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-11-30 01:46:35 +01:00
Fabian Dill
492e3a355e WebHost: delete unused script tag (#4062) 2024-11-30 00:37:26 +01:00
qwint
1487d323cd Core: update error message for mismatched "event" placements #4043 2024-11-30 00:01:24 +01:00
NewSoupVi
dd88b2c658 The Witness: Fix unreachable locations on Longbox + Postgame #4291 2024-11-29 23:47:27 +01:00
Aaron Wagener
46dfc4d4fc Core: Allow option groups to specify option order (#3393)
* Core: Allow option groups to specify option order

* words hard

* Actually use the earlier built dictionary for faster in checking

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

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2024-11-29 23:37:14 +01:00
Exempt-Medic
b0a61be9df Tests: Add test that local/non local items aren't modified late #3976 2024-11-29 22:57:35 +01:00
palex00
7c00c9a49d Core: Change "Unreachable Items" to "Unreachable progression items" in playthrough warning for clarification (#4287) 2024-11-29 22:48:01 +01:00
Kaito Sinclaire
1365bd7a0a CODEOWNERS: Add KScl as world maintainer for id Tech 1 games (#4288) 2024-11-29 22:46:38 +01:00
David St-Louis
6e5adc7abd New Game: Faxanadu (#3059) 2024-11-29 22:45:36 +01:00
NewSoupVi
c97e4866dd Core: Rewrite start inventory from pool code (#3778)
* Rewrite start inventory from pool code

* I think this is nicer?

* lol

* I just made it even shorter and nicer

* comments :D

* I think this makes more logical sense

* final change I promise

* HOLD UP THIS IS SO SHORT NOW

* ???????? Vi pls

* ???????? Vi pls????????????????

* this was probably important idk

* Lmao this just did not work correctly at all
2024-11-29 22:43:01 +01:00
Exempt-Medic
8444ffa0c7 id Tech: Standardizing and fixing display names (#4240) 2024-11-29 21:34:14 +01:00
Doug Hoskisson
2fb59d39c9 Zillion: use "new" settings api and cleaning (#3903)
* Zillion: use "new" settings api and cleaning

* python 3.10 typing update

* don't separate assignments of item link players
2024-11-29 21:25:01 +01:00
Doug Hoskisson
b5343a36ff Core: fix settings API for removal of Python 3.8, 3.9 (#4280)
* Core: fix settings API for removal of Python 3.8, 3.9

This is fixing 2 problems:
- The `World` class has the annotation:
  `settings: ClassVar[Optional["Group"]]`
  so `MyWorld.settings` should not raise an exception like it does for some worlds.
  With the `Optional` there, it looks like it should return `None` for the worlds that don't use it. So that's what I changed it to.

- `Group.update` had some code that required `typing.Union` instead of the Python 3.10 `|` for unions.

added unit test for this fix
added change in Zillion that I used to discover this problem and used it to test the test

* fix copy-pasted stuff

* tuple instead of set

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

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2024-11-29 21:17:56 +01:00
black-sliver
d7a0f4cb4c CI: fix naming of windows build action (#4286) 2024-11-29 20:49:36 +01:00
Ehseezed
77d35b95e2 Timespinner: Update AP to have parity with standalone options (#3805) 2024-11-29 20:46:12 +01:00
NewSoupVi
b605fb1032 The Witness: Make Elevators Come To You an OptionSet (#4000)
* Split elevators come to you

* .

* unit test

* mypy stuff

* Fine. I'll fix the fcking commented out code. Happy?

* ruff

* """""Backwards compatibility"""""

* ruff

* make it look better

* #

* fix presets

* fix a unit test

* Make that explicit in the code

* Improve description
2024-11-29 20:45:44 +01:00
NewSoupVi
a5231a27cc Yacht Dice: Mark YachtWeights.py as "linguist-generated" (#3898)
This means its diff will be collapsed by default on PRs that change it, because it is an "auto generated" file that does not need to be looked at by reviewers
2024-11-29 20:45:10 +01:00
qwint
1454bacfdd HK: better error messaging for charm plando (#3907) 2024-11-29 20:43:33 +01:00
Jouramie
ed4e44b994 Stardew Valley: Remove some events for a slight performance increase (#4085) 2024-11-29 20:41:26 +01:00
Benjamin S Wolf
d36c983461 Core: Log warnings at call site, not Utils itself (#4229) 2024-11-29 20:40:02 +01:00
black-sliver
05aa96a335 CI: use py3.12 for the linux and windows builds (#4284)
* CI: use py3.12 for the linux build

* CI: use py3.12 for the windows build
2024-11-29 20:07:14 +01:00
Bryce Wilson
6f2464d4ad Pokemon Emerald: Rework tags/dynamically create item and location groups (#3263)
* Pokemon Emerald: Rework location tags to categories

* Pokemon Emerald: Rework item tags, automatically create item/location groups

* Pokemon Emerald: Move item and location groups to data.py, add some regional location groups

* Map Regions

* Pokemon Emerald: Fix up location groups

* Pokemon Emerald: Move groups to their own file

* Pokemon Emerald: Add meta groups for location groups

* Pokemon Emerald: Fix has_group using updated item group name

* Pokemon Emerald: Add sanity check for maps in location groups

* Pokemon Emerald: Remove missed use of location.tags

* Pokemon Emerald: Reclassify white and black flutes

* Pokemon Emerald: Update changelog

* Pokemon Emerald: Adjust changelog

---------

Co-authored-by: Tsukino <16899482+Tsukino-uwu@users.noreply.github.com>
2024-11-29 09:24:24 +01:00
ken
91185f4f7c Core: Add timestamps to logging for seed generation (#3028)
* Add timestamps to logging for improved debugging

* Add datetime to general logging; particularly useful for large seeds.

* Move console timestamps from Main to Utils.init_logging (better location)

* Update Main.py

remove spurious blank line

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

---------

Co-authored-by: Zach Parks <zach@alliware.com>
Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
2024-11-29 07:16:54 +01:00
NewSoupVi
1371c63a8d Core: Actually take item from pool when plandoing from_pool (#2420)
* Actually take item from pool when plandoing from_pool

* Remove the awkward index thing

* oops left a comment in

* there wasn't a line break here before

* Only remove if actually found, check against player number

* oops

* Go back to index based system so we can just remove at the end

* Comment

* Fix error on None

* Update Fill.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-11-29 07:14:23 +01:00
Fabian Dill
30b414429f LTTP: sort of use new options system (#3764)
* LttP: switch to dataclass options definition

* LttP: write old options onto multiworld
LttP: use World.random
2024-11-29 05:02:26 +01:00
Solidus Snake
ce210cd4ee SMZ3: Add Start Inventory From Pool (#4252)
* Add Start Inventory From Pool

Just as the title implies

* Update Options.py

Fix dataclass since I had just pulled changes from prior options.py without seeing if anythin had changed

* Update Options.py

One more time with feeling

* Update worlds/smz3/Options.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-11-29 02:16:50 +01:00
BootsinSoots
8923b06a49 Webhost: Make YGO 06 setup title match page #4262
Make Guide title match the rest of the set up guides on the webhost
2024-11-29 02:16:12 +01:00
Emily
b783eab1e8 Core: Introduce 'Hint Priority' concept (#3506)
* Introduce 'Hint Priority' concept

* fix error when sorting hints while not connected

* fix 'found' -> 'status' kivy stuff

* remove extraneous warning

this warning fired if you clicked to select or toggle priority of any hint, as you weren't clicking on the header...

* skip scanning individual header widgets when not clicking on the header

* update hints on disconnection

* minor cleanup

* minor fixes/cleanup

* fix: hints not updating properly for receiving player

* update re: review

* 'type() is' -> 'isinstance()'

* cleanup, re: Jouramie's review

* Change 'priority' to 'status', add 'Unspecified' and 'Avoid' statuses, update colors

* cleanup

* move dicts out of functions

* fix: new hints being returned when hint already exists

* fix: show `Found` properly when hinting already-found hints

* import `Hint` and `HintStatus` directly from `NetUtils`

* Default any hinted `Trap` item to be classified as `Avoid` by default

* add some sanity checks

* re: Vi's feedback

* move dict out of function

* Update kvui.py

* remove unneeded dismiss message

* allow lclick to drop hint status dropdown

* underline hint statuses to indicate clickability

* only underline clickable statuses

* Update kvui.py

* Update kvui.py

---------

Co-authored-by: Silvris <58583688+Silvris@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-11-29 02:10:31 +01:00
Fabian Dill
b972e8c071 Core: fix deprecation warning for utcnow() in setup.py (#4170) 2024-11-29 01:57:18 +01:00
josephwhite
faeb54224e Super Mario 64: Option groups (#4161)
* sm64ex: add option groups

* sm64ex: rename sanity options group to item options

* sm64ex: rename sanity options group to logic options

* sm64ex: seperate star costs from goal options and add entrance rando to logic options

* sm64ex: seperate ability options from logic options group
2024-11-29 01:45:26 +01:00
Justus Lind
1ba7700283 Muse Dash: Change AttributeError to KeyError when Create_Item receives an item name that doesn't exist in the world (#4215)
* Change missing attribute error to key error.

* Swap to explicit key error

* Revert "Swap to explicit key error"

This reverts commit 719255891e.
2024-11-29 01:44:21 +01:00
NewSoupVi
710cf4ebba Core: Add __iter__ to VerifyKeys (#3550)
* Add __iter__ to VerifyKeys

* Typing
2024-11-29 01:42:08 +01:00
NewSoupVi
82260d728f The Witness: Add Fast Travel Option (#3766)
* add unlockable warps

* Change Swamp Near Platform to Swamp Platform

* apply changes to variety as well
2024-11-29 01:41:40 +01:00
NewSoupVi
62e4285924 Core: Make region.add_exits return the created Entrances (#3885)
* Core: Make region.add_exits return the created Entrances

* Update BaseClasses.py

* Update BaseClasses.py

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

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2024-11-29 01:41:13 +01:00
Exempt-Medic
ce78c75999 OoT: Turn Logic Tricks into an OptionSet (#3551)
* Alphabetizing WebHost display for logic tricks

* Convert to a Set

* Changing this back to match upstream
2024-11-29 01:40:53 +01:00
Exempt-Medic
c022c742b5 Core: Add item.filler helper (#4081)
* Add filler helper

* Update BaseClasses.py
2024-11-29 01:38:53 +01:00
Mysteryem
3cb5219e09 Core: Fix playthrough only checking half of the sphere 0 items (#4268)
* Core: Fix playthrough only checking half of the sphere 0 items

The lists of precollected items were being mutated while iterating those
same lists, causing playthrough to skip checking half of the sphere 0
advancement items.

This patch ensures the lists are copied before they are iterated.

* Replace chain.from_iterable with two for loops for better clarity

Added a comment to `multiworld.push_precollected(item)` to explain that
it is also modifying `precollected_items`.
2024-11-29 01:38:17 +01:00
NewSoupVi
5d30d16e09 Docs: Mention explicit_indirect_conditions & "Menu" -> origin_region_name (#3887)
* Docs: Mention explicit_indirect_conditions

https://github.com/ArchipelagoMW/Archipelago/pull/3682

* Update world api.md

* Docs: "Menu" -> origin_region_name

https://github.com/ArchipelagoMW/Archipelago/pull/3682

* Update docs/world api.md

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>

* Update world api.md

* I just didn't do this one and then Medic approved it anyway LMAO

* Update world api.md

---------

Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
2024-11-29 01:37:33 +01:00
NewSoupVi
4780fd9974 The Witness: Rename some *horrendously* named variables (#4258)
* Rename all instances of 'multi' to 'progressive' and all instances of 'prog' to 'progression'

* We do a little reordering

* More

* One more
2024-11-29 01:37:19 +01:00
LiquidCat64
3ba0576cf6 CV64: Fix the first Waterway 3HB ledge setting the flag of one of the Nitro room item locations. #4277 2024-11-29 01:36:21 +01:00
axe-y
283d1ab7e8 DLC Quest Bug Fix 50+ coin bundle basic Campaign (#4276)
* DLC Quest Bug Fix

* DLC Quest Bug Fix
2024-11-29 01:35:09 +01:00
Shiny
78bc7b8156 Docs: update Pokemon R/B spanish guide (#2672)
* Update setup_es.md

* Update setup_es.md

i'm stupid and actually didn't edit the client chose part lol
2024-11-28 21:43:58 +01:00
Lolo
a07ddb4371 Docs: (Re)write french alttp setup guide and game page (#2296) 2024-11-28 17:13:14 +01:00
Tim Mahan
4395c608e8 [Docs] Update the macOS guide to match changes in core (#4265)
* Update mac_en.md

Updated the minimum version recommended to a version actually supported by AP.

* 3.13 is not in fact, supported.

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

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2024-11-28 08:41:13 +01:00
nmorale5
f4322242a1 Pokemon RB - Fix Incorrect Item Location in Victory Road 2F (#4260) 2024-11-28 02:43:37 +01:00
black-sliver
a3711eb463 Launcher: fix detection of valid .apworld (#4272) 2024-11-28 01:46:06 +01:00
Scipio Wright
6656528d78 TUNIC: Fix missing ladder rule for library fuse #4271 2024-11-28 01:43:52 +01:00
NewSoupVi
e1f16c6721 WebHost: Fix crash on advanced options when a Range option used "random" as its default (#4263) 2024-11-27 14:19:52 +01:00
Fabian Dill
334781e976 Core: purge py3.8 and py3.9 (#3973)
Co-authored-by: Remy Jette <remy@remyjette.com>
Co-authored-by: Jouramie <16137441+Jouramie@users.noreply.github.com>
Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
2024-11-27 03:28:00 +01:00
NewSoupVi
6c939d2d59 The Witness: Rename "Panel Hunt Settings" to "Panel Hunt Options" (#4251)
Who let me get away with this lmao
2024-11-27 02:49:18 +01:00
agilbert1412
e882c68277 Stardew Valley - Update documentation 5.x.x links into 6.x.x links #4255 2024-11-27 02:09:53 +01:00
NewSoupVi
dbf284d4b2 The Witness: Give an actual name to the new option (lol) #4238 2024-11-27 02:09:13 +01:00
agilbert1412
75624042f7 Stardew Valley: Make progressive movie theater a progression trap (#3985) 2024-11-27 00:44:33 +01:00
634 changed files with 40481 additions and 10350 deletions

1
.gitattributes vendored
View File

@@ -1 +1,2 @@
worlds/blasphemous/region_data.py linguist-generated=true
worlds/yachtdice/YachtWeights.py linguist-generated=true

View File

@@ -1,8 +1,20 @@
{
"include": [
"type_check.py",
"../BizHawkClient.py",
"../Patch.py",
"../test/general/test_groups.py",
"../test/general/test_helpers.py",
"../test/general/test_memory.py",
"../test/general/test_names.py",
"../test/multiworld/__init__.py",
"../test/multiworld/test_multiworlds.py",
"../test/netutils/__init__.py",
"../test/programs/__init__.py",
"../test/programs/test_multi_server.py",
"../test/utils/__init__.py",
"../test/webhost/test_descriptions.py",
"../worlds/AutoSNIClient.py",
"../Patch.py"
"type_check.py"
],
"exclude": [
@@ -16,7 +28,7 @@
"reportMissingImports": true,
"reportMissingTypeStubs": true,
"pythonVersion": "3.8",
"pythonVersion": "3.10",
"pythonPlatform": "Windows",
"executionEnvironments": [

View File

@@ -53,7 +53,7 @@ jobs:
- uses: actions/setup-python@v5
if: env.diff != ''
with:
python-version: 3.8
python-version: '3.10'
- name: "Install dependencies"
if: env.diff != ''

View File

@@ -24,14 +24,15 @@ env:
jobs:
# build-release-macos: # LF volunteer
build-win-py38: # RCs will still be built and signed by hand
build-win: # RCs will still be built and signed by hand
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
with:
python-version: '3.8'
python-version: '~3.12.7'
check-latest: true
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
@@ -111,10 +112,11 @@ jobs:
- name: Get a recent python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '~3.12.7'
check-latest: true
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.11" >> $GITHUB_ENV
echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
@@ -130,7 +132,7 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"

View File

@@ -11,7 +11,7 @@ on:
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**.CMakeLists'
- '**/CMakeLists.txt'
- '.github/workflows/ctest.yml'
pull_request:
paths:
@@ -21,7 +21,7 @@ on:
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**.CMakeLists'
- '**/CMakeLists.txt'
- '.github/workflows/ctest.yml'
jobs:

View File

@@ -44,10 +44,11 @@ jobs:
- name: Get a recent python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '~3.12.7'
check-latest: true
- name: Install build-time dependencies
run: |
echo "PYTHON=python3.11" >> $GITHUB_ENV
echo "PYTHON=python3.12" >> $GITHUB_ENV
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
chmod a+rx appimagetool-x86_64.AppImage
./appimagetool-x86_64.AppImage --appimage-extract
@@ -63,7 +64,7 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"

View File

@@ -40,10 +40,10 @@ jobs:
run: |
wget https://apt.llvm.org/llvm.sh
chmod +x ./llvm.sh
sudo ./llvm.sh 17
sudo ./llvm.sh 19
- name: Install scan-build command
run: |
sudo apt install clang-tools-17
sudo apt install clang-tools-19
- name: Get a recent python
uses: actions/setup-python@v5
with:
@@ -56,7 +56,7 @@ jobs:
- name: scan-build
run: |
source venv/bin/activate
scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
- name: Store report
if: failure()
uses: actions/upload-artifact@v4

View File

@@ -26,7 +26,7 @@ jobs:
- name: "Install dependencies"
run: |
python -m pip install --upgrade pip pyright==1.1.358
python -m pip install --upgrade pip pyright==1.1.392.post0
python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes
- name: "pyright: strict check on specific files"

View File

@@ -33,13 +33,11 @@ jobs:
matrix:
os: [ubuntu-latest]
python:
- {version: '3.8'}
- {version: '3.9'}
- {version: '3.10'}
- {version: '3.11'}
- {version: '3.12'}
include:
- python: {version: '3.8'} # win7 compat
- python: {version: '3.10'} # old compat
os: windows-latest
- python: {version: '3.12'} # current
os: windows-latest

View File

@@ -1,18 +1,16 @@
from __future__ import annotations
import collections
import itertools
import functools
import logging
import random
import secrets
import typing # this can go away when Python 3.8 support is dropped
from argparse import Namespace
from collections import Counter, deque
from collections.abc import Collection, MutableSequence
from enum import IntEnum, IntFlag
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, Type)
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
from typing_extensions import NotRequired, TypedDict
@@ -20,7 +18,8 @@ import NetUtils
import Options
import Utils
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from entrance_rando import ERPlacementState
from worlds import AutoWorld
@@ -231,7 +230,7 @@ class MultiWorld():
for player in self.player_ids:
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
self.worlds[player] = world_type(self, player)
options_dataclass: typing.Type[Options.PerGameCommonOptions] = world_type.options_dataclass
options_dataclass: type[Options.PerGameCommonOptions] = world_type.options_dataclass
self.worlds[player].options = options_dataclass(**{option_key: getattr(args, option_key)[player]
for option_key in options_dataclass.type_hints})
@@ -428,12 +427,12 @@ class MultiWorld():
def get_location(self, location_name: str, player: int) -> Location:
return self.regions.location_cache[player][location_name]
def get_all_state(self, use_cache: bool) -> CollectionState:
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState:
cached = getattr(self, "_all_state", None)
if use_cache and cached:
return cached.copy()
ret = CollectionState(self)
ret = CollectionState(self, allow_partial_entrances)
for item in self.itempool:
self.worlds[item.player].collect(ret, item)
@@ -606,6 +605,49 @@ class MultiWorld():
state.collect(location.item, True, location)
locations -= sphere
def get_sendable_spheres(self) -> Iterator[Set[Location]]:
"""
yields a set of multiserver sendable locations (location.item.code: int) for each logical sphere
If there are unreachable locations, the last sphere of reachable locations is followed by an empty set,
and then a set of all of the unreachable locations.
"""
state = CollectionState(self)
locations: Set[Location] = set()
events: Set[Location] = set()
for location in self.get_filled_locations():
if type(location.item.code) is int:
locations.add(location)
else:
events.add(location)
while locations:
sphere: Set[Location] = set()
# cull events out
done_events: Set[Union[Location, None]] = {None}
while done_events:
done_events = set()
for event in events:
if event.can_reach(state):
state.collect(event.item, True, event)
done_events.add(event)
events -= done_events
for location in locations:
if location.can_reach(state):
sphere.add(location)
yield sphere
if not sphere:
if locations:
yield locations # unreachable locations
break
for location in sphere:
state.collect(location.item, True, location)
locations -= sphere
def fulfills_accessibility(self, state: Optional[CollectionState] = None):
"""Check if accessibility rules are fulfilled with current or supplied state."""
if not state:
@@ -676,10 +718,11 @@ class CollectionState():
path: Dict[Union[Region, Entrance], PathValue]
locations_checked: Set[Location]
stale: Dict[int, bool]
allow_partial_entrances: bool
additional_init_functions: List[Callable[[CollectionState, MultiWorld], None]] = []
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
def __init__(self, parent: MultiWorld):
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
@@ -688,6 +731,7 @@ class CollectionState():
self.path = {}
self.locations_checked = set()
self.stale = {player: True for player in parent.get_all_ids()}
self.allow_partial_entrances = allow_partial_entrances
for function in self.additional_init_functions:
function(self, parent)
for items in parent.precollected_items.values():
@@ -722,6 +766,8 @@ class CollectionState():
if new_region in reachable_regions:
blocked_connections.remove(connection)
elif connection.can_reach(self):
if self.allow_partial_entrances and not new_region:
continue
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
reachable_regions.add(new_region)
blocked_connections.remove(connection)
@@ -747,7 +793,9 @@ class CollectionState():
if new_region in reachable_regions:
blocked_connections.remove(connection)
elif connection.can_reach(self):
assert new_region, f"tried to search through an Entrance \"{connection}\" with no Region"
if self.allow_partial_entrances and not new_region:
continue
assert new_region, f"tried to search through an Entrance \"{connection}\" with no connected Region"
reachable_regions.add(new_region)
blocked_connections.remove(connection)
blocked_connections.update(new_region.exits)
@@ -767,6 +815,7 @@ class CollectionState():
ret.advancements = self.advancements.copy()
ret.path = self.path.copy()
ret.locations_checked = self.locations_checked.copy()
ret.allow_partial_entrances = self.allow_partial_entrances
for function in self.additional_copy_functions:
ret = function(self, ret)
return ret
@@ -820,21 +869,40 @@ class CollectionState():
def has(self, item: str, player: int, count: int = 1) -> bool:
return self.prog_items[player][item] >= count
# for loops are specifically used in all/any/count methods, instead of all()/any()/sum(), to avoid the overhead of
# creating and iterating generator instances. In `return all(player_prog_items[item] for item in items)`, the
# argument to all() would be a new generator instance, for example.
def has_all(self, items: Iterable[str], player: int) -> bool:
"""Returns True if each item name of items is in state at least once."""
return all(self.prog_items[player][item] for item in items)
player_prog_items = self.prog_items[player]
for item in items:
if not player_prog_items[item]:
return False
return True
def has_any(self, items: Iterable[str], player: int) -> bool:
"""Returns True if at least one item name of items is in state at least once."""
return any(self.prog_items[player][item] for item in items)
player_prog_items = self.prog_items[player]
for item in items:
if player_prog_items[item]:
return True
return False
def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool:
"""Returns True if each item name is in the state at least as many times as specified."""
return all(self.prog_items[player][item] >= count for item, count in item_counts.items())
player_prog_items = self.prog_items[player]
for item, count in item_counts.items():
if player_prog_items[item] < count:
return False
return True
def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool:
"""Returns True if at least one item name is in the state at least as many times as specified."""
return any(self.prog_items[player][item] >= count for item, count in item_counts.items())
player_prog_items = self.prog_items[player]
for item, count in item_counts.items():
if player_prog_items[item] >= count:
return True
return False
def count(self, item: str, player: int) -> int:
return self.prog_items[player][item]
@@ -862,11 +930,20 @@ class CollectionState():
def count_from_list(self, items: Iterable[str], player: int) -> int:
"""Returns the cumulative count of items from a list present in state."""
return sum(self.prog_items[player][item_name] for item_name in items)
player_prog_items = self.prog_items[player]
total = 0
for item_name in items:
total += player_prog_items[item_name]
return total
def count_from_list_unique(self, items: Iterable[str], player: int) -> int:
"""Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item."""
return sum(self.prog_items[player][item_name] > 0 for item_name in items)
player_prog_items = self.prog_items[player]
total = 0
for item_name in items:
if player_prog_items[item_name] > 0:
total += 1
return total
# item name group related
def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool:
@@ -931,6 +1008,11 @@ class CollectionState():
self.stale[item.player] = True
class EntranceType(IntEnum):
ONE_WAY = 1
TWO_WAY = 2
class Entrance:
access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True)
hide_path: bool = False
@@ -938,19 +1020,24 @@ class Entrance:
name: str
parent_region: Optional[Region]
connected_region: Optional[Region] = None
randomization_group: int
randomization_type: EntranceType
# LttP specific, TODO: should make a LttPEntrance
addresses = None
target = None
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None) -> None:
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
self.name = name
self.parent_region = parent
self.player = player
self.randomization_group = randomization_group
self.randomization_type = randomization_type
def can_reach(self, state: CollectionState) -> bool:
assert self.parent_region, f"called can_reach on an Entrance \"{self}\" with no parent_region"
if self.parent_region.can_reach(state) and self.access_rule(state):
if not self.hide_path and not self in state.path:
if not self.hide_path and self not in state.path:
state.path[self] = (self.name, state.path.get(self.parent_region, (self.parent_region.name, None)))
return True
@@ -962,6 +1049,32 @@ class Entrance:
self.addresses = addresses
region.entrances.append(self)
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
"""
Determines whether this is a valid source transition, that is, whether the entrance
randomizer is allowed to pair it to place any other regions. By default, this is the
same as a reachability check, but can be modified by Entrance implementations to add
other restrictions based on the placement state.
:param er_state: The current (partial) state of the ongoing entrance randomization
"""
return self.can_reach(er_state.collection_state)
def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool:
"""
Determines whether a given Entrance is a valid target transition, that is, whether
the entrance randomizer is allowed to pair this Entrance to that Entrance. By default,
only allows connection between entrances of the same type (one ways only go to one ways,
two ways always go to two ways) and prevents connecting an exit to itself in coupled mode.
:param other: The proposed Entrance to connect to
:param dead_end: Whether the other entrance considered a dead end by Entrance randomization
:param er_state: The current (partial) state of the ongoing entrance randomization
"""
# the implementation of coupled causes issues for self-loops since the reverse entrance will be the
# same as the forward entrance. In uncoupled they are ok.
return self.randomization_type == other.randomization_type and (not er_state.coupled or self.name != other.name)
def __repr__(self):
multiworld = self.parent_region.multiworld if self.parent_region else None
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
@@ -975,7 +1088,7 @@ class Region:
entrances: List[Entrance]
exits: List[Entrance]
locations: List[Location]
entrance_type: ClassVar[Type[Entrance]] = Entrance
entrance_type: ClassVar[type[Entrance]] = Entrance
class Register(MutableSequence):
region_manager: MultiWorld.RegionManager
@@ -1075,7 +1188,7 @@ class Region:
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
def add_locations(self, locations: Dict[str, Optional[int]],
location_type: Optional[Type[Location]] = None) -> None:
location_type: Optional[type[Location]] = None) -> None:
"""
Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address.
@@ -1111,8 +1224,18 @@ class Region:
self.exits.append(exit_)
return exit_
def create_er_target(self, name: str) -> Entrance:
"""
Creates and returns an Entrance object as an entrance to this region
:param name: name of the Entrance being created
"""
entrance = self.entrance_type(self.player, name)
entrance.connect(self)
return entrance
def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]],
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None:
rules: Dict[str, Callable[[CollectionState], bool]] = None) -> List[Entrance]:
"""
Connects current region to regions in exit dictionary. Passed region names must exist first.
@@ -1122,10 +1245,14 @@ class Region:
"""
if not isinstance(exits, Dict):
exits = dict.fromkeys(exits)
for connecting_region, name in exits.items():
self.connect(self.multiworld.get_region(connecting_region, self.player),
name,
rules[connecting_region] if rules and connecting_region in rules else None)
return [
self.connect(
self.multiworld.get_region(connecting_region, self.player),
name,
rules[connecting_region] if rules and connecting_region in rules else None,
)
for connecting_region, name in exits.items()
]
def __repr__(self):
return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})'
@@ -1209,13 +1336,26 @@ class Location:
class ItemClassification(IntFlag):
filler = 0b0000 # aka trash, as in filler items like ammo, currency etc,
progression = 0b0001 # Item that is logically relevant
useful = 0b0010 # Item that is generally quite useful, but not required for anything logical
trap = 0b0100 # detrimental item
skip_balancing = 0b1000 # should technically never occur on its own
# Item that is logically relevant, but progression balancing should not touch.
# Typically currency or other counted items.
filler = 0b0000
""" aka trash, as in filler items like ammo, currency etc """
progression = 0b0001
""" Item that is logically relevant.
Protects this item from being placed on excluded or unreachable locations. """
useful = 0b0010
""" Item that is especially useful.
Protects this item from being placed on excluded or unreachable locations.
When combined with another flag like "progression", it means "an especially useful progression item". """
trap = 0b0100
""" Item that is detrimental in some way. """
skip_balancing = 0b1000
""" should technically never occur on its own
Item that is logically relevant, but progression balancing should not touch.
Typically currency or other counted items. """
progression_skip_balancing = 0b1001 # only progression gets balanced
def as_flag(self) -> int:
@@ -1264,6 +1404,10 @@ class Item:
def trap(self) -> bool:
return ItemClassification.trap in self.classification
@property
def filler(self) -> bool:
return not (self.advancement or self.useful or self.trap)
@property
def excludable(self) -> bool:
return not (self.advancement or self.useful)
@@ -1386,14 +1530,21 @@ class Spoiler:
# second phase, sphere 0
removed_precollected: List[Item] = []
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
multiworld.precollected_items[item.player].remove(item)
multiworld.state.remove(item)
if not multiworld.can_beat_game():
multiworld.push_precollected(item)
else:
removed_precollected.append(item)
for precollected_items in multiworld.precollected_items.values():
# The list of items is mutated by removing one item at a time to determine if each item is required to beat
# the game, and re-adding that item if it was required, so a copy needs to be made before iterating.
for item in precollected_items.copy():
if not item.advancement:
continue
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
precollected_items.remove(item)
multiworld.state.remove(item)
if not multiworld.can_beat_game():
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
multiworld.push_precollected(item)
else:
removed_precollected.append(item)
# we are now down to just the required progress items in collection_spheres. Unfortunately
# the previous pruning stage could potentially have made certain items dependant on others
@@ -1532,7 +1683,7 @@ class Spoiler:
[f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else
[f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()]))
if self.unreachables:
outfile.write('\n\nUnreachable Items:\n\n')
outfile.write('\n\nUnreachable Progression Items:\n\n')
outfile.write(
'\n'.join(['%s: %s' % (unreachable.item, unreachable) for unreachable in self.unreachables]))

View File

@@ -23,7 +23,7 @@ if __name__ == "__main__":
from MultiServer import CommandProcessor
from NetUtils import (Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot,
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, SlotType)
RawJSONtoTextParser, add_json_text, add_json_location, add_json_item, JSONTypes, HintStatus, SlotType)
from Utils import Version, stream_input, async_start
from worlds import network_data_package, AutoWorldRegister
import os
@@ -31,6 +31,7 @@ import ssl
if typing.TYPE_CHECKING:
import kvui
import argparse
logger = logging.getLogger("Client")
@@ -412,6 +413,7 @@ class CommonContext:
await self.server.socket.close()
if self.server_task is not None:
await self.server_task
self.ui.update_hints()
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
""" `msgs` JSON serializable """
@@ -458,6 +460,13 @@ class CommonContext:
await self.send_msgs([payload])
await self.send_msgs([{"cmd": "Get", "keys": ["_read_race_mode"]}])
async def check_locations(self, locations: typing.Collection[int]) -> set[int]:
"""Send new location checks to the server. Returns the set of actually new locations that were sent."""
locations = set(locations) & self.missing_locations
if locations:
await self.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(locations)}])
return locations
async def console_input(self) -> str:
if self.ui:
self.ui.focus_textinput()
@@ -551,7 +560,14 @@ class CommonContext:
await self.ui_task
if self.input_task:
self.input_task.cancel()
# Hints
def update_hint(self, location: int, finding_player: int, status: typing.Optional[HintStatus]) -> None:
msg = {"cmd": "UpdateHint", "location": location, "player": finding_player}
if status is not None:
msg["status"] = status
async_start(self.send_msgs([msg]), name="update_hint")
# DataPackage
async def prepare_data_package(self, relevant_games: typing.Set[str],
remote_date_package_versions: typing.Dict[str, int],
@@ -693,8 +709,16 @@ class CommonContext:
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
self._messagebox_connection_loss = self.gui_error(msg, exc_info[1])
def make_gui(self) -> typing.Type["kvui.GameManager"]:
"""To return the Kivy App class needed for run_gui so it can be overridden before being built"""
def make_gui(self) -> "type[kvui.GameManager]":
"""
To return the Kivy `App` class needed for `run_gui` so it can be overridden before being built
Common changes are changing `base_title` to update the window title of the client and
updating `logging_pairs` to automatically make new tabs that can be filled with their respective logger.
ex. `logging_pairs.append(("Foo", "Bar"))`
will add a "Bar" tab which follows the logger returned from `logging.getLogger("Foo")`
"""
from kvui import GameManager
class TextManager(GameManager):
@@ -883,6 +907,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.disconnected_intentionally = True
ctx.event_invalid_game()
elif 'IncompatibleVersion' in errors:
ctx.disconnected_intentionally = True
raise Exception('Server reported your client version as incompatible. '
'This probably means you have to update.')
elif 'InvalidItemsHandling' in errors:
@@ -1033,6 +1058,32 @@ def get_base_parser(description: typing.Optional[str] = None):
return parser
def handle_url_arg(args: "argparse.Namespace",
parser: "typing.Optional[argparse.ArgumentParser]" = None) -> "argparse.Namespace":
"""
Parse the url arg "archipelago://name:pass@host:port" from launcher into correct launch args for CommonClient
If alternate data is required the urlparse response is saved back to args.url if valid
"""
if not args.url:
return args
url = urllib.parse.urlparse(args.url)
if url.scheme != "archipelago":
if not parser:
parser = get_base_parser()
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
return args
args.url = url
args.connect = url.netloc
if url.username:
args.name = urllib.parse.unquote(url.username)
if url.password:
args.password = urllib.parse.unquote(url.password)
return args
def run_as_textclient(*args):
class TextContext(CommonContext):
# Text Mode to use !hint and such with games that have no text entry
@@ -1045,7 +1096,7 @@ def run_as_textclient(*args):
if password_requested and not self.password:
await super(TextContext, self).server_auth(password_requested)
await self.get_username()
await self.send_connect()
await self.send_connect(game="")
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
@@ -1074,17 +1125,7 @@ def run_as_textclient(*args):
parser.add_argument("url", nargs="?", help="Archipelago connection url")
args = parser.parse_args(args)
# handle if text client is launched using the "archipelago://name:pass@host:port" url from webhost
if args.url:
url = urllib.parse.urlparse(args.url)
if url.scheme == "archipelago":
args.connect = url.netloc
if url.username:
args.name = urllib.parse.unquote(url.username)
if url.password:
args.password = urllib.parse.unquote(url.password)
else:
parser.error(f"bad url, found {args.url}, expected url in form of archipelago://archipelago.gg:38281")
args = handle_url_arg(args, parser=parser)
# use colorama to display colored text highlighting on windows
colorama.init()

108
Fill.py
View File

@@ -36,7 +36,8 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False, allow_excluded: bool = False, name: str = "Unknown") -> None:
allow_partial: bool = False, allow_excluded: bool = False, one_item_per_player: bool = True,
name: str = "Unknown") -> None:
"""
:param multiworld: Multiworld to be filled.
:param base_state: State assumed before fill.
@@ -63,14 +64,22 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
placed = 0
while any(reachable_items.values()) and locations:
# grab one item per player
items_to_place = [items.pop()
for items in reachable_items.values() if items]
if one_item_per_player:
# grab one item per player
items_to_place = [items.pop()
for items in reachable_items.values() if items]
else:
next_player = multiworld.random.choice([player for player, items in reachable_items.items() if items])
items_to_place = []
if item_pool:
items_to_place.append(reachable_items[next_player].pop())
for item in items_to_place:
for p, pool_item in enumerate(item_pool):
if pool_item is item:
item_pool.pop(p)
break
maximum_exploration_state = sweep_from_pool(
base_state, item_pool + unplaced_items, multiworld.get_filled_locations(item.player)
if single_player_placement else None)
@@ -226,18 +235,30 @@ def remaining_fill(multiworld: MultiWorld,
locations: typing.List[Location],
itempool: typing.List[Item],
name: str = "Remaining",
move_unplaceable_to_start_inventory: bool = False) -> None:
move_unplaceable_to_start_inventory: bool = False,
check_location_can_fill: bool = False) -> None:
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
total = min(len(itempool), len(locations))
placed = 0
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
if check_location_can_fill:
state = CollectionState(multiworld)
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
return location_to_fill.can_fill(state, item_to_fill, check_access=False)
else:
def location_can_fill_item(location_to_fill: Location, item_to_fill: Item):
return location_to_fill.item_rule(item_to_fill)
while locations and itempool:
item_to_place = itempool.pop()
spot_to_fill: typing.Optional[Location] = None
for i, location in enumerate(locations):
if location.item_rule(item_to_place):
if location_can_fill_item(location, item_to_place):
# popping by index is faster than removing by content,
spot_to_fill = locations.pop(i)
# skipping a scan for the element
@@ -258,7 +279,7 @@ def remaining_fill(multiworld: MultiWorld,
location.item = None
placed_item.location = None
if location.item_rule(item_to_place):
if location_can_fill_item(location, item_to_place):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
@@ -480,7 +501,14 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if prioritylocations:
# "priority fill"
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority")
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority", one_item_per_player=True, allow_partial=True)
if prioritylocations:
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False)
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
defaultlocations = prioritylocations + defaultlocations
@@ -509,7 +537,8 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if progitempool:
raise FillError(
f"Not enough locations for progression items. "
f"There are {len(progitempool)} more progression items than there are available locations.",
f"There are {len(progitempool)} more progression items than there are available locations.\n"
f"Unfilled locations:\n{multiworld.get_unfilled_locations()}.",
multiworld=multiworld,
)
accessibility_corrections(multiworld, multiworld.state, defaultlocations)
@@ -527,7 +556,7 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if excludedlocations:
raise FillError(
f"Not enough filler items for excluded locations. "
f"There are {len(excludedlocations)} more excluded locations than filler or trap items.",
f"There are {len(excludedlocations)} more excluded locations than excludable items.",
multiworld=multiworld,
)
@@ -548,6 +577,26 @@ def distribute_items_restrictive(multiworld: MultiWorld,
print_data = {"items": items_counter, "locations": locations_counter}
logging.info(f"Per-Player counts: {print_data})")
more_locations = locations_counter - items_counter
more_items = items_counter - locations_counter
for player in multiworld.player_ids:
if more_locations[player]:
logging.error(
f"Player {multiworld.get_player_name(player)} had {more_locations[player]} more locations than items.")
elif more_items[player]:
logging.warning(
f"Player {multiworld.get_player_name(player)} had {more_items[player]} more items than locations.")
if unfilled:
raise FillError(
f"Unable to fill all locations.\n" +
f"Unfilled locations({len(unfilled)}): {unfilled}"
)
else:
logging.warning(
f"Unable to place all items.\n" +
f"Unplaced items({len(unplaced)}): {unplaced}"
)
def flood_items(multiworld: MultiWorld) -> None:
# get items to distribute
@@ -978,15 +1027,32 @@ def distribute_planned(multiworld: MultiWorld) -> None:
multiworld.random.shuffle(items)
count = 0
err: typing.List[str] = []
successful_pairs: typing.List[typing.Tuple[Item, Location]] = []
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
claimed_indices: typing.Set[typing.Optional[int]] = set()
for item_name in items:
item = multiworld.worlds[player].create_item(item_name)
index_to_delete: typing.Optional[int] = None
if from_pool:
try:
# If from_pool, try to find an existing item with this name & player in the itempool and use it
index_to_delete, item = next(
(i, item) for i, item in enumerate(multiworld.itempool)
if item.player == player and item.name == item_name and i not in claimed_indices
)
except StopIteration:
warn(
f"Could not remove {item_name} from pool for {multiworld.player_name[player]} as it's already missing from it.",
placement['force'])
item = multiworld.worlds[player].create_item(item_name)
else:
item = multiworld.worlds[player].create_item(item_name)
for location in reversed(candidates):
if (location.address is None) == (item.code is None): # either both None or both not None
if not location.item:
if location.item_rule(item):
if location.can_fill(multiworld.state, item, False):
successful_pairs.append((item, location))
successful_pairs.append((index_to_delete, item, location))
claimed_indices.add(index_to_delete)
candidates.remove(location)
count = count + 1
break
@@ -998,6 +1064,7 @@ def distribute_planned(multiworld: MultiWorld) -> None:
err.append(f"Cannot place {item_name} into already filled location {location}.")
else:
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
if count == maxcount:
break
if count < placement['count']['min']:
@@ -1005,17 +1072,16 @@ def distribute_planned(multiworld: MultiWorld) -> None:
failed(
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
for (item, location) in successful_pairs:
# Sort indices in reverse so we can remove them one by one
successful_pairs = sorted(successful_pairs, key=lambda successful_pair: successful_pair[0] or 0, reverse=True)
for (index, item, location) in successful_pairs:
multiworld.push_item(location, item, collect=False)
location.locked = True
logging.debug(f"Plando placed {item} at {location}")
if from_pool:
try:
multiworld.itempool.remove(item)
except ValueError:
warn(
f"Could not remove {item} from pool for {multiworld.player_name[player]} as it's already missing from it.",
placement['force'])
if index is not None: # If this item is from_pool and was found in the pool, remove it.
multiworld.itempool.pop(index)
except Exception as e:
raise Exception(

View File

@@ -42,7 +42,9 @@ def mystery_argparse():
help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd
parser.add_argument('--race', action='store_true', default=defaults.race)
parser.add_argument('--meta_file_path', default=defaults.meta_file_path)
parser.add_argument('--log_level', default='info', help='Sets log level')
parser.add_argument('--log_level', default=defaults.loglevel, help='Sets log level')
parser.add_argument('--log_time', help="Add timestamps to STDOUT",
default=defaults.logtime, action='store_true')
parser.add_argument("--csv_output", action="store_true",
help="Output rolled player options to csv (made for async multiworld).")
parser.add_argument("--plando", default=defaults.plando_options,
@@ -75,7 +77,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level, add_timestamp=args.log_time)
random.seed(seed)
seed_name = get_seed_name(random)
@@ -114,7 +116,14 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
path = os.path.join(args.player_files_path, fname)
try:
weights_cache[fname] = read_weights_yamls(path)
weights_for_file = []
for doc_idx, yaml in enumerate(read_weights_yamls(path)):
if yaml is None:
logging.warning(f"Ignoring empty yaml document #{doc_idx + 1} in {fname}")
else:
weights_for_file.append(yaml)
weights_cache[fname] = tuple(weights_for_file)
except Exception as e:
raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e
@@ -431,7 +440,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
if "linked_options" in weights:
weights = roll_linked_options(weights)
valid_keys = set()
valid_keys = {"triggers"}
if "triggers" in weights:
weights = roll_triggers(weights, weights["triggers"], valid_keys)
@@ -490,15 +499,23 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
for option_key, option in world_type.options_dataclass.type_hints.items():
handle_option(ret, game_weights, option_key, option, plando_options)
valid_keys.add(option_key)
for option_key in game_weights:
if option_key in {"triggers", *valid_keys}:
continue
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers.")
# TODO remove plando_items after moving it to the options system
valid_keys.add("plando_items")
if PlandoOptions.items in plando_options:
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
if ret.game == "A Link to the Past":
# TODO there are still more LTTP options not on the options system
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}
roll_alttp_settings(ret, game_weights)
# log a warning for options within a game section that aren't determined as valid
for option_key in game_weights:
if option_key in valid_keys:
continue
logging.warning(f"{option_key} is not a valid option name for {ret.game} and is not present in triggers "
f"for player {ret.name}.")
return ret

View File

@@ -1,7 +1,7 @@
MIT License
Copyright (c) 2017 LLCoolDave
Copyright (c) 2022 Berserker66
Copyright (c) 2025 Berserker66
Copyright (c) 2022 CaitSith2
Copyright (c) 2021 LegendaryLinux

View File

@@ -126,12 +126,13 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
elif component.display_name == "Text Client":
text_client_component = component
from kvui import App, Button, BoxLayout, Label, Clock, Window
if client_component is None:
run_component(text_client_component, *launch_args)
return
from kvui import App, Button, BoxLayout, Label, Window
class Popup(App):
timer_label: Label
remaining_time: Optional[int]
def __init__(self):
self.title = "Connect to Multiworld"
self.icon = r"data/icon.png"
@@ -139,48 +140,25 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
def build(self):
layout = BoxLayout(orientation="vertical")
layout.add_widget(Label(text="Select client to open and connect with."))
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
if client_component is None:
self.remaining_time = 7
label_text = (f"A game client able to parse URIs was not detected for {game}.\n"
f"Launching Text Client in 7 seconds...")
self.timer_label = Label(text=label_text)
layout.add_widget(self.timer_label)
Clock.schedule_interval(self.update_label, 1)
else:
layout.add_widget(Label(text="Select client to open and connect with."))
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
text_client_button = Button(
text=text_client_component.display_name,
on_release=lambda *args: run_component(text_client_component, *launch_args)
)
button_row.add_widget(text_client_button)
text_client_button = Button(
text=text_client_component.display_name,
on_release=lambda *args: run_component(text_client_component, *launch_args)
)
button_row.add_widget(text_client_button)
game_client_button = Button(
text=client_component.display_name,
on_release=lambda *args: run_component(client_component, *launch_args)
)
button_row.add_widget(game_client_button)
game_client_button = Button(
text=client_component.display_name,
on_release=lambda *args: run_component(client_component, *launch_args)
)
button_row.add_widget(game_client_button)
layout.add_widget(button_row)
layout.add_widget(button_row)
return layout
def update_label(self, dt):
if self.remaining_time > 1:
# countdown the timer and string replace the number
self.remaining_time -= 1
self.timer_label.text = self.timer_label.text.replace(
str(self.remaining_time + 1), str(self.remaining_time)
)
else:
# our timer is finished so launch text client and close down
run_component(text_client_component, *launch_args)
Clock.unschedule(self.update_label)
App.get_running_app().stop()
Window.close()
def _stop(self, *largs):
# see run_gui Launcher _stop comment for details
self.root_window.close()
@@ -246,9 +224,8 @@ refresh_components: Optional[Callable[[], None]] = None
def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage
from kivy.core.window import Window
from kivy.uix.image import AsyncImage
from kivy.uix.relativelayout import RelativeLayout
class Launcher(App):
@@ -281,8 +258,8 @@ def run_gui():
button.component = component
button.bind(on_release=self.component_action)
if component.icon != "icon":
image = AsyncImage(source=icon_paths[component.icon],
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
image = ApAsyncImage(source=icon_paths[component.icon],
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
box_layout = RelativeLayout(size_hint_y=None, height=40)
box_layout.add_widget(button)
box_layout.add_widget(image)

View File

@@ -28,6 +28,7 @@ from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
from NetUtils import ClientStatus
from worlds.ladx.Common import BASE_ID as LABaseID
from worlds.ladx.GpsTracker import GpsTracker
from worlds.ladx.TrackerConsts import storage_key
from worlds.ladx.ItemTracker import ItemTracker
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
@@ -100,19 +101,23 @@ class LAClientConstants:
WRamCheckSize = 0x4
WRamSafetyValue = bytearray([0]*WRamCheckSize)
wRamStart = 0xC000
hRamStart = 0xFF80
hRamSize = 0x80
MinGameplayValue = 0x06
MaxGameplayValue = 0x1A
VictoryGameplayAndSub = 0x0102
class RAGameboy():
cache = []
cache_start = 0
cache_size = 0
last_cache_read = None
socket = None
def __init__(self, address, port) -> None:
self.cache_start = LAClientConstants.wRamStart
self.cache_size = LAClientConstants.hRamStart + LAClientConstants.hRamSize - LAClientConstants.wRamStart
self.address = address
self.port = port
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@@ -131,9 +136,14 @@ class RAGameboy():
async def get_retroarch_status(self):
return await self.send_command("GET_STATUS")
def set_cache_limits(self, cache_start, cache_size):
self.cache_start = cache_start
self.cache_size = cache_size
def set_checks_range(self, checks_start, checks_size):
self.checks_start = checks_start
self.checks_size = checks_size
def set_location_range(self, location_start, location_size, critical_addresses):
self.location_start = location_start
self.location_size = location_size
self.critical_location_addresses = critical_addresses
def send(self, b):
if type(b) is str:
@@ -188,21 +198,57 @@ class RAGameboy():
if not await self.check_safe_gameplay():
return
cache = []
remaining_size = self.cache_size
while remaining_size:
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
remaining_size -= len(block)
cache += block
attempts = 0
while True:
# RA doesn't let us do an atomic read of a large enough block of RAM
# Some bytes can't change in between reading location_block and hram_block
location_block = await self.read_memory_block(self.location_start, self.location_size)
hram_block = await self.read_memory_block(LAClientConstants.hRamStart, LAClientConstants.hRamSize)
verification_block = await self.read_memory_block(self.location_start, self.location_size)
valid = True
for address in self.critical_location_addresses:
if location_block[address - self.location_start] != verification_block[address - self.location_start]:
valid = False
if valid:
break
attempts += 1
# Shouldn't really happen, but keep it from choking
if attempts > 5:
return
checks_block = await self.read_memory_block(self.checks_start, self.checks_size)
if not await self.check_safe_gameplay():
return
self.cache = cache
self.cache = bytearray(self.cache_size)
start = self.checks_start - self.cache_start
self.cache[start:start + len(checks_block)] = checks_block
start = self.location_start - self.cache_start
self.cache[start:start + len(location_block)] = location_block
start = LAClientConstants.hRamStart - self.cache_start
self.cache[start:start + len(hram_block)] = hram_block
self.last_cache_read = time.time()
async def read_memory_block(self, address: int, size: int):
block = bytearray()
remaining_size = size
while remaining_size:
chunk = await self.async_read_memory(address + len(block), remaining_size)
remaining_size -= len(chunk)
block += chunk
return block
async def read_memory_cache(self, addresses):
# TODO: can we just update once per frame?
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
await self.update_cache()
if not self.cache:
@@ -235,7 +281,7 @@ class RAGameboy():
def check_command_response(self, command: str, response: bytes):
if command == "VERSION":
ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None
ok = re.match(r"\d+\.\d+\.\d+", response.decode('ascii')) is not None
else:
ok = response.startswith(command.encode())
if not ok:
@@ -359,11 +405,12 @@ class LinksAwakeningClient():
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
self.auth = auth
async def wait_and_init_tracker(self):
async def wait_and_init_tracker(self, magpie: MagpieBridge):
await self.wait_for_game_ready()
self.tracker = LocationTracker(self.gameboy)
self.item_tracker = ItemTracker(self.gameboy)
self.gps_tracker = GpsTracker(self.gameboy)
magpie.gps_tracker = self.gps_tracker
async def recved_item_from_ap(self, item_id, from_player, next_index):
# Don't allow getting an item until you've got your first check
@@ -405,9 +452,11 @@ class LinksAwakeningClient():
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
await self.gameboy.update_cache()
await self.tracker.readChecks(item_get_cb)
await self.item_tracker.readItems()
await self.gps_tracker.read_location()
await self.gps_tracker.read_entrances()
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
if self.deathlink_debounce and current_health != 0:
@@ -465,6 +514,10 @@ class LinksAwakeningContext(CommonContext):
magpie_task = None
won = False
@property
def slot_storage_key(self):
return f"{self.slot_info[self.slot].name}_{storage_key}"
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
self.client = LinksAwakeningClient()
self.slot_data = {}
@@ -507,7 +560,19 @@ class LinksAwakeningContext(CommonContext):
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def send_checks(self):
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
message = [{"cmd": "LocationChecks", "locations": self.found_checks}]
await self.send_msgs(message)
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
# Store the entrances we find on the server for future sessions
message = [{
"cmd": "Set",
"key": self.slot_storage_key,
"default": {},
"want_reply": False,
"operations": [{"operation": "update", "value": entrances}],
}]
await self.send_msgs(message)
had_invalid_slot_data = None
@@ -536,6 +601,12 @@ class LinksAwakeningContext(CommonContext):
logger.info("victory!")
await self.send_msgs(message)
self.won = True
async def request_found_entrances(self):
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
# Ask for updates so that players can co-op entrances in a seed
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
if self.ENABLE_DEATHLINK:
@@ -560,6 +631,10 @@ class LinksAwakeningContext(CommonContext):
while self.client.auth == None:
await asyncio.sleep(0.1)
# Just return if we're closing
if self.exit_event.is_set():
return
self.auth = self.client.auth
await self.send_connect()
@@ -572,6 +647,12 @@ class LinksAwakeningContext(CommonContext):
if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]):
self.client.recvd_checks[index] = item
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
if cmd == "SetReply" and self.magpie_enabled and args["key"] == self.slot_storage_key:
self.client.gps_tracker.receive_found_entrances(args["value"])
async def sync(self):
sync_msg = [{'cmd': 'Sync'}]
@@ -585,6 +666,12 @@ class LinksAwakeningContext(CommonContext):
checkMetadataTable[check.id])] for check in ladxr_checks]
self.new_checks(checks, [check.id for check in ladxr_checks])
for check in ladxr_checks:
if check.value and check.linkedItem:
linkedItem = check.linkedItem
if 'condition' not in linkedItem or linkedItem['condition'](self.slot_data):
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
async def victory():
await self.send_victory()
@@ -618,12 +705,20 @@ class LinksAwakeningContext(CommonContext):
if not self.client.recvd_checks:
await self.sync()
await self.client.wait_and_init_tracker()
await self.client.wait_and_init_tracker(self.magpie)
min_tick_duration = 0.1
last_tick = time.time()
while True:
await self.client.main_tick(on_item_get, victory, deathlink)
await asyncio.sleep(0.1)
now = time.time()
tick_duration = now - last_tick
sleep_duration = max(min_tick_duration - tick_duration, 0)
await asyncio.sleep(sleep_duration)
last_tick = now
if self.last_resend + 5.0 < now:
self.last_resend = now
await self.send_checks()
@@ -631,8 +726,15 @@ class LinksAwakeningContext(CommonContext):
try:
self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker)
self.magpie.slot_data = self.slot_data
if self.client.gps_tracker.needs_found_entrances:
await self.request_found_entrances()
self.client.gps_tracker.needs_found_entrances = False
new_entrances = await self.magpie.send_gps(self.client.gps_tracker)
if new_entrances:
await self.send_new_entrances(new_entrances)
except Exception:
# Don't let magpie errors take out the client
pass

View File

@@ -33,10 +33,15 @@ WINDOW_MIN_HEIGHT = 525
WINDOW_MIN_WIDTH = 425
class AdjusterWorld(object):
class AdjusterSubWorld(object):
def __init__(self, random):
self.random = random
def __init__(self, sprite_pool):
import random
self.sprite_pool = {1: sprite_pool}
self.per_slot_randoms = {1: random}
self.worlds = {1: self.AdjusterSubWorld(random)}
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):

93
Main.py
View File

@@ -148,50 +148,44 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
else:
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
AutoWorld.call_all(multiworld, "connect_entrances")
AutoWorld.call_all(multiworld, "generate_basic")
# remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible.
if any(getattr(multiworld.worlds[player].options, "start_inventory_from_pool", None) for player in multiworld.player_ids):
new_items: List[Item] = []
old_items: List[Item] = []
depletion_pool: Dict[int, Dict[str, int]] = {
player: getattr(multiworld.worlds[player].options,
"start_inventory_from_pool",
StartInventoryPool({})).value.copy()
for player in multiworld.player_ids
}
for player, items in depletion_pool.items():
player_world: AutoWorld.World = multiworld.worlds[player]
for count in items.values():
for _ in range(count):
new_items.append(player_world.create_filler())
target: int = sum(sum(items.values()) for items in depletion_pool.values())
for i, item in enumerate(multiworld.itempool):
if depletion_pool[item.player].get(item.name, 0):
target -= 1
depletion_pool[item.player][item.name] -= 1
# quick abort if we have found all items
if not target:
old_items.extend(multiworld.itempool[i+1:])
break
else:
old_items.append(item)
fallback_inventory = StartInventoryPool({})
depletion_pool: Dict[int, Dict[str, int]] = {
player: getattr(multiworld.worlds[player].options, "start_inventory_from_pool", fallback_inventory).value.copy()
for player in multiworld.player_ids
}
target_per_player = {
player: sum(target_items.values()) for player, target_items in depletion_pool.items() if target_items
}
# leftovers?
if target:
for player, remaining_items in depletion_pool.items():
remaining_items = {name: count for name, count in remaining_items.items() if count}
if remaining_items:
logger.warning(f"{multiworld.get_player_name(player)}"
f" is trying to remove items from their pool that don't exist: {remaining_items}")
# find all filler we generated for the current player and remove until it matches
removables = [item for item in new_items if item.player == player]
for _ in range(sum(remaining_items.values())):
new_items.remove(removables.pop())
assert len(multiworld.itempool) == len(new_items + old_items), "Item Pool amounts should not change."
multiworld.itempool[:] = new_items + old_items
if target_per_player:
new_itempool: List[Item] = []
# Make new itempool with start_inventory_from_pool items removed
for item in multiworld.itempool:
if depletion_pool[item.player].get(item.name, 0):
depletion_pool[item.player][item.name] -= 1
else:
new_itempool.append(item)
# Create filler in place of the removed items, warn if any items couldn't be found in the multiworld itempool
for player, target in target_per_player.items():
unfound_items = {item: count for item, count in depletion_pool[player].items() if count}
if unfound_items:
player_name = multiworld.get_player_name(player)
logger.warning(f"{player_name} tried to remove items from their pool that don't exist: {unfound_items}")
needed_items = target_per_player[player] - sum(unfound_items.values())
new_itempool += [multiworld.worlds[player].create_filler() for _ in range(needed_items)]
assert len(multiworld.itempool) == len(new_itempool), "Item Pool amounts should not change."
multiworld.itempool[:] = new_itempool
multiworld.link_items()
@@ -249,6 +243,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
def write_multidata():
import NetUtils
from NetUtils import HintStatus
slot_data = {}
client_versions = {}
games = {}
@@ -273,10 +268,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for slot in multiworld.player_ids:
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
def precollect_hint(location):
def precollect_hint(location: Location, auto_status: HintStatus):
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False, entrance, location.item.flags)
location.item.code, False, entrance, location.item.flags, auto_status)
precollected_hints[location.player].add(hint)
if location.item.player not in multiworld.groups:
precollected_hints[location.item.player].add(hint)
@@ -289,19 +284,22 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \
"location.address should then also be None. Location: " \
f" {location}"
f" {location}, Item: {location.item}"
assert location.address not in locations_data[location.player], (
f"Locations with duplicate address. {location} and "
f"{locations_data[location.player][location.address]}")
locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags
auto_status = HintStatus.HINT_AVOID if location.item.trap else HintStatus.HINT_PRIORITY
if location.name in multiworld.worlds[location.player].options.start_location_hints:
precollect_hint(location)
if not location.item.trap: # Unspecified status for location hints, except traps
auto_status = HintStatus.HINT_UNSPECIFIED
precollect_hint(location, auto_status)
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
precollect_hint(location)
precollect_hint(location, auto_status)
elif any([location.item.name in multiworld.worlds[player].options.start_hints
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location)
precollect_hint(location, auto_status)
# embedded data package
data_package = {
@@ -313,11 +311,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# get spheres -> filter address==None -> skip empty
spheres: List[Dict[int, Set[int]]] = []
for sphere in multiworld.get_spheres():
for sphere in multiworld.get_sendable_spheres():
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
for sphere_location in sphere:
if type(sphere_location.address) is int:
current_sphere[sphere_location.player].add(sphere_location.address)
current_sphere[sphere_location.player].add(sphere_location.address)
if current_sphere:
spheres.append(dict(current_sphere))

View File

@@ -5,8 +5,15 @@ import multiprocessing
import warnings
if sys.version_info < (3, 8, 6):
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
if sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 11):
# Official micro version updates. This should match the number in docs/running from source.md.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. Official 3.10.15+ is supported.")
elif sys.platform in ("win32", "darwin") and sys.version_info < (3, 10, 15):
# There are known security issues, but no easy way to install fixed versions on Windows for testing.
warnings.warn(f"Python Version {sys.version_info} has security issues. Don't use in production.")
elif sys.version_info < (3, 10, 1):
# Other platforms may get security backports instead of micro updates, so the number is unreliable.
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.1+ is supported.")
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
_skip_update = bool(getattr(sys, "frozen", False) or multiprocessing.parent_process())

View File

@@ -28,9 +28,11 @@ ModuleUpdate.update()
if typing.TYPE_CHECKING:
import ssl
from NetUtils import ServerConnection
import websockets
import colorama
import websockets
from websockets.extensions.permessage_deflate import PerMessageDeflate
try:
# ponyorm is a requirement for webhost, not default server, so may not be importable
from pony.orm.dbapiprovider import OperationalError
@@ -41,7 +43,8 @@ import NetUtils
import Utils
from Utils import version_tuple, restricted_loads, Version, async_start, get_intended_text
from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \
SlotType, LocationStore
SlotType, LocationStore, Hint, HintStatus
from BaseClasses import ItemClassification
min_client_version = Version(0, 1, 6)
colorama.init()
@@ -118,13 +121,14 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int:
class Client(Endpoint):
version = Version(0, 0, 0)
tags: typing.List[str] = []
tags: typing.List[str]
remote_items: bool
remote_start_inventory: bool
no_items: bool
no_locations: bool
no_text: bool
def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context):
def __init__(self, socket: "ServerConnection", ctx: Context) -> None:
super().__init__(socket)
self.auth = False
self.team = None
@@ -174,6 +178,7 @@ class Context:
"compatibility": int}
# team -> slot id -> list of clients authenticated to slot.
clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]]
endpoints: list[Client]
locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]]
hints_used: typing.Dict[typing.Tuple[int, int], int]
@@ -228,7 +233,7 @@ class Context:
self.hint_cost = hint_cost
self.location_check_points = location_check_points
self.hints_used = collections.defaultdict(int)
self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
self.hints: typing.Dict[team_slot, typing.Set[Hint]] = collections.defaultdict(set)
self.release_mode: str = release_mode
self.remaining_mode: str = remaining_mode
self.collect_mode: str = collect_mode
@@ -363,18 +368,28 @@ class Context:
return True
def broadcast_all(self, msgs: typing.List[dict]):
msgs = self.dumper(msgs)
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
data = self.dumper(msgs)
endpoints = (
endpoint
for endpoint in self.endpoints
if endpoint.auth and not (msg_is_text and endpoint.no_text)
)
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
self.logger.info("Notice (all): %s" % text)
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
def broadcast_team(self, team: int, msgs: typing.List[dict]):
msgs = self.dumper(msgs)
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs)
data = self.dumper(msgs)
endpoints = (
endpoint
for endpoint in itertools.chain.from_iterable(self.clients[team].values())
if not (msg_is_text and endpoint.no_text)
)
async_start(self.broadcast_send_encoded_msgs(endpoints, data))
def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]):
msgs = self.dumper(msgs)
@@ -388,13 +403,13 @@ class Context:
await on_client_disconnected(self, endpoint)
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
if not client.auth:
if not client.auth or client.no_text:
return
self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
if not client.auth:
if not client.auth or client.no_text:
return
async_start(self.send_msgs(client,
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
@@ -443,7 +458,7 @@ class Context:
self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
self.groups = {slot: set(slot_info.group_members) for slot, slot_info in self.slot_info.items()
if slot_info.type == SlotType.group}
self.clients = {0: {}}
@@ -656,13 +671,29 @@ class Context:
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None):
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None,
changed: typing.Optional[typing.Set[team_slot]] = None) -> None:
"""Refreshes the hints for the specified team/slot. Providing 'None' for either team or slot
will refresh all teams or all slots respectively. If a set is passed for 'changed', each (team,slot)
pair that has at least one hint modified will be added to the set.
"""
for hint_team, hint_slot in self.hints:
if (team is None or team == hint_team) and (slot is None or slot == hint_slot):
self.hints[hint_team, hint_slot] = {
hint.re_check(self, hint_team) for hint in
self.hints[hint_team, hint_slot]
}
if team != hint_team and team is not None:
continue # Check specified team only, all if team is None
if slot != hint_slot and slot is not None:
continue # Check specified slot only, all if slot is None
new_hints: typing.Set[Hint] = set()
for hint in self.hints[hint_team, hint_slot]:
new_hint = hint.re_check(self, hint_team)
new_hints.add(new_hint)
if hint == new_hint:
continue
for player in self.slot_set(hint.receiving_player) | {hint.finding_player}:
if changed is not None:
changed.add((hint_team,player))
if slot is not None and slot != player:
self.replace_hint(hint_team, player, hint, new_hint)
self.hints[hint_team, hint_slot] = new_hints
def get_rechecked_hints(self, team: int, slot: int):
self.recheck_hints(team, slot)
@@ -711,7 +742,7 @@ class Context:
else:
return self.player_names[team, slot]
def notify_hints(self, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False,
def notify_hints(self, team: int, hints: typing.List[Hint], only_new: bool = False,
recipients: typing.Sequence[int] = None):
"""Send and remember hints."""
if only_new:
@@ -726,29 +757,41 @@ class Context:
concerns[player].append(data)
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
# remember hints in all cases
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in self.hints[team, hint.finding_player]:
self.hints[team, hint.finding_player].add(hint)
new_hint_events.add(hint.finding_player)
for player in self.slot_set(hint.receiving_player):
self.hints[team, player].add(hint)
new_hint_events.add(player)
# only remember hints that were not already found at the time of creation
if not hint.found:
# since hints are bidirectional, finding player and receiving player,
# we can check once if hint already exists
if hint not in self.hints[team, hint.finding_player]:
self.hints[team, hint.finding_player].add(hint)
new_hint_events.add(hint.finding_player)
for player in self.slot_set(hint.receiving_player):
self.hints[team, player].add(hint)
new_hint_events.add(player)
self.logger.info("Notice (Team #%d): %s" % (team + 1, format_hint(self, team, hint)))
for slot in new_hint_events:
self.on_new_hint(team, slot)
for slot, hint_data in concerns.items():
if recipients is None or slot in recipients:
clients = self.clients[team].get(slot)
clients = filter(lambda c: not c.no_text, self.clients[team].get(slot, []))
if not clients:
continue
client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)]
for client in clients:
async_start(self.send_msgs(client, client_hints))
def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]:
for hint in self.hints[team, finding_player]:
if hint.location == seeked_location and hint.finding_player == finding_player:
return hint
return None
def replace_hint(self, team: int, slot: int, old_hint: Hint, new_hint: Hint) -> None:
if old_hint in self.hints[team, slot]:
self.hints[team, slot].remove(old_hint)
self.hints[team, slot].add(new_hint)
# "events"
def on_goal_achieved(self, client: Client):
@@ -790,7 +833,7 @@ def update_aliases(ctx: Context, team: int):
async_start(ctx.send_encoded_msgs(client, cmd))
async def server(websocket, path: str = "/", ctx: Context = None):
async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None:
client = Client(websocket, ctx)
ctx.endpoints.append(client)
@@ -881,6 +924,10 @@ async def on_client_joined(ctx: Context, client: Client):
"If your client supports it, "
"you may have additional local commands you can list with /help.",
{"type": "Tutorial"})
if not any(isinstance(extension, PerMessageDeflate) for extension in client.socket.extensions):
ctx.notify_client(client, "Warning: your client does not support compressed websocket connections! "
"It may stop working in the future. If you are a player, please report this to the "
"client's developer.")
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
@@ -947,9 +994,13 @@ def get_status_string(ctx: Context, team: int, tag: str):
tagged = len([client for client in ctx.clients[team][slot] if tag in client.tags])
completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})"
tag_text = f" {tagged} of which are tagged {tag}" if connected and tag else ""
goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "."
status_text = (
" and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else
" and is ready." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_READY else
"."
)
text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \
f"{tag_text}{goal_text} {completion_text}"
f"{tag_text}{status_text} {completion_text}"
return text
@@ -1027,21 +1078,37 @@ def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem
def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int],
count_activity: bool = True):
slot_locations = ctx.locations[slot]
new_locations = set(locations) - ctx.location_checks[team, slot]
new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata
new_locations.intersection_update(slot_locations) # ignore location IDs unknown to this multidata
if new_locations:
if count_activity:
ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc)
sortable: list[tuple[int, int, int, int]] = []
for location in new_locations:
item_id, target_player, flags = ctx.locations[slot][location]
# extract all fields to avoid runtime overhead in LocationStore
item_id, target_player, flags = slot_locations[location]
# sort/group by receiver and item
sortable.append((target_player, item_id, location, flags))
info_texts: list[dict[str, typing.Any]] = []
for target_player, item_id, location, flags in sorted(sortable):
new_item = NetworkItem(item_id, location, slot, flags)
send_items_to(ctx, team, target_player, new_item)
ctx.logger.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], ctx.item_names[ctx.slot_info[target_player].game][item_id],
ctx.player_names[(team, target_player)], ctx.location_names[ctx.slot_info[slot].game][location]))
info_text = json_format_send_event(new_item, target_player)
ctx.broadcast_team(team, [info_text])
if len(info_texts) >= 140:
# split into chunks that are close to compression window of 64K but not too big on the wire
# (roughly 1300-2600 bytes after compression depending on repetitiveness)
ctx.broadcast_team(team, info_texts)
info_texts.clear()
info_texts.append(json_format_send_event(new_item, target_player))
ctx.broadcast_team(team, info_texts)
del info_texts
del sortable
ctx.location_checks[team, slot] |= new_locations
send_new_items(ctx)
@@ -1050,14 +1117,15 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi
"hint_points": get_slot_points(ctx, team, slot),
"checked_locations": new_locations, # send back new checks only
}])
old_hints = ctx.hints[team, slot].copy()
ctx.recheck_hints(team, slot)
if old_hints != ctx.hints[team, slot]:
ctx.on_changed_hints(team, slot)
updated_slots: typing.Set[tuple[int, int]] = set()
ctx.recheck_hints(team, slot, updated_slots)
for hint_team, hint_slot in updated_slots:
ctx.on_changed_hints(hint_team, hint_slot)
ctx.save()
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]:
def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str], auto_status: HintStatus) \
-> typing.List[Hint]:
hints = []
slots: typing.Set[int] = {slot}
for group_id, group in ctx.groups.items():
@@ -1067,31 +1135,58 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st
seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item]
for finding_player, location_id, item_id, receiving_player, item_flags \
in ctx.locations.find_item(slots, seeked_item_id):
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
item_flags))
prev_hint = ctx.get_hint(team, finding_player, location_id)
if prev_hint:
hints.append(prev_hint)
else:
found = location_id in ctx.location_checks[team, finding_player]
entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "")
new_status = auto_status
if found:
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
hints.append(Hint(receiving_player, finding_player, location_id, item_id, found, entrance,
item_flags, new_status))
return hints
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str) -> typing.List[NetUtils.Hint]:
def collect_hint_location_name(ctx: Context, team: int, slot: int, location: str, auto_status: HintStatus) \
-> typing.List[Hint]:
seeked_location: int = ctx.location_names_for_game(ctx.games[slot])[location]
return collect_hint_location_id(ctx, team, slot, seeked_location)
return collect_hint_location_id(ctx, team, slot, seeked_location, auto_status)
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int) -> typing.List[NetUtils.Hint]:
def collect_hint_location_id(ctx: Context, team: int, slot: int, seeked_location: int, auto_status: HintStatus) \
-> typing.List[Hint]:
prev_hint = ctx.get_hint(team, slot, seeked_location)
if prev_hint:
return [prev_hint]
result = ctx.locations[slot].get(seeked_location, (None, None, None))
if any(result):
item_id, receiving_player, item_flags = result
found = seeked_location in ctx.location_checks[team, slot]
entrance = ctx.er_hint_data.get(slot, {}).get(seeked_location, "")
return [NetUtils.Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags)]
new_status = auto_status
if found:
new_status = HintStatus.HINT_FOUND
elif item_flags & ItemClassification.trap:
new_status = HintStatus.HINT_AVOID
return [Hint(receiving_player, slot, seeked_location, item_id, found, entrance, item_flags,
new_status)]
return []
def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
status_names: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "(found)",
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
HintStatus.HINT_NO_PRIORITY: "(no priority)",
HintStatus.HINT_AVOID: "(avoid)",
HintStatus.HINT_PRIORITY: "(priority)",
}
def format_hint(ctx: Context, team: int, hint: Hint) -> str:
text = f"[Hint]: {ctx.player_names[team, hint.receiving_player]}'s " \
f"{ctx.item_names[ctx.slot_info[hint.receiving_player].game][hint.item]} is " \
f"at {ctx.location_names[ctx.slot_info[hint.finding_player].game][hint.location]} " \
@@ -1099,7 +1194,8 @@ def format_hint(ctx: Context, team: int, hint: NetUtils.Hint) -> str:
if hint.entrance:
text += f" at {hint.entrance}"
return text + (". (found)" if hint.found else ".")
return text + ". " + status_names.get(hint.status, "(unknown)")
def json_format_send_event(net_item: NetworkItem, receiving_player: int):
@@ -1503,7 +1599,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
def get_hints(self, input_text: str, for_location: bool = False) -> bool:
points_available = get_client_points(self.ctx, self.client)
cost = self.ctx.get_hint_cost(self.client.slot)
auto_status = HintStatus.HINT_UNSPECIFIED if for_location else HintStatus.HINT_PRIORITY
if not input_text:
hints = {hint.re_check(self.ctx, self.client.team) for hint in
self.ctx.hints[self.client.team, self.client.slot]}
@@ -1529,9 +1625,9 @@ class ClientMessageProcessor(CommonCommandProcessor):
self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.")
hints = []
elif not for_location:
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id)
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
else:
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id)
hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id, auto_status)
else:
game = self.ctx.games[self.client.slot]
@@ -1551,16 +1647,16 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints = []
for item_name in self.ctx.item_name_groups[game][hint_name]:
if item_name in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name, auto_status))
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
elif hint_name in self.ctx.location_name_groups[game]: # location group name
hints = []
for loc_name in self.ctx.location_name_groups[game][hint_name]:
if loc_name in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name))
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name, auto_status))
else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name, auto_status)
else:
self.output(response)
@@ -1725,7 +1821,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.clients[team][slot].append(client)
client.version = args['version']
client.tags = args['tags']
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
# set NoText for old PopTracker clients that predate the tag to save traffic
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
connected_packet = {
"cmd": "Connected",
"team": client.team, "slot": client.slot,
@@ -1798,6 +1896,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
client.tags = args["tags"]
if set(old_tags) != set(client.tags):
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
client.no_text = "NoText" in client.tags or (
"PopTracker" in client.tags and client.version < (0, 5, 1)
)
ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
f"from {old_tags} to {client.tags}.",
@@ -1826,19 +1927,63 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
for location in args["locations"]:
if type(location) is not int:
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'LocationScouts',
[{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'Locations has to be a list of integers',
"original_cmd": cmd}])
return
target_item, target_player, flags = ctx.locations[client.slot][location]
if create_as_hint:
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location,
HintStatus.HINT_UNSPECIFIED))
locs.append(NetworkItem(target_item, location, target_player, flags))
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
if locs and create_as_hint:
ctx.save()
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'UpdateHint':
location = args["location"]
player = args["player"]
status = args["status"]
if not isinstance(player, int) or not isinstance(location, int) \
or (status is not None and not isinstance(status, int)):
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint',
"original_cmd": cmd}])
return
hint = ctx.get_hint(client.team, player, location)
if not hint:
return # Ignored safely
if client.slot not in ctx.slot_set(hint.receiving_player):
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint: No Permission',
"original_cmd": cmd}])
return
new_hint = hint
if status is None:
return
try:
status = HintStatus(status)
except ValueError:
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'UpdateHint: Invalid Status', "original_cmd": cmd}])
return
if status == HintStatus.HINT_FOUND:
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'UpdateHint: Cannot manually update status to "HINT_FOUND"', "original_cmd": cmd}])
return
new_hint = new_hint.re_prioritize(ctx, status)
if hint == new_hint:
return
ctx.replace_hint(client.team, hint.finding_player, hint, new_hint)
ctx.replace_hint(client.team, hint.receiving_player, hint, new_hint)
ctx.save()
ctx.on_changed_hints(client.team, hint.finding_player)
ctx.on_changed_hints(client.team, hint.receiving_player)
elif cmd == 'StatusUpdate':
update_client_status(ctx, client, args["status"])
@@ -1886,6 +2031,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
args["cmd"] = "SetReply"
value = ctx.stored_data.get(args["key"], args.get("default", 0))
args["original_value"] = copy.copy(value)
args["slot"] = client.slot
for operation in args["operations"]:
func = modify_functions[operation["operation"]]
value = func(value, operation["value"])
@@ -2143,9 +2289,9 @@ class ServerCommandProcessor(CommonCommandProcessor):
hints = []
for item_name_from_group in self.ctx.item_name_groups[game][item]:
if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group))
hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group, HintStatus.HINT_PRIORITY))
else: # item name or id
hints = collect_hints(self.ctx, team, slot, item)
hints = collect_hints(self.ctx, team, slot, item, HintStatus.HINT_PRIORITY)
if hints:
self.ctx.notify_hints(team, hints)
@@ -2179,14 +2325,17 @@ class ServerCommandProcessor(CommonCommandProcessor):
if usable:
if isinstance(location, int):
hints = collect_hint_location_id(self.ctx, team, slot, location)
hints = collect_hint_location_id(self.ctx, team, slot, location,
HintStatus.HINT_UNSPECIFIED)
elif game in self.ctx.location_name_groups and location in self.ctx.location_name_groups[game]:
hints = []
for loc_name_from_group in self.ctx.location_name_groups[game][location]:
if loc_name_from_group in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group))
hints.extend(collect_hint_location_name(self.ctx, team, slot, loc_name_from_group,
HintStatus.HINT_UNSPECIFIED))
else:
hints = collect_hint_location_name(self.ctx, team, slot, location)
hints = collect_hint_location_name(self.ctx, team, slot, location,
HintStatus.HINT_UNSPECIFIED)
if hints:
self.ctx.notify_hints(team, hints)
else:
@@ -2276,6 +2425,8 @@ def parse_args() -> argparse.Namespace:
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
parser.add_argument('--loglevel', default=defaults["loglevel"],
choices=['debug', 'info', 'warning', 'error', 'critical'])
parser.add_argument('--logtime', help="Add timestamps to STDOUT",
default=defaults["logtime"], action='store_true')
parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int)
parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int)
parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
@@ -2356,7 +2507,9 @@ def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLConte
async def main(args: argparse.Namespace):
Utils.init_logging("Server", loglevel=args.loglevel.lower())
Utils.init_logging(name="Server",
loglevel=args.loglevel.lower(),
add_timestamp=args.logtime)
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,

View File

@@ -5,11 +5,20 @@ import enum
import warnings
from json import JSONEncoder, JSONDecoder
import websockets
if typing.TYPE_CHECKING:
from websockets import WebSocketServerProtocol as ServerConnection
from Utils import ByValue, Version
class HintStatus(ByValue, enum.IntEnum):
HINT_UNSPECIFIED = 0
HINT_NO_PRIORITY = 10
HINT_AVOID = 20
HINT_PRIORITY = 30
HINT_FOUND = 40
class JSONMessagePart(typing.TypedDict, total=False):
text: str
# optional
@@ -19,6 +28,8 @@ class JSONMessagePart(typing.TypedDict, total=False):
player: int
# if type == item indicates item flags
flags: int
# if type == hint_status
hint_status: HintStatus
class ClientStatus(ByValue, enum.IntEnum):
@@ -141,7 +152,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode
class Endpoint:
socket: websockets.WebSocketServerProtocol
socket: "ServerConnection"
def __init__(self, socket):
self.socket = socket
@@ -184,6 +195,7 @@ class JSONTypes(str, enum.Enum):
location_name = "location_name"
location_id = "location_id"
entrance_name = "entrance_name"
hint_status = "hint_status"
class JSONtoTextParser(metaclass=HandlerMeta):
@@ -224,7 +236,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
def _handle_player_id(self, node: JSONMessagePart):
player = int(node["text"])
node["color"] = 'magenta' if player == self.ctx.slot else 'yellow'
node["color"] = 'magenta' if self.ctx.slot_concerns_self(player) else 'yellow'
node["text"] = self.ctx.player_names[player]
return self._handle_color(node)
@@ -265,6 +277,10 @@ class JSONtoTextParser(metaclass=HandlerMeta):
node["color"] = 'blue'
return self._handle_color(node)
def _handle_hint_status(self, node: JSONMessagePart):
node["color"] = status_colors.get(node["hint_status"], "red")
return self._handle_color(node)
class RawJSONtoTextParser(JSONtoTextParser):
def _handle_color(self, node: JSONMessagePart):
@@ -297,6 +313,27 @@ def add_json_location(parts: list, location_id: int, player: int = 0, **kwargs)
parts.append({"text": str(location_id), "player": player, "type": JSONTypes.location_id, **kwargs})
status_names: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "(found)",
HintStatus.HINT_UNSPECIFIED: "(unspecified)",
HintStatus.HINT_NO_PRIORITY: "(no priority)",
HintStatus.HINT_AVOID: "(avoid)",
HintStatus.HINT_PRIORITY: "(priority)",
}
status_colors: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "green",
HintStatus.HINT_UNSPECIFIED: "white",
HintStatus.HINT_NO_PRIORITY: "slateblue",
HintStatus.HINT_AVOID: "salmon",
HintStatus.HINT_PRIORITY: "plum",
}
def add_json_hint_status(parts: list, hint_status: HintStatus, text: typing.Optional[str] = None, **kwargs):
parts.append({"text": text if text != None else status_names.get(hint_status, "(unknown)"),
"hint_status": hint_status, "type": JSONTypes.hint_status, **kwargs})
class Hint(typing.NamedTuple):
receiving_player: int
finding_player: int
@@ -305,14 +342,21 @@ class Hint(typing.NamedTuple):
found: bool
entrance: str = ""
item_flags: int = 0
status: HintStatus = HintStatus.HINT_UNSPECIFIED
def re_check(self, ctx, team) -> Hint:
if self.found:
if self.found and self.status == HintStatus.HINT_FOUND:
return self
found = self.location in ctx.location_checks[team, self.finding_player]
if found:
return Hint(self.receiving_player, self.finding_player, self.location, self.item, found, self.entrance,
self.item_flags)
return self._replace(found=found, status=HintStatus.HINT_FOUND)
return self
def re_prioritize(self, ctx, status: HintStatus) -> Hint:
if self.found and status != HintStatus.HINT_FOUND:
status = HintStatus.HINT_FOUND
if status != self.status:
return self._replace(status=status)
return self
def __hash__(self):
@@ -334,10 +378,7 @@ class Hint(typing.NamedTuple):
else:
add_json_text(parts, "'s World")
add_json_text(parts, ". ")
if self.found:
add_json_text(parts, "(found)", type="color", color="green")
else:
add_json_text(parts, "(not found)", type="color", color="red")
add_json_hint_status(parts, self.status)
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
"receiving": self.receiving_player,
@@ -383,6 +424,8 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
checked = state[team, slot]
if not checked:
# This optimizes the case where everyone connects to a fresh game at the same time.
if slot not in self:
raise KeyError(slot)
return []
return [location_id for
location_id in self[slot] if

View File

@@ -1,7 +1,6 @@
import tkinter as tk
import argparse
import logging
import random
import os
import zipfile
from itertools import chain
@@ -197,7 +196,6 @@ def set_icon(window):
def adjust(args):
# Create a fake multiworld and OOTWorld to use as a base
multiworld = MultiWorld(1)
multiworld.per_slot_randoms = {1: random}
ootworld = OOTWorld(multiworld, 1)
# Set options in the fake OOTWorld
for name, option in chain(cosmetic_options.items(), sfx_options.items()):

View File

@@ -137,7 +137,7 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
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
If this is None, it inherits the value of `WebWorld.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.
@@ -496,7 +496,7 @@ class TextChoice(Choice):
def __init__(self, value: typing.Union[str, int]):
assert isinstance(value, str) or isinstance(value, int), \
f"{value} is not a valid option for {self.__class__.__name__}"
f"'{value}' is not a valid option for '{self.__class__.__name__}'"
self.value = value
@property
@@ -617,17 +617,17 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
used_locations.append(location)
used_bosses.append(boss)
if not cls.valid_boss_name(boss):
raise ValueError(f"{boss.title()} is not a valid boss name.")
raise ValueError(f"'{boss.title()}' is not a valid boss name.")
if not cls.valid_location_name(location):
raise ValueError(f"{location.title()} is not a valid boss location name.")
raise ValueError(f"'{location.title()}' is not a valid boss location name.")
if not cls.can_place_boss(boss, location):
raise ValueError(f"{location.title()} is not a valid location for {boss.title()} to be placed.")
raise ValueError(f"'{location.title()}' is not a valid location for {boss.title()} to be placed.")
else:
if cls.duplicate_bosses:
if not cls.valid_boss_name(option):
raise ValueError(f"{option} is not a valid boss name.")
raise ValueError(f"'{option}' is not a valid boss name.")
else:
raise ValueError(f"{option.title()} is not formatted correctly.")
raise ValueError(f"'{option.title()}' is not formatted correctly.")
@classmethod
def can_place_boss(cls, boss: str, location: str) -> bool:
@@ -689,9 +689,9 @@ class Range(NumericOption):
@classmethod
def weighted_range(cls, text) -> Range:
if text == "random-low":
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_start))
return cls(cls.triangular(cls.range_start, cls.range_end, 0.0))
elif text == "random-high":
return cls(cls.triangular(cls.range_start, cls.range_end, cls.range_end))
return cls(cls.triangular(cls.range_start, cls.range_end, 1.0))
elif text == "random-middle":
return cls(cls.triangular(cls.range_start, cls.range_end))
elif text.startswith("random-range-"):
@@ -717,11 +717,11 @@ class Range(NumericOption):
f"{random_range[0]}-{random_range[1]} is outside allowed range "
f"{cls.range_start}-{cls.range_end} for option {cls.__name__}")
if text.startswith("random-range-low"):
return cls(cls.triangular(random_range[0], random_range[1], random_range[0]))
return cls(cls.triangular(random_range[0], random_range[1], 0.0))
elif text.startswith("random-range-middle"):
return cls(cls.triangular(random_range[0], random_range[1]))
elif text.startswith("random-range-high"):
return cls(cls.triangular(random_range[0], random_range[1], random_range[1]))
return cls(cls.triangular(random_range[0], random_range[1], 1.0))
else:
return cls(random.randint(random_range[0], random_range[1]))
@@ -739,8 +739,16 @@ class Range(NumericOption):
return str(self.value)
@staticmethod
def triangular(lower: int, end: int, tri: typing.Optional[int] = None) -> int:
return int(round(random.triangular(lower, end, tri), 0))
def triangular(lower: int, end: int, tri: float = 0.5) -> int:
"""
Integer triangular distribution for `lower` inclusive to `end` inclusive.
Expects `lower <= end` and `0.0 <= tri <= 1.0`. The result of other inputs is undefined.
"""
# Use the continuous range [lower, end + 1) to produce an integer result in [lower, end].
# random.triangular is actually [a, b] and not [a, b), so there is a very small chance of getting exactly b even
# when a != b, so ensure the result is never more than `end`.
return min(end, math.floor(random.triangular(0.0, 1.0, tri) * (end - lower + 1) + lower))
class NamedRange(Range):
@@ -754,7 +762,7 @@ class NamedRange(Range):
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__} " +
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():
@@ -817,18 +825,21 @@ class VerifyKeys(metaclass=FreezeValidKeys):
for item_name in self.value:
if item_name not in world.item_names:
picks = get_fuzzy_results(item_name, world.item_names, limit=1)
raise Exception(f"Item {item_name} from option {self} "
f"is not a valid item name from {world.game}. "
raise Exception(f"Item '{item_name}' from option '{self}' "
f"is not a valid item name from '{world.game}'. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
elif self.verify_location_name:
for location_name in self.value:
if location_name not in world.location_names:
picks = get_fuzzy_results(location_name, world.location_names, limit=1)
raise Exception(f"Location {location_name} from option {self} "
f"is not a valid location name from {world.game}. "
raise Exception(f"Location '{location_name}' from option '{self}' "
f"is not a valid location name from '{world.game}'. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)")
def __iter__(self) -> typing.Iterator[typing.Any]:
return self.value.__iter__()
class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]):
default = {}
supports_weighting = False
@@ -860,6 +871,8 @@ class ItemDict(OptionDict):
verify_item_name = True
def __init__(self, value: typing.Dict[str, int]):
if any(item_count is None for item_count in value.values()):
raise Exception("Items must have counts associated with them. Please provide positive integer values in the format \"item\": count .")
if any(item_count < 1 for item_count in value.values()):
raise Exception("Cannot have non-positive item counts.")
super(ItemDict, self).__init__(value)
@@ -1106,11 +1119,11 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
used_entrances.append(entrance)
used_exits.append(exit)
if not cls.validate_entrance_name(entrance):
raise ValueError(f"{entrance.title()} is not a valid entrance.")
raise ValueError(f"'{entrance.title()}' is not a valid entrance.")
if not cls.validate_exit_name(exit):
raise ValueError(f"{exit.title()} is not a valid exit.")
raise ValueError(f"'{exit.title()}' is not a valid exit.")
if not cls.can_connect(entrance, exit):
raise ValueError(f"Connection between {entrance.title()} and {exit.title()} is invalid.")
raise ValueError(f"Connection between '{entrance.title()}' and '{exit.title()}' is invalid.")
@classmethod
def from_any(cls, data: PlandoConFromAnyType) -> Self:
@@ -1175,7 +1188,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
class Accessibility(Choice):
"""
Set rules for reachability of your items/locations.
**Full:** ensure everything can be reached and acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired.
@@ -1193,7 +1206,7 @@ class Accessibility(Choice):
class ItemsAccessibility(Accessibility):
"""
Set rules for reachability of your items/locations.
**Full:** ensure everything can be reached and acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired.
@@ -1244,12 +1257,16 @@ class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing
accessibility: Accessibility
def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
def as_dict(self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
"""
Returns a dictionary of [str, Option.value]
:param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
:param toggles_as_bools: whether toggle options should be output as bools instead of strings
"""
assert option_names, "options.as_dict() was used without any option names."
option_results = {}
@@ -1271,6 +1288,8 @@ class CommonOptions(metaclass=OptionsMetaProperty):
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
else:
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
@@ -1368,8 +1387,8 @@ class ItemLinks(OptionList):
picks_group = get_fuzzy_results(item_name, world.item_name_groups.keys(), limit=1)
picks_group = f" or '{picks_group[0][0]}' ({picks_group[0][1]}% sure)" if allow_item_groups else ""
raise Exception(f"Item {item_name} from item link {item_link} "
f"is not a valid item from {world.game} for {pool_name}. "
raise Exception(f"Item '{item_name}' from item link '{item_link}' "
f"is not a valid item from '{world.game}' for '{pool_name}'. "
f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure){picks_group}")
if allow_item_groups:
pool |= world.item_name_groups.get(item_name, {item_name})
@@ -1460,22 +1479,26 @@ it.
def get_option_groups(world: typing.Type[World], visibility_level: Visibility = Visibility.template) -> typing.Dict[
str, typing.Dict[str, typing.Type[Option[typing.Any]]]]:
"""Generates and returns a dictionary for the option groups of a specified world."""
option_groups = {option: option_group.name
for option_group in world.web.option_groups
for option in option_group.options}
option_to_name = {option: option_name for option_name, option in world.options_dataclass.type_hints.items()}
ordered_groups = {group.name: group.options for group in world.web.option_groups}
# add a default option group for uncategorized options to get thrown into
ordered_groups = ["Game Options"]
[ordered_groups.append(group) for group in option_groups.values() if group not in ordered_groups]
grouped_options = {group: {} for group in ordered_groups}
for option_name, option in world.options_dataclass.type_hints.items():
if visibility_level & option.visibility:
grouped_options[option_groups.get(option, "Game Options")][option_name] = option
if "Game Options" not in ordered_groups:
grouped_options = set(option for group in ordered_groups.values() for option in group)
ungrouped_options = [option for option in option_to_name if option not in grouped_options]
# only add the game options group if we have ungrouped options
if ungrouped_options:
ordered_groups = {**{"Game Options": ungrouped_options}, **ordered_groups}
# if the world doesn't have any ungrouped options, this group will be empty so just remove it
if not grouped_options["Game Options"]:
del grouped_options["Game Options"]
return grouped_options
return {
group: {
option_to_name[option]: option
for option in group_options
if (visibility_level in option.visibility and option in option_to_name)
}
for group, group_options in ordered_groups.items()
}
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
@@ -1559,7 +1582,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
}
output.append(player_output)
for option_key, option in world.options_dataclass.type_hints.items():
if issubclass(Removed, option):
if option.visibility == Visibility.none:
continue
display_name = getattr(option, "display_name", option_key)
player_output[display_name] = getattr(world.options, option_key).current_option_name

View File

@@ -76,6 +76,10 @@ Currently, the following games are supported:
* Kingdom Hearts 1
* Mega Man 2
* Yacht Dice
* Faxanadu
* Saving Princess
* Castlevania: Circle of the Moon
* Inscryption
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled

View File

@@ -243,6 +243,9 @@ class SNIContext(CommonContext):
# Once the games handled by SNIClient gets made to be remote items,
# this will no longer be needed.
async_start(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}]))
if self.client_handler is not None:
self.client_handler.on_package(self, cmd, args)
def run_gui(self) -> None:
from kvui import GameManager

View File

@@ -19,8 +19,7 @@ import warnings
from argparse import Namespace
from settings import Settings, get_settings
from time import sleep
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
from typing_extensions import TypeGuard
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
from yaml import load, load_all, dump
try:
@@ -48,7 +47,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.5.1"
__version__ = "0.6.0"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -153,8 +152,15 @@ def home_path(*path: str) -> str:
if hasattr(home_path, 'cached_path'):
pass
elif sys.platform.startswith('linux'):
home_path.cached_path = os.path.expanduser('~/Archipelago')
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
xdg_data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
home_path.cached_path = xdg_data_home + '/Archipelago'
if not os.path.isdir(home_path.cached_path):
legacy_home_path = os.path.expanduser('~/Archipelago')
if os.path.isdir(legacy_home_path):
os.renames(legacy_home_path, home_path.cached_path)
os.symlink(home_path.cached_path, legacy_home_path)
else:
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
else:
# not implemented
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
@@ -422,7 +428,8 @@ class RestrictedUnpickler(pickle.Unpickler):
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# used by MultiServer -> savegame/multidata
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint", "SlotType", "NetworkSlot"}:
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
"SlotType", "NetworkSlot", "HintStatus"}:
return getattr(self.net_utils_module, name)
# Options and Plando are unpickled by WebHost -> Generate
if module == "worlds.generic" and name == "PlandoItem":
@@ -436,7 +443,8 @@ class RestrictedUnpickler(pickle.Unpickler):
else:
mod = importlib.import_module(module)
obj = getattr(mod, name)
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection)):
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
self.options_module.PlandoText)):
return obj
# Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
@@ -485,9 +493,9 @@ def get_text_after(text: str, start: str) -> str:
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
exception_logger: typing.Optional[str] = None):
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
write_mode: str = "w", log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
add_timestamp: bool = False, exception_logger: typing.Optional[str] = None):
import datetime
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = user_path("logs")
@@ -514,11 +522,15 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
def filter(self, record: logging.LogRecord) -> bool:
return self.condition(record)
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.getMessage()))
root_logger.addHandler(file_handler)
if sys.stdout:
formatter = logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.addFilter(Filter("NoFile", lambda record: not getattr(record, "NoStream", False)))
if add_timestamp:
stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler)
# Relay unhandled exceptions to logger.
@@ -530,7 +542,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logging.getLogger(exception_logger).exception("Uncaught exception",
exc_info=(exc_type, exc_value, exc_traceback))
exc_info=(exc_type, exc_value, exc_traceback),
extra={"NoStream": exception_logger is None})
return orig_hook(exc_type, exc_value, exc_traceback)
handle_exception._wrapped = True
@@ -553,7 +566,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
import platform
logging.info(
f"Archipelago ({__version__}) logging initialized"
f" on {platform.platform()}"
f" on {platform.platform()} process {os.getpid()}"
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
f"{' (frozen)' if is_frozen() else ''}"
)
@@ -855,11 +868,10 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non
task.add_done_callback(_faf_tasks.discard)
def deprecate(message: str):
def deprecate(message: str, add_stacklevels: int = 0):
if __debug__:
raise Exception(message)
import warnings
warnings.warn(message)
warnings.warn(message, stacklevel=2 + add_stacklevels)
class DeprecateDict(dict):
@@ -873,10 +885,9 @@ class DeprecateDict(dict):
def __getitem__(self, item: Any) -> Any:
if self.should_error:
deprecate(self.log_message)
deprecate(self.log_message, add_stacklevels=1)
elif __debug__:
import warnings
warnings.warn(self.log_message)
warnings.warn(self.log_message, stacklevel=2)
return super().__getitem__(item)
@@ -930,7 +941,7 @@ def freeze_support() -> None:
def visualize_regions(root_region: Region, file_name: str, *,
show_entrance_names: bool = False, show_locations: bool = True, show_other_regions: bool = True,
linetype_ortho: bool = True) -> None:
linetype_ortho: bool = True, regions_to_highlight: set[Region] | None = None) -> None:
"""Visualize the layout of a world as a PlantUML diagram.
:param root_region: The region from which to start the diagram from. (Usually the "Menu" region of your world.)
@@ -946,16 +957,22 @@ def visualize_regions(root_region: Region, file_name: str, *,
Items without ID will be shown in italics.
:param show_other_regions: (default True) If enabled, regions that can't be reached by traversing exits are shown.
:param linetype_ortho: (default True) If enabled, orthogonal straight line parts will be used; otherwise polylines.
:param regions_to_highlight: Regions that will be highlighted in green if they are reachable.
Example usage in World code:
from Utils import visualize_regions
visualize_regions(self.multiworld.get_region("Menu", self.player), "my_world.puml")
state = self.multiworld.get_all_state(False)
state.update_reachable_regions(self.player)
visualize_regions(self.get_region("Menu"), "my_world.puml", show_entrance_names=True,
regions_to_highlight=state.reachable_regions[self.player])
Example usage in Main code:
from Utils import visualize_regions
for player in multiworld.player_ids:
visualize_regions(multiworld.get_region("Menu", player), f"{multiworld.get_out_file_name_base(player)}.puml")
"""
if regions_to_highlight is None:
regions_to_highlight = set()
assert root_region.multiworld, "The multiworld attribute of root_region has to be filled"
from BaseClasses import Entrance, Item, Location, LocationProgressType, MultiWorld, Region
from collections import deque
@@ -1008,7 +1025,7 @@ def visualize_regions(root_region: Region, file_name: str, *,
uml.append(f"\"{fmt(region)}\" : {{field}} {lock}{fmt(location)}")
def visualize_region(region: Region) -> None:
uml.append(f"class \"{fmt(region)}\"")
uml.append(f"class \"{fmt(region)}\" {'#00FF00' if region in regions_to_highlight else ''}")
if show_locations:
visualize_locations(region)
visualize_exits(region)

View File

@@ -17,7 +17,7 @@ from Utils import get_file_safe_name
if typing.TYPE_CHECKING:
from flask import Flask
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
Utils.local_path.cached_path = os.path.dirname(__file__)
settings.no_gui = True
configpath = os.path.abspath("config.yaml")
if not os.path.exists(configpath): # fall back to config.yaml in home
@@ -34,7 +34,7 @@ def get_app() -> "Flask":
app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}")
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
parser = argparse.ArgumentParser()
parser = argparse.ArgumentParser(allow_abbrev=False)
parser.add_argument('--config_override', default=None,
help="Path to yaml config file that overrules config.yaml.")
args = parser.parse_known_args()[0]

View File

@@ -39,6 +39,8 @@ app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
app.config["JOB_THRESHOLD"] = 1
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
# memory limit for generator processes in bytes
app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent
@@ -85,6 +87,6 @@ def register():
from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session
app.register_blueprint(api.api_endpoints)

View File

@@ -3,13 +3,13 @@ from typing import List, Tuple
from flask import Blueprint
from ..models import Seed
from ..models import Seed, Slot
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
def get_players(seed: Seed) -> List[Tuple[str, str]]:
return [(slot.player_name, slot.game) for slot in seed.slots]
return [(slot.player_name, slot.game) for slot in seed.slots.order_by(Slot.player_id)]
from . import datapackage, generate, room, user # trigger registration

View File

@@ -30,4 +30,4 @@ def get_seeds():
"creation_time": seed.creation_time,
"players": get_players(seed.slots),
})
return jsonify(response)
return jsonify(response)

View File

@@ -6,6 +6,7 @@ import multiprocessing
import typing
from datetime import timedelta, datetime
from threading import Event, Thread
from typing import Any
from uuid import UUID
from pony.orm import db_session, select, commit
@@ -53,7 +54,21 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
generation.state = STATE_STARTED
def init_db(pony_config: dict):
def init_generator(config: dict[str, Any]) -> None:
try:
import resource
except ModuleNotFoundError:
pass # unix only module
else:
# set soft limit for memory to from config (default 4GiB)
soft_limit = config["GENERATOR_MEMORY_LIMIT"]
old_limit, hard_limit = resource.getrlimit(resource.RLIMIT_AS)
if soft_limit != old_limit:
resource.setrlimit(resource.RLIMIT_AS, (soft_limit, hard_limit))
logging.debug(f"Changed AS mem limit {old_limit} -> {soft_limit}")
del resource, soft_limit, hard_limit
pony_config = config["PONY"]
db.bind(**pony_config)
db.generate_mapping()
@@ -105,8 +120,8 @@ def autogen(config: dict):
try:
with Locker("autogen"):
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
initargs=(config,), maxtasksperchild=10) as generator_pool:
with db_session:
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)

View File

@@ -105,8 +105,9 @@ def roll_options(options: Dict[str, Union[dict, str]],
plando_options=plando_options)
else:
for i, yaml_data in enumerate(yaml_datas):
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
plando_options=plando_options)
if yaml_data is not None:
rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data,
plando_options=plando_options)
except Exception as e:
if e.__cause__:
results[filename] = f"Failed to generate options in {filename}: {e} - {e.__cause__}"

View File

@@ -117,6 +117,7 @@ class WebHostContext(Context):
self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load
self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})}
self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})}
missing_checksum = False
for game in list(multidata.get("datapackage", {})):
game_data = multidata["datapackage"][game]
@@ -132,11 +133,13 @@ class WebHostContext(Context):
continue
else:
self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}")
else:
missing_checksum = True # Game rolled on old AP and will load data package from multidata
self.gamespackage[game] = static_gamespackage.get(game, {})
self.item_name_groups[game] = static_item_name_groups.get(game, {})
self.location_name_groups[game] = static_location_name_groups.get(game, {})
if not game_data_packages:
if not game_data_packages and not missing_checksum:
# all static -> use the static dicts directly
self.gamespackage = static_gamespackage
self.item_name_groups = static_item_name_groups

View File

@@ -31,11 +31,11 @@ def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[s
server_options = {
"hint_cost": int(options_source.get("hint_cost", ServerOptions.hint_cost)),
"release_mode": options_source.get("release_mode", ServerOptions.release_mode),
"remaining_mode": options_source.get("remaining_mode", ServerOptions.remaining_mode),
"collect_mode": options_source.get("collect_mode", ServerOptions.collect_mode),
"release_mode": str(options_source.get("release_mode", ServerOptions.release_mode)),
"remaining_mode": str(options_source.get("remaining_mode", ServerOptions.remaining_mode)),
"collect_mode": str(options_source.get("collect_mode", ServerOptions.collect_mode)),
"item_cheat": bool(int(options_source.get("item_cheat", not ServerOptions.disable_item_cheat))),
"server_password": options_source.get("server_password", None),
"server_password": str(options_source.get("server_password", None)),
}
generator_options = {
"spoiler": int(options_source.get("spoiler", GeneratorOptions.spoiler)),

View File

@@ -18,13 +18,6 @@ def get_world_theme(game_name: str):
return 'grass'
@app.before_request
def register_session():
session.permanent = True # technically 31 days after the last visit
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.errorhandler(404)
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
def page_not_found(err):

View File

@@ -5,9 +5,7 @@ waitress>=3.0.0
Flask-Caching>=2.3.0
Flask-Compress>=1.15
Flask-Limiter>=3.8.0
bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.4.3; python_version == '3.9'
bokeh>=3.5.2; python_version >= '3.10'
bokeh>=3.5.2
markupsafe>=2.1.5
Markdown>=3.7
mdx-breakless-lists>=1.0.1

31
WebHostLib/session.py Normal file
View File

@@ -0,0 +1,31 @@
from uuid import uuid4, UUID
from flask import session, render_template
from WebHostLib import app
@app.before_request
def register_session():
session.permanent = True # technically 31 days after the last visit
if not session.get("_id", None):
session["_id"] = uuid4() # uniquely identify each session without needing a login
@app.route('/session')
def show_session():
return render_template(
"session.html",
)
@app.route('/session/<string:_id>')
def set_session(_id: str):
new_id: UUID = UUID(_id, version=4)
old_id: UUID = session["_id"]
if old_id != new_id:
session["_id"] = new_id
return render_template(
"session.html",
old_id=old_id,
)

View File

@@ -22,7 +22,7 @@ players to rely upon each other to complete their game.
While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows
players to randomize any of the supported games, and send items between them. This allows players of different
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld.
games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds.
Here is a list of our [Supported Games](https://archipelago.gg/games).
## Can I generate a single-player game with Archipelago?

View File

@@ -75,6 +75,27 @@
#inventory-table img.acquired.green{ /*32CD32*/
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
}
#inventory-table img.acquired.hotpink{ /*FF69B4*/
filter: sepia(100%) hue-rotate(300deg) saturate(10);
}
#inventory-table img.acquired.lightsalmon{ /*FFA07A*/
filter: sepia(100%) hue-rotate(347deg) saturate(10);
}
#inventory-table img.acquired.crimson{ /*DB143B*/
filter: sepia(100%) hue-rotate(318deg) saturate(10) brightness(0.86);
}
#inventory-table span{
color: #B4B4A0;
font-size: 40px;
max-width: 40px;
max-height: 40px;
filter: grayscale(100%) contrast(75%) brightness(30%);
}
#inventory-table span.acquired{
filter: none;
}
#inventory-table div.image-stack{
display: grid;

View File

@@ -178,8 +178,15 @@
})
.then(text => new DOMParser().parseFromString(text, 'text/html'))
.then(newDocument => {
let el = newDocument.getElementById("host-room-info");
document.getElementById("host-room-info").innerHTML = el.innerHTML;
["host-room-info", "slots-table"].forEach(function(id) {
const newEl = newDocument.getElementById(id);
const oldEl = document.getElementById(id);
if (oldEl && newEl) {
oldEl.innerHTML = newEl.innerHTML;
} else if (newEl) {
console.warn(`Did not find element to replace for ${id}`)
}
});
});
}

View File

@@ -1,6 +1,6 @@
{% block footer %}
<footer id="island-footer">
<div id="copyright-notice">Copyright 2024 Archipelago</div>
<div id="copyright-notice">Copyright 2025 Archipelago</div>
<div id="links">
<a href="/sitemap">Site Map</a>
-

View File

@@ -8,7 +8,7 @@
{%- endmacro %}
{% macro list_patches_room(room) %}
{% if room.seed.slots %}
<table>
<table id="slots-table">
<thead>
<tr>
<th>Id</th>

View File

@@ -0,0 +1,30 @@
{% extends 'pageWrapper.html' %}
{% block head %}
{% include 'header/stoneHeader.html' %}
<title>Session</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
{% endblock %}
{% block body %}
<div class="markdown">
{% if old_id is defined %}
<p>Your old code was:</p>
<code>{{ old_id }}</code>
<br>
{% endif %}
<p>The following code is your unique identifier, it binds your uploaded content, such as rooms and seeds to you.
Treat it like a combined login name and password.
You should save this securely if you ever need to restore access.
You can also paste it into another device to access your content from multiple devices / browsers.
Some browsers, such as Brave, will delete your identifier cookie on a timer.</p>
<code>{{ session["_id"] }}</code>
<br>
<p>
The following link can be used to set the identifier. Do not share the code or link with others. <br>
<a href="{{ url_for('set_session', _id=session['_id']) }}">
{{ url_for('set_session', _id=session['_id'], _external=True) }}
</a>
</p>
</div>
{% endblock %}

View File

@@ -26,6 +26,7 @@
<li><a href="/user-content">User Content</a></li>
<li><a href="{{url_for('stats')}}">Game Statistics</a></li>
<li><a href="/glossary/en">Glossary</a></li>
<li><a href="{{url_for("show_session")}}">Session / Login</a></li>
</ul>
<h2>Tutorials</h2>

View File

@@ -4,9 +4,6 @@
{% include 'header/grassHeader.html' %}
<title>Option Templates (YAML)</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
crossorigin="anonymous"></script>
{% endblock %}
{% block body %}

View File

@@ -99,6 +99,52 @@
{% endif %}
</div>
</div>
{% if 'PrismBreak' in options or 'LockKeyAmadeus' in options or 'GateKeep' in options %}
<div class="table-row">
{% if 'PrismBreak' in options %}
<div class="C1">
<div class="image-stack">
<div class="stack-front">
<div class="stack-top-left">
<img src="{{ icons['Laser Access'] }}" class="hotpink {{ 'acquired' if 'Laser Access A' in acquired_items }}" title="Laser Access A" />
</div>
<div class="stack-top-right">
<img src="{{ icons['Laser Access'] }}" class="lightsalmon {{ 'acquired' if 'Laser Access I' in acquired_items }}" title="Laser Access I" />
</div>
<div class="stack-bottum-left">
<img src="{{ icons['Laser Access'] }}" class="crimson {{ 'acquired' if 'Laser Access M' in acquired_items }}" title="Laser Access M" />
</div>
</div>
</div>
</div>
{% endif %}
{% if 'LockKeyAmadeus' in options %}
<div class="C2">
<div class="image-stack">
<div class="stack-front">
<div class="stack-top-left">
<img src="{{ icons['Lab Glasses'] }}" class="{{ 'acquired' if 'Lab Access Genza' in acquired_items }}" title="Lab Access Genza" />
</div>
<div class="stack-top-right">
<img src="{{ icons['Eye Orb'] }}" class="{{ 'acquired' if 'Lab Access Dynamo' in acquired_items }}" title="Lab Access Dynamo" />
</div>
<div class="stack-bottum-left">
<img src="{{ icons['Lab Coat'] }}" class="{{ 'acquired' if 'Lab Access Research' in acquired_items }}" title="Lab Access Research" />
</div>
<div class="stack-bottum-right">
<img src="{{ icons['Demon'] }}" class="{{ 'acquired' if 'Lab Access Experiment' in acquired_items }}" title="Lab Access Experiment" />
</div>
</div>
</div>
</div>
{% endif %}
{% if 'GateKeep' in options %}
<div class="C3">
<span class="{{ 'acquired' if 'Drawbridge Key' in acquired_items }}" title="Drawbridge Key">&#10070;</span>
</div>
{% endif %}
</div>
{% endif %}
</div>
<table id="location-table">

View File

@@ -53,7 +53,7 @@
<table class="range-rows" data-option="{{ option_name }}">
<tbody>
{{ RangeRow(option_name, option, option.range_start, option.range_start, True) }}
{% if option.range_start < option.default < option.range_end %}
{% if option.default is number and option.range_start < option.default < option.range_end %}
{{ RangeRow(option_name, option, option.default, option.default, True) }}
{% endif %}
{{ RangeRow(option_name, option, option.range_end, option.range_end, True) }}

View File

@@ -1071,6 +1071,11 @@ if "Timespinner" in network_data_package["games"]:
"Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png",
"Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png",
"Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png",
"Laser Access": "https://timespinnerwiki.com/mediawiki/images/9/99/Historical_Documents.png",
"Lab Glasses": "https://timespinnerwiki.com/mediawiki/images/4/4a/Lab_Glasses.png",
"Eye Orb": "https://timespinnerwiki.com/mediawiki/images/a/a4/Eye_Orb.png",
"Lab Coat": "https://timespinnerwiki.com/mediawiki/images/5/51/Lab_Coat.png",
"Demon": "https://timespinnerwiki.com/mediawiki/images/f/f8/Familiar_Demon.png",
}
timespinner_location_ids = {
@@ -1118,6 +1123,9 @@ if "Timespinner" in network_data_package["games"]:
timespinner_location_ids["Ancient Pyramid"] += [
1337237, 1337238, 1337239,
1337240, 1337241, 1337242, 1337243, 1337244, 1337245]
if (slot_data["PyramidStart"]):
timespinner_location_ids["Ancient Pyramid"] += [
1337233, 1337234, 1337235]
display_data = {}

View File

@@ -69,6 +69,14 @@ cdef struct IndexEntry:
size_t count
if TYPE_CHECKING:
State = Dict[Tuple[int, int], Set[int]]
else:
State = Union[Tuple[int, int], Set[int], defaultdict]
T = TypeVar('T')
@cython.auto_pickle(False)
cdef class LocationStore:
"""Compact store for locations and their items in a MultiServer"""
@@ -137,10 +145,16 @@ cdef class LocationStore:
warnings.warn("Game has no locations")
# allocate the arrays and invalidate index (0xff...)
self.entries = <LocationEntry*>self._mem.alloc(count, sizeof(LocationEntry))
if count:
# leaving entries as NULL if there are none, makes potential memory errors more visible
self.entries = <LocationEntry*>self._mem.alloc(count, sizeof(LocationEntry))
self.sender_index = <IndexEntry*>self._mem.alloc(max_sender + 1, sizeof(IndexEntry))
self._raw_proxies = <PyObject**>self._mem.alloc(max_sender + 1, sizeof(PyObject*))
assert (not self.entries) == (not count)
assert self.sender_index
assert self._raw_proxies
# build entries and index
cdef size_t i = 0
for sender, locations in sorted(locations_dict.items()):
@@ -190,8 +204,6 @@ cdef class LocationStore:
raise KeyError(key)
return <object>self._raw_proxies[key]
T = TypeVar('T')
def get(self, key: int, default: T) -> Union[PlayerLocationProxy, T]:
# calling into self.__getitem__ here is slow, but this is not used in MultiServer
try:
@@ -246,12 +258,11 @@ cdef class LocationStore:
all_locations[sender].add(entry.location)
return all_locations
if TYPE_CHECKING:
State = Dict[Tuple[int, int], Set[int]]
else:
State = Union[Tuple[int, int], Set[int], defaultdict]
def get_checked(self, state: State, team: int, slot: int) -> List[int]:
cdef ap_player_t sender = slot
if sender < 0 or sender >= self.sender_index_size:
raise KeyError(slot)
# This used to validate checks actually exist. A remnant from the past.
# If the order of locations becomes relevant at some point, we could not do sorted(set), so leaving it.
cdef set checked = state[team, slot]
@@ -263,7 +274,6 @@ cdef class LocationStore:
# Unless the set is close to empty, it's cheaper to use the python set directly, so we do that.
cdef LocationEntry* entry
cdef ap_player_t sender = slot
cdef size_t start = self.sender_index[sender].start
cdef size_t count = self.sender_index[sender].count
return [entry.location for
@@ -273,9 +283,11 @@ cdef class LocationStore:
def get_missing(self, state: State, team: int, slot: int) -> List[int]:
cdef LocationEntry* entry
cdef ap_player_t sender = slot
if sender < 0 or sender >= self.sender_index_size:
raise KeyError(slot)
cdef set checked = state[team, slot]
cdef size_t start = self.sender_index[sender].start
cdef size_t count = self.sender_index[sender].count
cdef set checked = state[team, slot]
if not len(checked):
# Skip `in` if none have been checked.
# This optimizes the case where everyone connects to a fresh game at the same time.
@@ -290,9 +302,11 @@ cdef class LocationStore:
def get_remaining(self, state: State, team: int, slot: int) -> List[Tuple[int, int]]:
cdef LocationEntry* entry
cdef ap_player_t sender = slot
if sender < 0 or sender >= self.sender_index_size:
raise KeyError(slot)
cdef set checked = state[team, slot]
cdef size_t start = self.sender_index[sender].start
cdef size_t count = self.sender_index[sender].count
cdef set checked = state[team, slot]
return sorted([(entry.receiver, entry.item) for
entry in self.entries[start:start+count] if
entry.location not in checked])
@@ -328,7 +342,8 @@ cdef class PlayerLocationProxy:
cdef LocationEntry* entry = NULL
# binary search
cdef size_t l = self._store.sender_index[self._player].start
cdef size_t r = l + self._store.sender_index[self._player].count
cdef size_t e = l + self._store.sender_index[self._player].count
cdef size_t r = e
cdef size_t m
while l < r:
m = (l + r) // 2
@@ -337,7 +352,7 @@ cdef class PlayerLocationProxy:
l = m + 1
else:
r = m
if entry: # count != 0
if l < e:
entry = self._store.entries + l
if entry.location == loc:
return entry
@@ -349,8 +364,6 @@ cdef class PlayerLocationProxy:
return entry.item, entry.receiver, entry.flags
raise KeyError(f"No location {key} for player {self._player}")
T = TypeVar('T')
def get(self, key: int, default: T) -> Union[Tuple[int, int, int], T]:
cdef LocationEntry* entry = self._get(key)
if entry:

View File

@@ -3,8 +3,16 @@ import os
def make_ext(modname, pyxfilename):
from distutils.extension import Extension
return Extension(name=modname,
sources=[pyxfilename],
depends=["intset.h"],
include_dirs=[os.getcwd()],
language="c")
return Extension(
name=modname,
sources=[pyxfilename],
depends=["intset.h"],
include_dirs=[os.getcwd()],
language="c",
# to enable ASAN and debug build:
# extra_compile_args=["-fsanitize=address", "-UNDEBUG", "-Og", "-g"],
# extra_objects=["-fsanitize=address"],
# NOTE: we can not put -lasan at the front of link args, so needs to be run with
# LD_PRELOAD=/usr/lib/libasan.so ASAN_OPTIONS=detect_leaks=0 path/to/exe
# NOTE: this can't find everything unless libpython and cymem are also built with ASAN
)

View File

@@ -59,7 +59,7 @@
finding_text: "Finding Player"
location_text: "Location"
entrance_text: "Entrance"
found_text: "Found?"
status_text: "Status"
TooltipLabel:
id: receiving
sort_key: 'receiving'
@@ -96,9 +96,9 @@
valign: 'center'
pos_hint: {"center_y": 0.5}
TooltipLabel:
id: found
sort_key: 'found'
text: root.found_text
id: status
sort_key: 'status'
text: root.status_text
halign: 'center'
valign: 'center'
pos_hint: {"center_y": 0.5}
@@ -147,3 +147,8 @@
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
<ServerToolTip>:
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
<AutocompleteHintInput>
size_hint_y: None
height: dp(30)
multiline: False
write_tab: False

View File

@@ -121,6 +121,14 @@ Response:
Expected Response Type: `HASH_RESPONSE`
- `MEMORY_SIZE`
Returns the size in bytes of the specified memory domain.
Expected Response Type: `MEMORY_SIZE_RESPONSE`
Additional Fields:
- `domain` (`string`): The name of the memory domain to check
- `GUARD`
Checks a section of memory against `expected_data`. If the bytes starting
at `address` do not match `expected_data`, the response will have `value`
@@ -216,6 +224,12 @@ Response:
Additional Fields:
- `value` (`string`): The returned hash
- `MEMORY_SIZE_RESPONSE`
Contains the size in bytes of the specified memory domain.
Additional Fields:
- `value` (`number`): The size of the domain in bytes
- `GUARD_RESPONSE`
The result of an attempted `GUARD` request.
@@ -376,6 +390,15 @@ request_handlers = {
return res
end,
["MEMORY_SIZE"] = function (req)
local res = {}
res["type"] = "MEMORY_SIZE_RESPONSE"
res["value"] = memory.getmemorydomainsize(req["domain"])
return res
end,
["GUARD"] = function (req)
local res = {}
local expected_data = base64.decode(req["expected_data"])
@@ -613,9 +636,11 @@ end)
if bizhawk_major < 2 or (bizhawk_major == 2 and bizhawk_minor < 7) then
print("Must use BizHawk 2.7.0 or newer")
elseif bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 9) then
print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.9.")
else
if bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 10) then
print("Warning: This version of BizHawk is newer than this script. If it doesn't work, consider downgrading to 2.10.")
end
if emu.getsystemid() == "NULL" then
print("No ROM is loaded. Please load a ROM.")
while emu.getsystemid() == "NULL" do

View File

@@ -1816,7 +1816,7 @@ end
-- Main control handling: main loop and socket receive
function receive()
function APreceive()
l, e = ootSocket:receive()
-- Handle incoming message
if e == 'closed' then
@@ -1874,7 +1874,7 @@ function main()
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 30 == 0) then
receive()
APreceive()
end
elseif (curstate == STATE_UNINITIALIZED) then
if (frame % 60 == 0) then

View File

@@ -36,6 +36,9 @@
# Castlevania 64
/worlds/cv64/ @LiquidCat64
# Castlevania: Circle of the Moon
/worlds/cvcotm/ @LiquidCat64
# Celeste 64
/worlds/celeste64/ @PoryGone
@@ -55,19 +58,22 @@
/worlds/dlcquest/ @axe-y @agilbert1412
# DOOM 1993
/worlds/doom_1993/ @Daivuk
/worlds/doom_1993/ @Daivuk @KScl
# DOOM II
/worlds/doom_ii/ @Daivuk
/worlds/doom_ii/ @Daivuk @KScl
# Factorio
/worlds/factorio/ @Berserker66
# Faxanadu
/worlds/faxanadu/ @Daivuk
# Final Fantasy Mystic Quest
/worlds/ffmq/ @Alchav @wildham0
# Heretic
/worlds/heretic/ @Daivuk
/worlds/heretic/ @Daivuk @KScl
# Hollow Knight
/worlds/hk/ @BadMagic100 @qwint
@@ -75,6 +81,9 @@
# Hylics 2
/worlds/hylics2/ @TRPG0
# Inscryption
/worlds/inscryption/ @DrBibop @Glowbuzz
# Kirby's Dream Land 3
/worlds/kdl3/ @Silvris
@@ -90,6 +99,9 @@
# Lingo
/worlds/lingo/ @hatkirby
# Links Awakening DX
/worlds/ladx/ @threeandthreee
# Lufia II Ancient Cave
/worlds/lufia2ac/ @el-u
/worlds/lufia2ac/docs/ @wordfcuk @el-u
@@ -139,8 +151,11 @@
# Risk of Rain 2
/worlds/ror2/ @kindasneaki
# Saving Princess
/worlds/saving_princess/ @LeonarthCG
# Shivers
/worlds/shivers/ @GodlFire
/worlds/shivers/ @GodlFire @korydondzila
# A Short Hike
/worlds/shorthike/ @chandler05 @BrandenEK
@@ -224,9 +239,6 @@
# Final Fantasy (1)
# /worlds/ff1/
# Links Awakening DX
# /worlds/ladx/
# Ocarina of Time
# /worlds/oot/

View File

@@ -43,3 +43,26 @@ A faster alternative to the `for` loop would be to use a [list comprehension](ht
```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 how to use `multiworld.register_indirect_condition` to register 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. It starts from the origin region, checks entrances one by one, and adds newly reached 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_rule depends on region access, then the following may happen:
1. The entrance is checked and determined to be nontraversable because the region in its access_rule hasn't been reached yet during the graph search.
2. Then, the region in its access_rule is determined to be reachable.
This entrance *would* be in logic if it were rechecked, but it won't be rechecked this cycle.
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.
An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep if a specific region is reached during it.
This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness), using them is significantly faster than just "rechecking each entrance until nothing new is found".
The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is because 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 possible for a world to opt out of indirect conditions entirely, instead using the system of checking each entrance whenever a region has been reached, although this does come with a performance cost.
Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should be reasonable to know all entrance &rarr; region dependencies, making indirect conditions preferred because they are much faster.

View File

@@ -16,7 +16,7 @@ game contributions:
* **Do not introduce unit test failures/regressions.**
Archipelago supports multiple versions of Python. You may need to download older Python versions to fully test
your changes. Currently, the oldest supported version
is [Python 3.8](https://www.python.org/downloads/release/python-380/).
is [Python 3.10](https://www.python.org/downloads/release/python-31015/).
It is recommended that automated github actions are turned on in your fork to have github run unit tests after
pushing.
You can turn them on here:

View File

@@ -0,0 +1,424 @@
# Entrance Randomization
This document discusses the API and underlying implementation of the generic entrance randomization algorithm
exposed in [entrance_rando.py](/entrance_rando.py). Throughout the doc, entrance randomization is frequently abbreviated
as "ER."
This doc assumes familiarity with Archipelago's graph logic model. If you don't have a solid understanding of how
regions work, you should start there.
## Entrance randomization concepts
### Terminology
Some important terminology to understand when reading this doc and working with ER is listed below.
* Entrance rando - sometimes called "room rando," "transition rando," "door rando," or similar,
this is a game mode in which the game map itself is randomized.
In Archipelago, these things are often represented as `Entrance`s in the region graph, so we call it Entrance rando.
* Entrances and exits - entrances are ways into your region, exits are ways out of the region. In code, they are both
represented as `Entrance` objects. In this doc, the terms "entrances" and "exits" will be used in this sense; the
`Entrance` class will always be referenced in a code block with an uppercase E.
* Dead end - a connected group of regions which can never help ER progress. This means that it:
* Is not in any indirect conditions/access rules.
* Has no plando'd or otherwise preplaced progression items, including events.
* Has no randomized exits.
* One way transition - a transition that, in the game, is not safe to reverse through (for example, in Hollow Knight,
some transitions are inaccessible backwards in vanilla and would put you out of bounds). One way transitions are
paired together during randomization to prevent such unsafe game states. Most transitions are not one way.
### Basic randomization strategy
The Generic ER algorithm works by using the logic structures you are already familiar with. To give a basic example,
let's assume a toy world is defined with the vanilla region graph modeled below. In this diagram, the smaller boxes
represent regions while the larger boxes represent scenes. Scenes are not an Archipelago concept, the grouping is
purely illustrative.
```mermaid
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
graph LR
subgraph startingRoom [Starting Room]
S[Starting Room Right Door]
end
subgraph sceneB [Scene B]
BR1[Scene B Right Door]
end
subgraph sceneA [Scene A]
AL1[Scene A Lower Left Door] <--> AR1[Scene A Right Door]
AL2[Scene A Upper Left Door] <--> AR1
end
subgraph sceneC [Scene C]
CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door]
CL1 <--> CR2[Scene C Lower Right Door]
end
subgraph sceneD [Scene D]
DL1[Scene D Left Door] <--> DR1[Scene D Right Door]
end
subgraph endingRoom [Ending Room]
EL1[Ending Room Upper Left Door] <--> Victory
EL2[Ending Room Lower Left Door] <--> Victory
end
Menu --> S
S <--> AL2
BR1 <--> AL1
AR1 <--> CL1
CR1 <--> DL1
DR1 <--> EL1
CR2 <--> EL2
classDef hidden display:none;
```
First, the world begins by splitting the `Entrance`s which should be randomized. This is essentially all that has to be
done on the world side; calling the `randomize_entrances` function will do the rest, using your region definitions and
logic to generate a valid world layout by connecting the partially connected edges you've defined. After you have done
that, your region graph might look something like the following diagram. Note how each randomizable entrance/exit pair
(represented as a bidirectional arrow) is disconnected on one end.
> [!NOTE]
> It is required to use explicit indirect conditions when using Generic ER. Without this restriction,
> Generic ER would have no way to correctly determine that a region may be required in logic,
> leading to significantly higher failure rates due to mis-categorized regions.
```mermaid
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
graph LR
subgraph startingRoom [Starting Room]
S[Starting Room Right Door]
end
subgraph sceneA [Scene A]
AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door]
AL2[Scene A Lower Left Door] <--> AR1
end
subgraph sceneB [Scene B]
BR1[Scene B Right Door]
end
subgraph sceneC [Scene C]
CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door]
CL1 <--> CR2[Scene C Lower Right Door]
end
subgraph sceneD [Scene D]
DL1[Scene D Left Door] <--> DR1[Scene D Right Door]
end
subgraph endingRoom [Ending Room]
EL1[Ending Room Upper Left Door] <--> Victory
EL2[Ending Room Lower Left Door] <--> Victory
end
Menu --> S
S <--> T1:::hidden
T2:::hidden <--> AL1
T3:::hidden <--> AL2
AR1 <--> T5:::hidden
BR1 <--> T4:::hidden
T6:::hidden <--> CL1
CR1 <--> T7:::hidden
CR2 <--> T11:::hidden
T8:::hidden <--> DL1
DR1 <--> T9:::hidden
T10:::hidden <--> EL1
T12:::hidden <--> EL2
classDef hidden display:none;
```
From here, you can call the `randomize_entrances` function and Archipelago takes over. Starting from the Menu region,
the algorithm will sweep out to find eligible region exits to randomize. It will then select an eligible target entrance
and connect them, prioritizing giving access to unvisited regions first until all regions are placed. Once the exit has
been connected to the new region, placeholder entrances are deleted. This process is visualized in the diagram below
with the newly connected edge highlighted in red.
```mermaid
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
graph LR
subgraph startingRoom [Starting Room]
S[Starting Room Right Door]
end
subgraph sceneA [Scene A]
AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door]
AL2[Scene A Lower Left Door] <--> AR1
end
subgraph sceneB [Scene B]
BR1[Scene B Right Door]
end
subgraph sceneC [Scene C]
CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door]
CL1 <--> CR2[Scene C Lower Right Door]
end
subgraph sceneD [Scene D]
DL1[Scene D Left Door] <--> DR1[Scene D Right Door]
end
subgraph endingRoom [Ending Room]
EL1[Ending Room Upper Left Door] <--> Victory
EL2[Ending Room Lower Left Door] <--> Victory
end
Menu --> S
S <--> CL1
T2:::hidden <--> AL1
T3:::hidden <--> AL2
AR1 <--> T5:::hidden
BR1 <--> T4:::hidden
CR1 <--> T7:::hidden
CR2 <--> T11:::hidden
T8:::hidden <--> DL1
DR1 <--> T9:::hidden
T10:::hidden <--> EL1
T12:::hidden <--> EL2
classDef hidden display:none;
linkStyle 8 stroke:red,stroke-width:5px;
```
This process is then repeated until all disconnected `Entrance`s have been connected or deleted, eventually resulting
in a randomized region layout.
```mermaid
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
graph LR
subgraph startingRoom [Starting Room]
S[Starting Room Right Door]
end
subgraph sceneA [Scene A]
AL1[Scene A Upper Left Door] <--> AR1[Scene A Right Door]
AL2[Scene A Lower Left Door] <--> AR1
end
subgraph sceneB [Scene B]
BR1[Scene B Right Door]
end
subgraph sceneC [Scene C]
CL1[Scene C Left Door] <--> CR1[Scene C Upper Right Door]
CL1 <--> CR2[Scene C Lower Right Door]
end
subgraph sceneD [Scene D]
DL1[Scene D Left Door] <--> DR1[Scene D Right Door]
end
subgraph endingRoom [Ending Room]
EL1[Ending Room Upper Left Door] <--> Victory
EL2[Ending Room Lower Left Door] <--> Victory
end
Menu --> S
S <--> CL1
AR1 <--> DL1
BR1 <--> EL2
CR1 <--> EL1
CR2 <--> AL1
DR1 <--> AL2
classDef hidden display:none;
```
#### ER and minimal accessibility
In general, even on minimal accessibility, ER will prefer to provide access to as many regions as possible. This is for
2 reasons:
1. Generally, having items spread across the world is going to be a more fun/engaging experience for players than
severely restricting their map. Imagine an ER arrangement with just the start region, the goal region, and exactly
enough locations in between them to get the goal - this may be the intuitive behavior of minimal, or even the desired
behavior in some cases, but it is not a particularly interesting randomizer.
2. Giving access to more of the world will give item fill a higher chance to succeed.
However, ER will cull unreachable regions and exits if necessary to save the generation of a beaten minimal.
## Usage
### Defining entrances to be randomized
The first step to using generic ER is defining entrances to be randomized. In order to do this, you will need to
leave partially disconnected exits without a `target_region` and partially disconnected entrances without a
`parent_region`. You can do this either by hand using `region.create_exit` and `region.create_er_target`, or you can
create your vanilla region graph and then use `disconnect_entrance_for_randomization` to split the desired edges.
If you're not sure which to use, prefer the latter approach as it will automatically satisfy the requirements for
coupled randomization (discussed in more depth later).
> [!TIP]
> It's recommended to give your `Entrance`s non-default names when creating them. The default naming scheme is
> `f"{parent_region} -> {target_region}"` which is generally not helpful in an entrance rando context - after all,
> the target region will not be the same as vanilla and regions are often not user-facing anyway. Instead consider names
> that describe the location of the exit, such as "Starting Room Right Door."
When creating your `Entrance`s you should also set the randomization type and group. One-way `Entrance`s represent
transitions which are impossible to traverse in reverse. All other transitions are two-ways. To ensure that all
transitions can be accessed in the game, one-ways are only randomized with other one-ways and two-ways are only
randomized with other two-ways. You can set whether an `Entrance` is one-way or two-way using the `randomization_type`
attribute.
`Entrance`s can also set the `randomization_group` attribute to allow for grouping during randomization. This can be
any integer you define and may be based on player options. Some possible use cases for grouping include:
* Directional matching - only match leftward-facing transitions to rightward-facing ones
* Terrain matching - only match water transitions to water transitions and land transitions to land transitions
* Dungeon shuffle - only shuffle entrances within a dungeon/area with each other
* Combinations of the above
By default, all `Entrance`s are placed in the group 0. An entrance can only be a member of one group, but a given group
may connect to many other groups.
### Calling generic ER
Once you have defined all your entrances and exits and connected the Menu region to your region graph, you can call
`randomize_entrances` to perform randomization.
#### Coupled and uncoupled modes
In coupled randomization, an entrance placed from A to B guarantees that the reverse placement B to A also exists
(assuming that A and B are both two-way doors). Uncoupled randomization does not make this guarantee.
When using coupled mode, there are some requirements for how placeholder ER targets for two-ways are named.
`disconnect_entrance_for_randomization` will handle this for you. However, if you opt to create your ER targets and
exits by hand, you will need to ensure that ER targets into a region are named the same as the exit they correspond to.
This allows the randomizer to find and connect the reverse pairing after the first pairing is completed. See the diagram
below for an example of incorrect and correct naming.
Incorrect target naming:
```mermaid
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
graph LR
subgraph a [" "]
direction TB
target1
target2
end
subgraph b [" "]
direction TB
Region
end
Region["Room1"] -->|Room1 Right Door| target1:::hidden
Region --- target2:::hidden -->|Room2 Left Door| Region
linkStyle 1 stroke:none;
classDef hidden display:none;
style a display:none;
style b display:none;
```
Correct target naming:
```mermaid
%%{init: {"graph": {"defaultRenderer": "elk"}} }%%
graph LR
subgraph a [" "]
direction TB
target1
target2
end
subgraph b [" "]
direction TB
Region
end
Region["Room1"] -->|Room1 Right Door| target1:::hidden
Region --- target2:::hidden -->|Room1 Right Door| Region
linkStyle 1 stroke:none;
classDef hidden display:none;
style a display:none;
style b display:none;
```
#### Implementing grouping
When you created your entrances, you defined the group each entrance belongs to. Now you will have to define how groups
should connect with each other. This is done with the `target_group_lookup` and `preserve_group_order` parameters.
There is also a convenience function `bake_target_group_lookup` which can help to prepare group lookups when more
complex group mapping logic is needed. Some recipes for `target_group_lookup` are presented here.
For the recipes below, assume the following groups (if the syntax used here is unfamiliar to you, "bit masking" and
"bitwise operators" would be the terms to search for):
```python
class Groups(IntEnum):
# Directions
LEFT = 1
RIGHT = 2
TOP = 3
BOTTOM = 4
DOOR = 5
# Areas
FIELD = 1 << 3
CAVE = 2 << 3
MOUNTAIN = 3 << 3
# Bitmasks
DIRECTION_MASK = FIELD - 1
AREA_MASK = ~0 << 3
```
Directional matching:
```python
direction_matching_group_lookup = {
# with preserve_group_order = False, pair a left transition to either a right transition or door randomly
# with preserve_group_order = True, pair a left transition to a right transition, or else a door if no
# viable right transitions remain
Groups.LEFT: [Groups.RIGHT, Groups.DOOR],
# ...
}
```
Terrain matching or dungeon shuffle:
```python
def randomize_within_same_group(group: int) -> List[int]:
return [group]
identity_group_lookup = bake_target_group_lookup(world, randomize_within_same_group)
```
Directional + area shuffle:
```python
def get_target_groups(group: int) -> List[int]:
# example group: LEFT | CAVE
# example result: [RIGHT | CAVE, DOOR | CAVE]
direction = group & Groups.DIRECTION_MASK
area = group & Groups.AREA_MASK
return [pair_direction | area for pair_direction in direction_matching_group_lookup[direction]]
target_group_lookup = bake_target_group_lookup(world, get_target_groups)
```
#### When to call `randomize_entrances`
The correct step for this is `World.connect_entrances`.
Currently, you could theoretically do it as early as `World.create_regions` or as late as `pre_fill`.
However, there are upcoming changes to Item Plando and Generic Entrance Randomizer to make the two features work better
together.
These changes necessitate that entrance randomization is done exactly in `World.connect_entrances`.
It is fine for your Entrances to be connected differently or not at all before this step.
#### Informing your client about randomized entrances
`randomize_entrances` returns the completed `ERPlacementState`. The `pairings` attribute contains a list of the
created placements by name which can be used to populate slot data.
### Imposing custom constraints on randomization
Generic ER is, as the name implies, generic! That means that your world may have some use case which is not covered by
the ER implementation. To solve this, you can create a custom `Entrance` class which provides custom implementations
for `is_valid_source_transition` and `can_connect_to`. These allow arbitrary constraints to be implemented on
randomization, for instance helping to prevent restrictive sphere 1s or ensuring a maximum distance from a "hub" region.
> [!IMPORTANT]
> When implementing these functions, make sure to use `super().is_valid_source_transition` and `super().can_connect_to`
> as part of your implementation. Otherwise ER may behave unexpectedly.
## Implementation details
This section is a medium-level explainer of the implementation of ER for those who don't want to decipher the code.
However, a basic understanding of the mechanics of `fill_restrictive` will be helpful as many of the underlying
algorithms are shared
ER uses a forward fill approach to create the region layout. First, ER collects `all_state` and performs a region sweep
from Menu, similar to fill. ER then proceeds in stages to complete the randomization:
1. Attempt to connect all non-dead-end regions, prioritizing access to unseen regions so there will always be new exits
to pair off.
2. Attempt to connect all dead-end regions, so that all regions will be placed
3. Connect all remaining dangling edges now that all regions are placed.
1. Connect any other dead end entrances (e.g. second entrances to the same dead end regions).
2. Connect all remaining non-dead-ends amongst each other.
The process for each connection will do the following:
1. Select a randomizable exit of a reachable region which is a valid source transition.
2. Get its group and check `target_group_lookup` to determine which groups are valid targets.
3. Look up ER targets from those groups and find one which is valid according to `can_connect_to`
4. Connect the source exit to the target's target_region and delete the target.
* In stage 1, before placing the last valid source transition, an additional speculative sweep is performed to ensure
that there will be an available exit after the placement so randomization can continue.
5. If it's coupled mode, find the reverse exit and target by name and connect them as well.
6. Sweep to update reachable regions.
7. Call the `on_connect` callback.
This process repeats until the stage is complete, no valid source transition is found, or no valid target transition is
found for any source transition. Unlike fill, there is no attempt made to save a failed randomization.

View File

@@ -47,6 +47,9 @@ Packets are simple JSON lists in which any number of ordered network commands ca
An object can contain the "class" key, which will tell the content data type, such as "Version" in the following example.
Websocket connections should support per-message compression. Uncompressed connections are deprecated and may stop
working in the future.
Example:
```javascript
[{"cmd": "RoomInfo", "version": {"major": 0, "minor": 1, "build": 3, "class": "Version"}, "tags": ["WebHost"], ... }]
@@ -261,6 +264,7 @@ Sent to clients in response to a [Set](#Set) package if want_reply was set to tr
| key | str | The key that was updated. |
| value | any | The new value for the key. |
| original_value | any | The value the key had before it was updated. Not present on "_read" prefixed special keys. |
| slot | int | The slot that originally sent the Set package causing this change. |
Additional arguments added to the [Set](#Set) package that triggered this [SetReply](#SetReply) will also be passed along.
@@ -272,6 +276,7 @@ These packets are sent purely from client to server. They are not accepted by cl
* [Sync](#Sync)
* [LocationChecks](#LocationChecks)
* [LocationScouts](#LocationScouts)
* [UpdateHint](#UpdateHint)
* [StatusUpdate](#StatusUpdate)
* [Say](#Say)
* [GetDataPackage](#GetDataPackage)
@@ -342,6 +347,33 @@ This is useful in cases where an item appears in the game world, such as 'ledge
| locations | list\[int\] | The ids of the locations seen by the client. May contain any number of locations, even ones sent before; duplicates do not cause issues with the Archipelago server. |
| create_as_hint | int | If non-zero, the scouted locations get created and broadcasted as a player-visible hint. <br/>If 2 only new hints are broadcast, however this does not remove them from the LocationInfo reply. |
### UpdateHint
Sent to the server to update the status of a Hint. The client must be the 'receiving_player' of the Hint, or the update fails.
### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| player | int | The ID of the player whose location is being hinted for. |
| location | int | The ID of the location to update the hint for. If no hint exists for this location, the packet is ignored. |
| status | [HintStatus](#HintStatus) | Optional. If included, sets the status of the hint to this status. Cannot set `HINT_FOUND`, or change the status from `HINT_FOUND`. |
#### HintStatus
An enumeration containing the possible hint states.
```python
import enum
class HintStatus(enum.IntEnum):
HINT_UNSPECIFIED = 0 # The receiving player has not specified any status
HINT_NO_PRIORITY = 10 # The receiving player has specified that the item is unneeded
HINT_AVOID = 20 # The receiving player has specified that the item is detrimental
HINT_PRIORITY = 30 # The receiving player has specified that the item is needed
HINT_FOUND = 40 # The location has been collected. Status cannot be changed once found.
```
- Hints for items with `ItemClassification.trap` default to `HINT_AVOID`.
- Hints created with `LocationScouts`, `!hint_location`, or similar (hinting a location) default to `HINT_UNSPECIFIED`.
- Hints created with `!hint` or similar (hinting an item for yourself) default to `HINT_PRIORITY`.
- Once a hint is collected, its' status is updated to `HINT_FOUND` automatically, and can no longer be changed.
### StatusUpdate
Sent to the server to update on the sender's status. Examples include readiness or goal completion. (Example: defeated Ganon in A Link to the Past)
@@ -501,9 +533,9 @@ In JSON this may look like:
{"item": 3, "location": 3, "player": 3, "flags": 0}
]
```
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
`item` is the item id of the item. Item ids are only supported in the range of [-2<sup>53</sup> + 1, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup>, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
`location` is the location id of the item inside the world. Location ids are only supported in the range of [-2<sup>53</sup> + 1, 2<sup>53</sup> - 1], with anything ≤ 0 reserved for Archipelago use.
`player` is the player slot of the world the item is located in, except when inside an [LocationInfo](#LocationInfo) Packet then it will be the slot of the player to receive the item
@@ -512,7 +544,7 @@ In JSON this may look like:
| ----- | ----- |
| 0 | Nothing special about this item |
| 0b001 | If set, indicates the item can unlock logical advancement |
| 0b010 | If set, indicates the item is important but not in a way that unlocks advancement |
| 0b010 | If set, indicates the item is especially useful |
| 0b100 | If set, indicates the item is a trap |
### JSONMessagePart
@@ -526,6 +558,7 @@ class JSONMessagePart(TypedDict):
color: Optional[str] # only available if type is a color
flags: Optional[int] # only available if type is an item_id or item_name
player: Optional[int] # only available if type is either item or location
hint_status: Optional[HintStatus] # only available if type is hint_status
```
`type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently.
@@ -541,6 +574,7 @@ Possible values for `type` include:
| location_id | Location ID, should be resolved to Location Name |
| location_name | Location Name, not currently used over network, but supported by reference Clients. |
| entrance_name | Entrance Name. No ID mapping exists. |
| hint_status | The [HintStatus](#HintStatus) of the hint. Both `text` and `hint_status` are given. |
| color | Regular text that should be colored. Only `type` that will contain `color` data. |
@@ -644,6 +678,7 @@ class Hint(typing.NamedTuple):
found: bool
entrance: str = ""
item_flags: int = 0
status: HintStatus = HintStatus.HINT_UNSPECIFIED
```
### Data Package Contents
@@ -713,6 +748,7 @@ Tags are represented as a list of strings, the common client tags follow:
| HintGame | Indicates the client is a hint game, made to send hints instead of locations. Special join/leave message,¹ `game` is optional.² |
| Tracker | Indicates the client is a tracker, made to track instead of sending locations. Special join/leave message,¹ `game` is optional.² |
| TextOnly | Indicates the client is a basic client, made to chat instead of sending locations. Special join/leave message,¹ `game` is optional.² |
| NoText | Indicates the client does not want to receive text messages, improving performance if not needed. |
¹: When connecting or disconnecting, the chat message shows e.g. "tracking".\
²: Allows `game` to be empty or null in [Connect](#connect). Game and version validation will then be skipped.

View File

@@ -95,7 +95,7 @@ user hovers over the yellow "(?)" icon, and included in the YAML templates gener
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 and enable rich text rendering by setting `WebWorld.rich_text_options_doc = True`.
[reStructuredText]: https://docutils.sourceforge.io/rst.html

View File

@@ -7,7 +7,9 @@ use that version. These steps are for developers or platforms without compiled r
## General
What you'll need:
* [Python 3.8.7 or newer](https://www.python.org/downloads/), not the Windows Store version
* [Python 3.10.11 or newer](https://www.python.org/downloads/), not the Windows Store version
* On Windows, please consider only using the latest supported version in production environments since security
updates for older versions are not easily available.
* Python 3.12.x is currently the newest supported version
* pip: included in downloads from python.org, separate in many Linux distributions
* Matching C compiler
@@ -41,9 +43,9 @@ Recommended steps
[Discord in #ap-core-dev](https://discord.com/channels/731205301247803413/731214280439103580/905154456377757808)
* It is recommended to use [PyCharm IDE](https://www.jetbrains.com/pycharm/)
* Run Generate.py which will prompt installation of missing modules, press enter to confirm
* In PyCharm: right-click Generate.py and select `Run 'Generate'`
* Without PyCharm: open a command prompt in the source folder and type `py Generate.py`
* Run ModuleUpdate.py which will prompt installation of missing modules, press enter to confirm
* In PyCharm: right-click ModuleUpdate.py and select `Run 'ModuleUpdate'`
* Without PyCharm: open a command prompt in the source folder and type `py ModuleUpdate.py`
## macOS

View File

@@ -73,11 +73,11 @@ When tests are run, this class will create a multiworld with a single player hav
generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld
that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be
overridden. For more information on what methods are available to your class, check the
[WorldTestBase definition](/test/bases.py#L104).
[WorldTestBase definition](/test/bases.py#L106).
#### Alternatives to WorldTestBase
Unit tests can also be created using [TestBase](/test/bases.py#L14) or
Unit tests can also be created using [TestBase](/test/bases.py#L16) or
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
testing portions of your code that can be tested without relying on a multiworld to be created first.

View File

@@ -27,8 +27,14 @@
# If you wish to deploy, uncomment the following line and set it to something not easily guessable.
# SECRET_KEY: "Your secret key here"
# TODO
#JOB_THRESHOLD: 2
# Slot limit to post a generation to Generator process pool instead of rolling directly in WebHost process
#JOB_THRESHOLD: 1
# After what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
#JOB_TIME: 600
# Memory limit for Generator processes in bytes, -1 for unlimited. Currently only works on Linux.
#GENERATOR_MEMORY_LIMIT: 4294967296
# waitress uses one thread for I/O, these are for processing of view that get sent
#WAITRESS_THREADS: 10

View File

@@ -222,8 +222,8 @@ could also be progress in a research tree, or even something more abstract like
Each location has a `name` and an `address` (hereafter referred to as an `id`), is placed in a Region, has access rules,
and has a classification. The name needs to be unique within each game and must not be numeric (must contain least 1
letter or symbol). The ID needs to be unique across all games, and is best kept in the same range as the item IDs.
Locations and items can share IDs, so typically a game's locations and items start at the same ID.
letter or symbol). The ID needs to be unique across all locations within the game.
Locations and items can share IDs, and locations can share IDs with other games' locations.
World-specific IDs must be in the range 1 to 2<sup>53</sup>-1; IDs ≤ 0 are global and reserved.
@@ -243,12 +243,15 @@ progression. Progression items will be assigned to locations with higher priorit
and satisfy progression balancing.
The name needs to be unique within each game, meaning if you need to create multiple items with the same name, they
will all have the same ID. Name must not be numeric (must contain at least 1 letter or symbol).
will all have the same ID. Name must not be numeric (must contain at least 1 letter or symbol).
The ID thus also needs to be unique across all items with different names within the game.
Items and locations can share IDs, and items can share IDs with other games' items.
Other classifications include:
* `filler`: a regular item or trash item
* `useful`: generally quite useful, but not required for anything logical. Cannot be placed on excluded locations
* `useful`: item that is especially useful. Cannot be placed on excluded or unreachable locations. When combined with
another flag like "progression", it means "an especially useful progression item".
* `trap`: negative impact on the player
* `skip_balancing`: denotes that an item should not be moved to an earlier sphere for the purpose of balancing (to be
combined with `progression`; see below)
@@ -288,8 +291,8 @@ like entrance randomization in logic.
Regions have a list called `exits`, containing `Entrance` objects representing transitions to other regions.
There must be one special region, "Menu", from which the logic unfolds. AP assumes that a player will always be able to
return to the "Menu" region by resetting the game ("Save and quit").
There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)),
from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit").
### Entrances
@@ -328,6 +331,9 @@ Even doing `state.can_reach_location` or `state.can_reach_entrance` is problemat
You can use `multiworld.register_indirect_condition(region, entrance)` to explicitly tell the generator that, when a given region becomes accessible, it is necessary to re-check a specific entrance.
You **must** use `multiworld.register_indirect_condition` if you perform this kind of `can_reach` from an entrance access rule, unless you have a **very** good technical understanding of the relevant code and can reason why it will never lead to problems in your case.
Alternatively, you can set [world.explicit_indirect_conditions = False](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L301-L304),
avoiding the need for indirect conditions at the expense of performance.
### Item Rules
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
@@ -463,7 +469,7 @@ The world has to provide the following things for generation:
* the properties mentioned above
* additions to the item pool
* additions to the regions list: at least one called "Menu"
* additions to the regions list: at least one named after the world class's origin_region_name ("Menu" by default)
* locations placed inside those regions
* a `def create_item(self, item: str) -> MyGameItem` to create any item on demand
* applying `self.multiworld.push_precollected` for world-defined start inventory
@@ -486,6 +492,9 @@ In addition, the following methods can be implemented and are called in this ord
after this step. Locations cannot be moved to different regions after this step.
* `set_rules(self)`
called to set access and item rules on locations and entrances.
* `connect_entrances(self)`
by the end of this step, all entrances must exist and be connected to their source and target regions.
Entrance randomization should be done here.
* `generate_basic(self)`
player-specific randomization that does not affect logic can be done here.
* `pre_fill(self)`, `fill_hook(self)` and `post_fill(self)`
@@ -516,7 +525,7 @@ def generate_early(self) -> None:
```python
def create_regions(self) -> None:
# Add regions to the multiworld. "Menu" is the required starting point.
# Add regions to the multiworld. One of them must use the origin_region_name as its name ("Menu" by default).
# Arguments to Region() are name, player, multiworld, and optionally hint_text
menu_region = Region("Menu", self.player, self.multiworld)
self.multiworld.regions.append(menu_region) # or use += [menu_region...]
@@ -553,17 +562,13 @@ from .items import is_progression # this is just a dummy
def create_item(self, item: str) -> MyGameItem:
# this is called when AP wants to create an item by name (for plando) or when you call it from your own code
classification = ItemClassification.progression if is_progression(item) else
ItemClassification.filler
return MyGameItem(item, classification, self.item_name_to_id[item],
self.player)
classification = ItemClassification.progression if is_progression(item) else ItemClassification.filler
return MyGameItem(item, classification, self.item_name_to_id[item], self.player)
def create_event(self, event: str) -> MyGameItem:
# while we are at it, we can also add a helper to create events
return MyGameItem(event, True, None, self.player)
return MyGameItem(event, ItemClassification.progression, None, self.player)
```
#### create_items
@@ -696,9 +701,92 @@ When importing a file that defines a class that inherits from `worlds.AutoWorld.
is automatically extended by the mixin's members. These members should be prefixed with the name of the implementing
world since the namespace is shared with all other logic mixins.
Some uses could be to add additional variables to the state object, or to have a custom state machine that gets modified
with the state.
Please do this with caution and only when necessary.
LogicMixin is handy when your logic is more complex than one-to-one location-item relationships.
A game in which "The red key opens the red door" can just express this relationship through a one-line access rule.
But now, consider a game with a heavy focus on combat, where the main logical consideration is which enemies you can
defeat with your current items.
There could be dozens of weapons, armor pieces, or consumables that each improve your ability to defeat
specific enemies to varying degrees. It would be useful to be able to keep track of "defeatable enemies" as a state variable,
and have this variable be recalculated as necessary based on newly collected/removed items.
This is the capability of LogicMixin: Adding custom variables to state that get recalculated as necessary.
In general, a LogicMixin class should have at least one mutable variable that is tracking some custom state per player,
as well as `init_mixin` and `copy_mixin` functions so that this variable gets initialized and copied correctly when
`CollectionState()` and `CollectionState.copy()` are called respectively.
```python
from BaseClasses import CollectionState, MultiWorld
from worlds.AutoWorld import LogicMixin
class MyGameState(LogicMixin):
mygame_defeatable_enemies: Dict[int, Set[str]] # per player
def init_mixin(self, multiworld: MultiWorld) -> None:
# Initialize per player with the corresponding "nothing" value, such as 0 or an empty set.
# You can also use something like Collections.defaultdict
self.mygame_defeatable_enemies = {
player: set() for player in multiworld.get_game_players("My Game")
}
def copy_mixin(self, new_state: CollectionState) -> CollectionState:
# Be careful to make a "deep enough" copy here!
new_state.mygame_defeatable_enemies = {
player: enemies.copy() for player, enemies in self.mygame_defeatable_enemies.items()
}
```
After doing this, you can now access `state.mygame_defeatable_enemies[player]` from your access rules.
Usually, doing this coincides with an override of `World.collect` and `World.remove`, where the custom state variable
gets recalculated when a relevant item is collected or removed.
```python
# __init__.py
def collect(self, state: CollectionState, item: Item) -> bool:
change = super().collect(state, item)
if change and item in COMBAT_ITEMS:
state.mygame_defeatable_enemies[self.player] |= get_newly_unlocked_enemies(state)
return change
def remove(self, state: CollectionState, item: Item) -> bool:
change = super().remove(state, item)
if change and item in COMBAT_ITEMS:
state.mygame_defeatable_enemies[self.player] -= get_newly_locked_enemies(state)
return change
```
Using LogicMixin can greatly slow down your code if you don't use it intelligently. This is because `collect`
and `remove` are called very frequently during fill. If your `collect` & `remove` cause a heavy calculation
every time, your code might end up being *slower* than just doing calculations in your access rules.
One way to optimise recalculations is to make use of the fact that `collect` should only unlock things,
and `remove` should only lock things.
In our example, we have two different functions: `get_newly_unlocked_enemies` and `get_newly_locked_enemies`.
`get_newly_unlocked_enemies` should only consider enemies that are *not already in the set*
and check whether they were **unlocked**.
`get_newly_locked_enemies` should only consider enemies that are *already in the set*
and check whether they **became locked**.
Another impactful way to optimise LogicMixin is to use caching.
Your custom state variables don't actually need to be recalculated on every `collect` / `remove`, because there are
often multiple calls to `collect` / `remove` between access rule calls. Thus, it would be much more efficient to hold
off on recaculating until the an actual access rule call happens.
A common way to realize this is to define a `mygame_state_is_stale` variable that is set to True in `collect`, `remove`,
and `init_mixin`. The calls to the actual recalculating functions are then moved to the start of the relevant
access rules like this:
```python
def can_defeat_enemy(state: CollectionState, player: int, enemy: str) -> bool:
if state.mygame_state_is_stale[player]:
state.mygame_defeatable_enemies[player] = recalculate_defeatable_enemies(state)
state.mygame_state_is_stale[player] = False
return enemy in state.mygame_defeatable_enemies[player]
```
Only use LogicMixin if necessary. There are often other ways to achieve what it does, like making clever use of
`state.prog_items`, using event items, pseudo-regions, etc.
#### pre_fill
@@ -748,14 +836,16 @@ def generate_output(self, output_directory: str) -> None:
### Slot Data
If the game client needs to know information about the generated seed, a preferred method of transferring the data
is through the slot data. This is filled with the `fill_slot_data` method of your world by returning
a `dict` with `str` keys that can be serialized with json.
But, to not waste resources, it should be limited to data that is absolutely necessary. Slot data is sent to your client
once it has successfully [connected](network%20protocol.md#connected).
If a client or tracker needs to know information about the generated seed, a preferred method of transferring the data
is through the slot data. This is filled with the `fill_slot_data` method of your world by returning a `dict` with
`str` keys that can be serialized with json. However, to not waste resources, it should be limited to data that is
absolutely necessary. Slot data is sent to your client once it has successfully
[connected](network%20protocol.md#connected).
If you need to know information about locations in your world, instead of propagating the slot data, it is preferable
to use [LocationScouts](network%20protocol.md#locationscouts), since that data already exists on the server. The most
common usage of slot data is sending option results that the client needs to be aware of.
to use [LocationScouts](network%20protocol.md#locationscouts), since that data already exists on the server. Adding
item/location pairs is unnecessary since the AP server already retains and freely gives that information to clients
that request it. The most common usage of slot data is sending option results that the client needs to be aware of.
```python
def fill_slot_data(self) -> Dict[str, Any]:

454
entrance_rando.py Normal file
View File

@@ -0,0 +1,454 @@
import itertools
import logging
import random
import time
from collections import deque
from collections.abc import Callable, Iterable
from BaseClasses import CollectionState, Entrance, Region, EntranceType
from Options import Accessibility
from worlds.AutoWorld import World
class EntranceRandomizationError(RuntimeError):
pass
class EntranceLookup:
class GroupLookup:
_lookup: dict[int, list[Entrance]]
def __init__(self):
self._lookup = {}
def __len__(self):
return sum(map(len, self._lookup.values()))
def __bool__(self):
return bool(self._lookup)
def __getitem__(self, item: int) -> list[Entrance]:
return self._lookup.get(item, [])
def __iter__(self):
return itertools.chain.from_iterable(self._lookup.values())
def __repr__(self):
return str(self._lookup)
def add(self, entrance: Entrance) -> None:
self._lookup.setdefault(entrance.randomization_group, []).append(entrance)
def remove(self, entrance: Entrance) -> None:
group = self._lookup[entrance.randomization_group]
group.remove(entrance)
if not group:
del self._lookup[entrance.randomization_group]
dead_ends: GroupLookup
others: GroupLookup
_random: random.Random
_expands_graph_cache: dict[Entrance, bool]
_coupled: bool
def __init__(self, rng: random.Random, coupled: bool):
self.dead_ends = EntranceLookup.GroupLookup()
self.others = EntranceLookup.GroupLookup()
self._random = rng
self._expands_graph_cache = {}
self._coupled = coupled
def _can_expand_graph(self, entrance: Entrance) -> bool:
"""
Checks whether an entrance is able to expand the region graph, either by
providing access to randomizable exits or by granting access to items or
regions used in logic conditions.
:param entrance: A randomizable (no parent) region entrance
"""
# we've seen this, return cached result
if entrance in self._expands_graph_cache:
return self._expands_graph_cache[entrance]
visited = set()
q: deque[Region] = deque()
q.append(entrance.connected_region)
while q:
region = q.popleft()
visited.add(region)
# check if the region itself is progression
if region in region.multiworld.indirect_connections:
self._expands_graph_cache[entrance] = True
return True
# check if any placed locations are progression
for loc in region.locations:
if loc.advancement:
self._expands_graph_cache[entrance] = True
return True
# check if there is a randomized exit out (expands the graph directly) or else search any connected
# regions to see if they are/have progression
for exit_ in region.exits:
# randomizable exits which are not reverse of the incoming entrance.
# uncoupled mode is an exception because in this case going back in the door you just came in could
# actually lead somewhere new
if not exit_.connected_region and (not self._coupled or exit_.name != entrance.name):
self._expands_graph_cache[entrance] = True
return True
elif exit_.connected_region and exit_.connected_region not in visited:
q.append(exit_.connected_region)
self._expands_graph_cache[entrance] = False
return False
def add(self, entrance: Entrance) -> None:
lookup = self.others if self._can_expand_graph(entrance) else self.dead_ends
lookup.add(entrance)
def remove(self, entrance: Entrance) -> None:
lookup = self.others if self._can_expand_graph(entrance) else self.dead_ends
lookup.remove(entrance)
def get_targets(
self,
groups: Iterable[int],
dead_end: bool,
preserve_group_order: bool
) -> Iterable[Entrance]:
lookup = self.dead_ends if dead_end else self.others
if preserve_group_order:
for group in groups:
self._random.shuffle(lookup[group])
ret = [entrance for group in groups for entrance in lookup[group]]
else:
ret = [entrance for group in groups for entrance in lookup[group]]
self._random.shuffle(ret)
return ret
def __len__(self):
return len(self.dead_ends) + len(self.others)
class ERPlacementState:
"""The state of an ongoing or completed entrance randomization"""
placements: list[Entrance]
"""The list of randomized Entrance objects which have been connected successfully"""
pairings: list[tuple[str, str]]
"""A list of pairings of connected entrance names, of the form (source_exit, target_entrance)"""
world: World
"""The world which is having its entrances randomized"""
collection_state: CollectionState
"""The CollectionState backing the entrance randomization logic"""
coupled: bool
"""Whether entrance randomization is operating in coupled mode"""
def __init__(self, world: World, coupled: bool):
self.placements = []
self.pairings = []
self.world = world
self.coupled = coupled
self.collection_state = world.multiworld.get_all_state(False, True)
@property
def placed_regions(self) -> set[Region]:
return self.collection_state.reachable_regions[self.world.player]
def find_placeable_exits(self, check_validity: bool, usable_exits: list[Entrance]) -> list[Entrance]:
if check_validity:
blocked_connections = self.collection_state.blocked_connections[self.world.player]
placeable_randomized_exits = [ex for ex in usable_exits
if not ex.connected_region
and ex in blocked_connections
and ex.is_valid_source_transition(self)]
else:
# this is on a beaten minimal attempt, so any exit anywhere is fair game
placeable_randomized_exits = [ex for ex in usable_exits if not ex.connected_region]
self.world.random.shuffle(placeable_randomized_exits)
return placeable_randomized_exits
def _connect_one_way(self, source_exit: Entrance, target_entrance: Entrance) -> None:
target_region = target_entrance.connected_region
target_region.entrances.remove(target_entrance)
source_exit.connect(target_region)
self.collection_state.stale[self.world.player] = True
self.placements.append(source_exit)
self.pairings.append((source_exit.name, target_entrance.name))
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance,
usable_exits: set[Entrance]) -> bool:
copied_state = self.collection_state.copy()
# simulated connection. A real connection is unsafe because the region graph is shallow-copied and would
# propagate back to the real multiworld.
copied_state.reachable_regions[self.world.player].add(target_entrance.connected_region)
copied_state.blocked_connections[self.world.player].remove(source_exit)
copied_state.blocked_connections[self.world.player].update(target_entrance.connected_region.exits)
copied_state.update_reachable_regions(self.world.player)
copied_state.sweep_for_advancements()
# test that at there are newly reachable randomized exits that are ACTUALLY reachable
available_randomized_exits = copied_state.blocked_connections[self.world.player]
for _exit in available_randomized_exits:
if _exit.connected_region:
continue
# ignore the source exit, and, if coupled, the reverse exit. They're not actually new
if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name):
continue
# make sure we are only paying attention to usable exits
if _exit not in usable_exits:
continue
# technically this should be is_valid_source_transition, but that may rely on side effects from
# on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would
# not want them to persist). can_reach is a close enough approximation most of the time.
if _exit.can_reach(copied_state):
return True
return False
def connect(
self,
source_exit: Entrance,
target_entrance: Entrance
) -> tuple[list[Entrance], list[Entrance]]:
"""
Connects a source exit to a target entrance in the graph, accounting for coupling
:returns: The newly placed exits and the dummy entrance(s) which were removed from the graph
"""
source_region = source_exit.parent_region
target_region = target_entrance.connected_region
self._connect_one_way(source_exit, target_entrance)
# if we're doing coupled randomization place the reverse transition as well.
if self.coupled and source_exit.randomization_type == EntranceType.TWO_WAY:
for reverse_entrance in source_region.entrances:
if reverse_entrance.name == source_exit.name:
if reverse_entrance.parent_region:
raise EntranceRandomizationError(
f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} "
f"because the reverse entrance is already parented to "
f"{reverse_entrance.parent_region.name}.")
break
else:
raise EntranceRandomizationError(f"Two way exit {source_exit.name} had no corresponding entrance in "
f"{source_exit.parent_region.name}")
for reverse_exit in target_region.exits:
if reverse_exit.name == target_entrance.name:
if reverse_exit.connected_region:
raise EntranceRandomizationError(
f"Could not perform coupling on {source_exit.name} -> {target_entrance.name} "
f"because the reverse exit is already connected to "
f"{reverse_exit.connected_region.name}.")
break
else:
raise EntranceRandomizationError(f"Two way entrance {target_entrance.name} had no corresponding exit "
f"in {target_region.name}.")
self._connect_one_way(reverse_exit, reverse_entrance)
return [source_exit, reverse_exit], [target_entrance, reverse_entrance]
return [source_exit], [target_entrance]
def bake_target_group_lookup(world: World, get_target_groups: Callable[[int], list[int]]) \
-> dict[int, list[int]]:
"""
Applies a transformation to all known entrance groups on randomizable exists to build a group lookup table.
:param world: Your World instance
:param get_target_groups: Function to call that returns the groups that a specific group type is allowed to
connect to
"""
unique_groups = { entrance.randomization_group for entrance in world.multiworld.get_entrances(world.player)
if entrance.parent_region and not entrance.connected_region }
return { group: get_target_groups(group) for group in unique_groups }
def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None) -> None:
"""
Given an entrance in a "vanilla" region graph, splits that entrance to prepare it for randomization
in randomize_entrances. This should be done after setting the type and group of the entrance.
:param entrance: The entrance which will be disconnected in preparation for randomization.
:param target_group: The group to assign to the created ER target. If not specified, the group from
the original entrance will be copied.
"""
child_region = entrance.connected_region
parent_region = entrance.parent_region
# disconnect the edge
child_region.entrances.remove(entrance)
entrance.connected_region = None
# create the needed ER target
if entrance.randomization_type == EntranceType.TWO_WAY:
# for 2-ways, create a target in the parent region with a matching name to support coupling.
# targets in the child region will be created when the other direction edge is disconnected
target = parent_region.create_er_target(entrance.name)
else:
# for 1-ways, the child region needs a target and coupling/naming is not a concern
target = child_region.create_er_target(child_region.name)
target.randomization_type = entrance.randomization_type
target.randomization_group = target_group or entrance.randomization_group
def randomize_entrances(
world: World,
coupled: bool,
target_group_lookup: dict[int, list[int]],
preserve_group_order: bool = False,
er_targets: list[Entrance] | None = None,
exits: list[Entrance] | None = None,
on_connect: Callable[[ERPlacementState, list[Entrance]], None] | None = None
) -> ERPlacementState:
"""
Randomizes Entrances for a single world in the multiworld.
:param world: Your World instance
:param coupled: Whether connected entrances should be coupled to go in both directions
:param target_group_lookup: Map from each group to a list of the groups that it can be connect to. Every group
used on an exit must be provided and must map to at least one other group. The default
group is 0.
:param preserve_group_order: Whether the order of groupings should be preserved for the returned target_groups
:param er_targets: The list of ER targets (Entrance objects with no parent region) to use for randomization.
Remember to be deterministic! If not provided, automatically discovers all valid targets
in your world.
:param exits: The list of exits (Entrance objects with no target region) to use for randomization.
Remember to be deterministic! If not provided, automatically discovers all valid exits in your world.
:param on_connect: A callback function which allows specifying side effects after a placement is completed
successfully and the underlying collection state has been updated.
"""
if not world.explicit_indirect_conditions:
raise EntranceRandomizationError("Entrance randomization requires explicit indirect conditions in order "
+ "to correctly analyze whether dead end regions can be required in logic.")
start_time = time.perf_counter()
er_state = ERPlacementState(world, coupled)
entrance_lookup = EntranceLookup(world.random, coupled)
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
perform_validity_check = True
if not er_targets:
er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player)
for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name)
if not exits:
exits = sorted([ex for region in world.multiworld.get_regions(world.player)
for ex in region.exits if not ex.connected_region], key=lambda x: x.name)
if len(er_targets) != len(exits):
raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of "
f"entrances ({len(er_targets)}) and exits ({len(exits)}.")
# used when membership checks are needed on the exit list, e.g. speculative sweep
exits_set = set(exits)
for entrance in er_targets:
entrance_lookup.add(entrance)
# place the menu region and connected start region(s)
er_state.collection_state.update_reachable_regions(world.player)
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
# remove the placed targets from consideration
for entrance in removed_entrances:
entrance_lookup.remove(entrance)
# propagate new connections
er_state.collection_state.update_reachable_regions(world.player)
er_state.collection_state.sweep_for_advancements()
if on_connect:
on_connect(er_state, placed_exits)
def find_pairing(dead_end: bool, require_new_exits: bool) -> bool:
nonlocal perform_validity_check
placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
for source_exit in placeable_exits:
target_groups = target_group_lookup[source_exit.randomization_group]
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
# when requiring new exits, ideally we would like to make it so that every placement increases
# (or keeps the same number of) reachable exits. The goal is to continue to expand the search space
# so that we do not crash. In the interest of performance and bias reduction, generally, just checking
# that we are going to a new region is a good approximation. however, we should take extra care on the
# very last exit and check whatever exits we open up are functionally accessible.
# this requirement can be ignored on a beaten minimal, islands are no issue there.
exit_requirement_satisfied = (not perform_validity_check or not require_new_exits
or target_entrance.connected_region not in er_state.placed_regions)
needs_speculative_sweep = (not dead_end and require_new_exits and perform_validity_check
and len(placeable_exits) == 1)
if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state):
if (needs_speculative_sweep
and not er_state.test_speculative_connection(source_exit, target_entrance, exits_set)):
continue
do_placement(source_exit, target_entrance)
return True
else:
# no source exits had any valid target so this stage is deadlocked. retries may be implemented if early
# deadlocking is a frequent issue.
lookup = entrance_lookup.dead_ends if dead_end else entrance_lookup.others
# if we're in a stage where we're trying to get to new regions, we could also enter this
# branch in a success state (when all regions of the preferred type have been placed, but there are still
# additional unplaced entrances into those regions)
if require_new_exits:
if all(e.connected_region in er_state.placed_regions for e in lookup):
return False
# if we're on minimal accessibility and can guarantee the game is beatable,
# we can prevent a failure by bypassing future validity checks. this check may be
# expensive; fortunately we only have to do it once
if perform_validity_check and world.options.accessibility == Accessibility.option_minimal \
and world.multiworld.has_beaten_game(er_state.collection_state, world.player):
# ensure that we have enough locations to place our progression
accessible_location_count = 0
prog_item_count = len([item for item in world.multiworld.itempool if item.advancement and item.player == world.player])
# short-circuit location checking in this case
if prog_item_count == 0:
return True
for region in er_state.placed_regions:
for loc in region.locations:
if not loc.item and loc.can_reach(er_state.collection_state):
# don't count locations with preplaced items
accessible_location_count += 1
if accessible_location_count >= prog_item_count:
perform_validity_check = False
# pretend that this was successful to retry the current stage
return True
unplaced_entrances = [entrance for region in world.multiworld.get_regions(world.player)
for entrance in region.entrances if not entrance.parent_region]
unplaced_exits = [exit_ for region in world.multiworld.get_regions(world.player)
for exit_ in region.exits if not exit_.connected_region]
entrance_kind = "dead ends" if dead_end else "non-dead ends"
region_access_requirement = "requires" if require_new_exits else "does not require"
raise EntranceRandomizationError(
f"None of the available entrances are valid targets for the available exits.\n"
f"Randomization stage is placing {entrance_kind} and {region_access_requirement} "
f"new region/exit access by default\n"
f"Placeable entrances: {lookup}\n"
f"Placeable exits: {placeable_exits}\n"
f"All unplaced entrances: {unplaced_entrances}\n"
f"All unplaced exits: {unplaced_exits}")
# stage 1 - try to place all the non-dead-end entrances
while entrance_lookup.others:
if not find_pairing(dead_end=False, require_new_exits=True):
break
# stage 2 - try to place all the dead-end entrances
while entrance_lookup.dead_ends:
if not find_pairing(dead_end=True, require_new_exits=True):
break
# stage 3 - all the regions should be placed at this point. We now need to connect dangling edges
# stage 3a - get the rest of the dead ends (e.g. second entrances into already-visited regions)
# doing this before the non-dead-ends is important to ensure there are enough connections to
# go around
while entrance_lookup.dead_ends:
find_pairing(dead_end=True, require_new_exits=False)
# stage 3b - tie all the other loose ends connecting visited regions to each other
while entrance_lookup.others:
find_pairing(dead_end=False, require_new_exits=False)
running_time = time.perf_counter() - start_time
if running_time > 1.0:
logging.info(f"Took {running_time:.4f} seconds during entrance randomization for player {world.player},"
f"named {world.multiworld.player_name[world.player]}")
return er_state

View File

@@ -186,6 +186,11 @@ Root: HKCR; Subkey: "{#MyAppName}cv64patch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}cv64patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cv64patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apcvcotm"; ValueData: "{#MyAppName}cvcotmpatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cvcotmpatch"; ValueData: "Archipelago Castlevania Circle of the Moon Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cvcotmpatch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}cvcotmpatch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmm2"; ValueData: "{#MyAppName}mm2patch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm2patch"; ValueData: "Archipelago Mega Man 2 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mm2patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";

215
kvui.py
View File

@@ -3,6 +3,8 @@ import logging
import sys
import typing
import re
import io
import pkgutil
from collections import deque
assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility"
@@ -12,10 +14,7 @@ if sys.platform == "win32":
# kivy 2.2.0 introduced DPI awareness on Windows, but it makes the UI enter an infinitely recursive re-layout
# by setting the application to not DPI Aware, Windows handles scaling the entire window on its own, ignoring kivy's
try:
ctypes.windll.shcore.SetProcessDpiAwareness(0)
except FileNotFoundError: # shcore may not be found on <= Windows 7
pass # TODO: remove silent except when Python 3.8 is phased out.
ctypes.windll.shcore.SetProcessDpiAwareness(0)
os.environ["KIVY_NO_CONSOLELOG"] = "1"
os.environ["KIVY_NO_FILELOG"] = "1"
@@ -27,6 +26,10 @@ import Utils
if Utils.is_frozen():
os.environ["KIVY_DATA_DIR"] = Utils.local_path("data")
import platformdirs
os.environ["KIVY_HOME"] = os.path.join(platformdirs.user_config_dir("Archipelago", False), "kivy")
os.makedirs(os.environ["KIVY_HOME"], exist_ok=True)
from kivy.config import Config
Config.set("input", "mouse", "mouse,disable_multitouch")
@@ -37,10 +40,11 @@ from kivy.app import App
from kivy.core.window import Window
from kivy.core.clipboard import Clipboard
from kivy.core.text.markup import MarkupLabel
from kivy.core.image import ImageLoader, ImageLoaderBase, ImageData
from kivy.base import ExceptionHandler, ExceptionManager
from kivy.clock import Clock
from kivy.factory import Factory
from kivy.properties import BooleanProperty, ObjectProperty
from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty
from kivy.metrics import dp
from kivy.effects.scroll import ScrollEffect
from kivy.uix.widget import Widget
@@ -55,6 +59,7 @@ from kivy.uix.boxlayout import BoxLayout
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.label import Label
from kivy.uix.progressbar import ProgressBar
from kivy.uix.dropdown import DropDown
from kivy.utils import escape_markup
from kivy.lang import Builder
from kivy.uix.recycleview.views import RecycleDataViewBehavior
@@ -63,10 +68,12 @@ from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
from kivy.animation import Animation
from kivy.uix.popup import Popup
from kivy.uix.dropdown import DropDown
from kivy.uix.image import AsyncImage
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType
from NetUtils import JSONtoTextParser, JSONMessagePart, SlotType, HintStatus
from Utils import async_start, get_input_text_from_response
if typing.TYPE_CHECKING:
@@ -303,11 +310,55 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
""" Respond to the selection of items in the view. """
self.selected = is_selected
class AutocompleteHintInput(TextInput):
min_chars = NumericProperty(3)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.dropdown = DropDown()
self.dropdown.bind(on_select=lambda instance, x: setattr(self, 'text', x))
self.bind(on_text_validate=self.on_message)
def on_message(self, instance):
App.get_running_app().commandprocessor("!hint "+instance.text)
def on_text(self, instance, value):
if len(value) >= self.min_chars:
self.dropdown.clear_widgets()
ctx: context_type = App.get_running_app().ctx
if not ctx.game:
return
item_names = ctx.item_names._game_store[ctx.game].values()
def on_press(button: Button):
split_text = MarkupLabel(text=button.text).markup
return self.dropdown.select("".join(text_frag for text_frag in split_text
if not text_frag.startswith("[")))
lowered = value.lower()
for item_name in item_names:
try:
index = item_name.lower().index(lowered)
except ValueError:
pass # substring not found
else:
text = escape_markup(item_name)
text = text[:index] + "[b]" + text[index:index+len(value)]+"[/b]"+text[index+len(value):]
btn = Button(text=text, size_hint_y=None, height=dp(30), markup=True)
btn.bind(on_release=on_press)
self.dropdown.add_widget(btn)
if not self.dropdown.attach_to:
self.dropdown.open(self)
else:
self.dropdown.dismiss()
class HintLabel(RecycleDataViewBehavior, BoxLayout):
selected = BooleanProperty(False)
striped = BooleanProperty(False)
index = None
dropdown: DropDown
def __init__(self):
super(HintLabel, self).__init__()
@@ -316,10 +367,32 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
self.finding_text = ""
self.location_text = ""
self.entrance_text = ""
self.found_text = ""
self.status_text = ""
self.hint = {}
for child in self.children:
child.bind(texture_size=self.set_height)
ctx = App.get_running_app().ctx
self.dropdown = DropDown()
def set_value(button):
self.dropdown.select(button.status)
def select(instance, data):
ctx.update_hint(self.hint["location"],
self.hint["finding_player"],
data)
for status in (HintStatus.HINT_NO_PRIORITY, HintStatus.HINT_PRIORITY, HintStatus.HINT_AVOID):
name = status_names[status]
status_button = Button(text=name, size_hint_y=None, height=dp(50))
status_button.status = status
status_button.bind(on_release=set_value)
self.dropdown.add_widget(status_button)
self.dropdown.bind(on_select=select)
def set_height(self, instance, value):
self.height = max([child.texture_size[1] for child in self.children])
@@ -331,7 +404,8 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
self.finding_text = data["finding"]["text"]
self.location_text = data["location"]["text"]
self.entrance_text = data["entrance"]["text"]
self.found_text = data["found"]["text"]
self.status_text = data["status"]["text"]
self.hint = data["status"]["hint"]
self.height = self.minimum_height
return super(HintLabel, self).refresh_view_attrs(rv, index, data)
@@ -341,13 +415,21 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
return True
if self.index: # skip header
if self.collide_point(*touch.pos):
if self.selected:
status_label = self.ids["status"]
if status_label.collide_point(*touch.pos):
if self.hint["status"] == HintStatus.HINT_FOUND:
return
ctx = App.get_running_app().ctx
if ctx.slot_concerns_self(self.hint["receiving_player"]): # If this player owns this hint
# open a dropdown
self.dropdown.open(self.ids["status"])
elif self.selected:
self.parent.clear_selection()
else:
text = "".join((self.receiving_text, "\'s ", self.item_text, " is at ", self.location_text, " in ",
self.finding_text, "\'s World", (" at " + self.entrance_text)
if self.entrance_text != "Vanilla"
else "", ". (", self.found_text.lower(), ")"))
else "", ". (", self.status_text.lower(), ")"))
temp = MarkupLabel(text).markup
text = "".join(
part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
@@ -361,18 +443,19 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
for child in self.children:
if child.collide_point(*touch.pos):
key = child.sort_key
parent.hint_sorter = lambda element: remove_between_brackets.sub("", element[key]["text"]).lower()
if key == "status":
parent.hint_sorter = lambda element: status_sort_weights[element["status"]["hint"]["status"]]
else:
parent.hint_sorter = lambda element: (
remove_between_brackets.sub("", element[key]["text"]).lower()
)
if key == parent.sort_key:
# second click reverses order
parent.reversed = not parent.reversed
else:
parent.sort_key = key
parent.reversed = False
break
else:
logging.warning("Did not find clicked header for sorting.")
App.get_running_app().update_hints()
App.get_running_app().update_hints()
def apply_selection(self, rv, index, is_selected):
""" Respond to the selection of items in the view. """
@@ -539,8 +622,10 @@ class GameManager(App):
# show Archipelago tab if other logging is present
self.tabs.add_widget(panel)
hint_panel = self.add_client_tab("Hints", HintLog(self.json_to_kivy_parser))
hint_panel = self.add_client_tab("Hints", HintLayout())
self.hint_log = HintLog(self.json_to_kivy_parser)
self.log_panels["Hints"] = hint_panel.content
hint_panel.content.add_widget(self.hint_log)
if len(self.logging_pairs) == 1:
self.tabs.default_tab_text = "Archipelago"
@@ -666,8 +751,8 @@ class GameManager(App):
self.energy_link_label.text = f"EL: {Utils.format_SI_prefix(self.ctx.current_energy_link_value)}J"
def update_hints(self):
hints = self.ctx.stored_data[f"_read_hints_{self.ctx.team}_{self.ctx.slot}"]
self.log_panels["Hints"].refresh_hints(hints)
hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", [])
self.hint_log.refresh_hints(hints)
# default F1 keybind, opens a settings menu, that seems to break the layout engine once closed
def open_settings(self, *largs):
@@ -722,6 +807,40 @@ class UILog(RecycleView):
element.height = element.texture_size[1]
class HintLayout(BoxLayout):
orientation = "vertical"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
boxlayout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30))
boxlayout.add_widget(Label(text="New Hint:", size_hint_x=None, size_hint_y=None, height=dp(30)))
boxlayout.add_widget(AutocompleteHintInput())
self.add_widget(boxlayout)
status_names: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "Found",
HintStatus.HINT_UNSPECIFIED: "Unspecified",
HintStatus.HINT_NO_PRIORITY: "No Priority",
HintStatus.HINT_AVOID: "Avoid",
HintStatus.HINT_PRIORITY: "Priority",
}
status_colors: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "green",
HintStatus.HINT_UNSPECIFIED: "white",
HintStatus.HINT_NO_PRIORITY: "cyan",
HintStatus.HINT_AVOID: "salmon",
HintStatus.HINT_PRIORITY: "plum",
}
status_sort_weights: dict[HintStatus, int] = {
HintStatus.HINT_FOUND: 0,
HintStatus.HINT_UNSPECIFIED: 1,
HintStatus.HINT_NO_PRIORITY: 2,
HintStatus.HINT_AVOID: 3,
HintStatus.HINT_PRIORITY: 4,
}
class HintLog(RecycleView):
header = {
"receiving": {"text": "[u]Receiving Player[/u]"},
@@ -729,12 +848,13 @@ class HintLog(RecycleView):
"finding": {"text": "[u]Finding Player[/u]"},
"location": {"text": "[u]Location[/u]"},
"entrance": {"text": "[u]Entrance[/u]"},
"found": {"text": "[u]Status[/u]"},
"status": {"text": "[u]Status[/u]",
"hint": {"receiving_player": -1, "location": -1, "finding_player": -1, "status": ""}},
"striped": True,
}
sort_key: str = ""
reversed: bool = False
reversed: bool = True
def __init__(self, parser):
super(HintLog, self).__init__()
@@ -742,8 +862,18 @@ class HintLog(RecycleView):
self.parser = parser
def refresh_hints(self, hints):
if not hints: # Fix the scrolling looking visually wrong in some edge cases
self.scroll_y = 1.0
data = []
ctx = App.get_running_app().ctx
for hint in hints:
if not hint.get("status"): # Allows connecting to old servers
hint["status"] = HintStatus.HINT_FOUND if hint["found"] else HintStatus.HINT_UNSPECIFIED
hint_status_node = self.parser.handle_node({"type": "color",
"color": status_colors.get(hint["status"], "red"),
"text": status_names.get(hint["status"], "Unknown")})
if hint["status"] != HintStatus.HINT_FOUND and ctx.slot_concerns_self(hint["receiving_player"]):
hint_status_node = f"[u]{hint_status_node}[/u]"
data.append({
"receiving": {"text": self.parser.handle_node({"type": "player_id", "text": hint["receiving_player"]})},
"item": {"text": self.parser.handle_node({
@@ -761,9 +891,10 @@ class HintLog(RecycleView):
"entrance": {"text": self.parser.handle_node({"type": "color" if hint["entrance"] else "text",
"color": "blue", "text": hint["entrance"]
if hint["entrance"] else "Vanilla"})},
"found": {
"text": self.parser.handle_node({"type": "color", "color": "green" if hint["found"] else "red",
"text": "Found" if hint["found"] else "Not Found"})},
"status": {
"text": hint_status_node,
"hint": hint,
},
})
data.sort(key=self.hint_sorter, reverse=self.reversed)
@@ -774,7 +905,7 @@ class HintLog(RecycleView):
@staticmethod
def hint_sorter(element: dict) -> str:
return ""
return element["status"]["hint"]["status"] # By status by default
def fix_heights(self):
"""Workaround fix for divergent texture and layout heights"""
@@ -783,6 +914,40 @@ class HintLog(RecycleView):
element.height = max_height
class ApAsyncImage(AsyncImage):
def is_uri(self, filename: str) -> bool:
if filename.startswith("ap:"):
return True
else:
return super().is_uri(filename)
class ImageLoaderPkgutil(ImageLoaderBase):
def load(self, filename: str) -> typing.List[ImageData]:
# take off the "ap:" prefix
module, path = filename[3:].split("/", 1)
data = pkgutil.get_data(module, path)
return self._bytes_to_data(data)
def _bytes_to_data(self, data: typing.Union[bytes, bytearray]) -> typing.List[ImageData]:
loader = next(loader for loader in ImageLoader.loaders if loader.can_load_memory())
return loader.load(loader, io.BytesIO(data))
# grab the default loader method so we can override it but use it as a fallback
_original_image_loader_load = ImageLoader.load
def load_override(filename: str, default_load=_original_image_loader_load, **kwargs):
if filename.startswith("ap:"):
return ImageLoaderPkgutil(filename)
else:
return default_load(filename, **kwargs)
ImageLoader.load = load_override
class E(ExceptionHandler):
logger = logging.getLogger("Client")

View File

@@ -2,3 +2,6 @@
python_files = test_*.py Test*.py # TODO: remove Test* once all worlds have been ported
python_classes = Test
python_functions = test
testpaths =
test
worlds

View File

@@ -7,7 +7,7 @@ schema>=0.7.7
kivy>=2.3.0
bsdiff4>=1.2.4
platformdirs>=4.2.2
certifi>=2024.8.30
certifi>=2024.12.14
cython>=3.0.11
cymem>=2.0.8
orjson>=3.10.7

View File

@@ -7,6 +7,7 @@ import os
import os.path
import shutil
import sys
import types
import typing
import warnings
from enum import IntEnum
@@ -108,7 +109,7 @@ class Group:
def get_type_hints(cls) -> Dict[str, Any]:
"""Returns resolved type hints for the class"""
if cls._type_cache is None:
if not isinstance(next(iter(cls.__annotations__.values())), str):
if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str):
# non-str: assume already resolved
cls._type_cache = cls.__annotations__
else:
@@ -162,8 +163,13 @@ class Group:
else:
# assign value, try to upcast to type hint
annotation = self.get_type_hints().get(k, None)
candidates = [] if annotation is None else \
typing.get_args(annotation) if typing.get_origin(annotation) is Union else [annotation]
candidates = (
[] if annotation is None else (
typing.get_args(annotation)
if typing.get_origin(annotation) in (Union, types.UnionType)
else [annotation]
)
)
none_type = type(None)
for cls in candidates:
assert isinstance(cls, type), f"{self.__class__.__name__}.{k}: type {cls} not supported in settings"
@@ -264,15 +270,20 @@ class Group:
# fetch class to avoid going through getattr
cls = self.__class__
type_hints = cls.get_type_hints()
entries = [e for e in self]
if not entries:
# write empty dict for empty Group with no instance values
cls._dump_value({}, f, indent=" " * level)
# validate group
for name in cls.__annotations__.keys():
assert hasattr(cls, name), f"{cls}.{name} is missing a default value"
# dump ordered members
for name in self:
for name in entries:
attr = cast(object, getattr(self, name))
attr_cls = type_hints[name] if name in type_hints else attr.__class__
attr_cls_origin = typing.get_origin(attr_cls)
while attr_cls_origin is Union: # resolve to first type for doc string
# resolve to first type for doc string
while attr_cls_origin is Union or attr_cls_origin is types.UnionType:
attr_cls = typing.get_args(attr_cls)[0]
attr_cls_origin = typing.get_origin(attr_cls)
if attr_cls.__doc__ and attr_cls.__module__ != "builtins":
@@ -593,6 +604,7 @@ class ServerOptions(Group):
savefile: Optional[str] = None
disable_save: bool = False
loglevel: str = "info"
logtime: bool = False
server_password: Optional[ServerPassword] = None
disable_item_cheat: Union[DisableItemCheat, bool] = False
location_check_points: LocationCheckPoints = LocationCheckPoints(1)
@@ -671,6 +683,8 @@ class GeneratorOptions(Group):
race: Race = Race(0)
plando_options: PlandoOptions = PlandoOptions("bosses, connections, texts")
panic_method: PanicMethod = PanicMethod("swap")
loglevel: str = "info"
logtime: bool = False
class SNIOptions(Group):
@@ -778,7 +792,17 @@ class Settings(Group):
if location:
from Utils import parse_yaml
with open(location, encoding="utf-8-sig") as f:
options = parse_yaml(f.read())
from yaml.error import MarkedYAMLError
try:
options = parse_yaml(f.read())
except MarkedYAMLError as ex:
if ex.problem_mark:
f.seek(0)
lines = f.readlines()
problem_line = lines[ex.problem_mark.line]
error_line = " " * ex.problem_mark.column + "^"
raise Exception(f"{ex.context} {ex.problem}\n{problem_line}{error_line}")
raise ex
# TODO: detect if upgrade is required
# TODO: once we have a cache for _world_settings_name_cache, detect if any game section is missing
self.update(options or {})

View File

@@ -321,7 +321,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
f"{ex}\nPlease close all AP instances and delete manually.")
# regular cx build
self.buildtime = datetime.datetime.utcnow()
self.buildtime = datetime.datetime.now(datetime.timezone.utc)
super().run()
# manually copy built modules to lib folder. cx_Freeze does not know they exist.
@@ -634,7 +634,7 @@ cx_Freeze.setup(
"excludes": ["numpy", "Cython", "PySide2", "PIL",
"pandas", "zstandard"],
"zip_include_packages": ["*"],
"zip_exclude_packages": ["worlds", "sc2", "orjson"], # TODO: remove orjson here once we drop py3.8 support
"zip_exclude_packages": ["worlds", "sc2"],
"include_files": [], # broken in cx 6.14.0, we use more special sauce now
"include_msvcr": False,
"replace_paths": ["*."],

View File

@@ -18,7 +18,15 @@ def run_locations_benchmark():
class BenchmarkRunner:
gen_steps: typing.Tuple[str, ...] = (
"generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
"generate_early",
"create_regions",
"create_items",
"set_rules",
"connect_entrances",
"generate_basic",
"pre_fill",
)
rule_iterations: int = 100_000
if sys.version_info >= (3, 9):

View File

@@ -1,4 +1,4 @@
cmake_minimum_required(VERSION 3.5)
cmake_minimum_required(VERSION 3.16)
project(ap-cpp-tests)
enable_testing()
@@ -7,8 +7,8 @@ find_package(GTest REQUIRED)
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
add_definitions("/source-charset:utf-8")
set(CMAKE_CXX_FLAGS_DEBUG "/MTd")
set(CMAKE_CXX_FLAGS_RELEASE "/MT")
# set(CMAKE_CXX_FLAGS_DEBUG "/MDd") # this is the default
# set(CMAKE_CXX_FLAGS_RELEASE "/MD") # this is the default
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
# enable static analysis for gcc
add_compile_options(-fanalyzer -Werror)

View File

@@ -5,7 +5,15 @@ from BaseClasses import CollectionState, Item, ItemClassification, Location, Mul
from worlds import network_data_package
from worlds.AutoWorld import World, call_all
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
gen_steps = (
"generate_early",
"create_regions",
"create_items",
"set_rules",
"connect_entrances",
"generate_basic",
"pre_fill",
)
def setup_solo_multiworld(

View File

@@ -0,0 +1,418 @@
import unittest
from enum import IntEnum
from BaseClasses import Region, EntranceType, MultiWorld, Entrance
from entrance_rando import disconnect_entrance_for_randomization, randomize_entrances, EntranceRandomizationError, \
ERPlacementState, EntranceLookup, bake_target_group_lookup
from Options import Accessibility
from test.general import generate_test_multiworld, generate_locations, generate_items
from worlds.generic.Rules import set_rule
class ERTestGroups(IntEnum):
LEFT = 1
RIGHT = 2
TOP = 3
BOTTOM = 4
directionally_matched_group_lookup = {
ERTestGroups.LEFT: [ERTestGroups.RIGHT],
ERTestGroups.RIGHT: [ERTestGroups.LEFT],
ERTestGroups.TOP: [ERTestGroups.BOTTOM],
ERTestGroups.BOTTOM: [ERTestGroups.TOP]
}
def generate_entrance_pair(region: Region, name_suffix: str, group: int):
lx = region.create_exit(region.name + name_suffix)
lx.randomization_group = group
lx.randomization_type = EntranceType.TWO_WAY
le = region.create_er_target(region.name + name_suffix)
le.randomization_group = group
le.randomization_type = EntranceType.TWO_WAY
def generate_disconnected_region_grid(multiworld: MultiWorld, grid_side_length: int, region_size: int = 0,
region_type: type[Region] = Region):
"""
Generates a grid-like region structure for ER testing, where menu is connected to the top-left region, and each
region "in vanilla" has 2 2-way exits going either down or to the right, until reaching the goal region in the
bottom right
"""
for row in range(grid_side_length):
for col in range(grid_side_length):
index = row * grid_side_length + col
name = f"region{index}"
region = region_type(name, 1, multiworld)
multiworld.regions.append(region)
generate_locations(region_size, 1, region=region, tag=f"_{name}")
if row == 0 and col == 0:
multiworld.get_region("Menu", 1).connect(region)
if col != 0:
generate_entrance_pair(region, "_left", ERTestGroups.LEFT)
if col != grid_side_length - 1:
generate_entrance_pair(region, "_right", ERTestGroups.RIGHT)
if row != 0:
generate_entrance_pair(region, "_top", ERTestGroups.TOP)
if row != grid_side_length - 1:
generate_entrance_pair(region, "_bottom", ERTestGroups.BOTTOM)
class TestEntranceLookup(unittest.TestCase):
def test_shuffled_targets(self):
"""tests that get_targets shuffles targets between groups when requested"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
for entrance in er_targets:
lookup.add(entrance)
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
False, False)
prev = None
group_order = [prev := group.randomization_group for group in retrieved_targets
if prev != group.randomization_group]
# technically possible that group order may not be shuffled, by some small chance, on some seeds. but generally
# a shuffled list should alternate more frequently which is the desired behavior here
self.assertGreater(len(group_order), 2)
def test_ordered_targets(self):
"""tests that get_targets does not shuffle targets between groups when requested"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
for entrance in er_targets:
lookup.add(entrance)
retrieved_targets = lookup.get_targets([ERTestGroups.TOP, ERTestGroups.BOTTOM],
False, True)
prev = None
group_order = [prev := group.randomization_group for group in retrieved_targets if prev != group.randomization_group]
self.assertEqual([ERTestGroups.TOP, ERTestGroups.BOTTOM], group_order)
class TestBakeTargetGroupLookup(unittest.TestCase):
def test_lookup_generation(self):
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
world = multiworld.worlds[1]
expected = {
ERTestGroups.LEFT: [-ERTestGroups.LEFT],
ERTestGroups.RIGHT: [-ERTestGroups.RIGHT],
ERTestGroups.TOP: [-ERTestGroups.TOP],
ERTestGroups.BOTTOM: [-ERTestGroups.BOTTOM]
}
actual = bake_target_group_lookup(world, lambda g: [-g])
self.assertEqual(expected, actual)
class TestDisconnectForRandomization(unittest.TestCase):
def test_disconnect_default_2way(self):
multiworld = generate_test_multiworld()
r1 = Region("r1", 1, multiworld)
r2 = Region("r2", 1, multiworld)
e = r1.create_exit("e")
e.randomization_type = EntranceType.TWO_WAY
e.randomization_group = 1
e.connect(r2)
disconnect_entrance_for_randomization(e)
self.assertIsNone(e.connected_region)
self.assertEqual([], r2.entrances)
self.assertEqual(1, len(r1.exits))
self.assertEqual(e, r1.exits[0])
self.assertEqual(1, len(r1.entrances))
self.assertIsNone(r1.entrances[0].parent_region)
self.assertEqual("e", r1.entrances[0].name)
self.assertEqual(EntranceType.TWO_WAY, r1.entrances[0].randomization_type)
self.assertEqual(1, r1.entrances[0].randomization_group)
def test_disconnect_default_1way(self):
multiworld = generate_test_multiworld()
r1 = Region("r1", 1, multiworld)
r2 = Region("r2", 1, multiworld)
e = r1.create_exit("e")
e.randomization_type = EntranceType.ONE_WAY
e.randomization_group = 1
e.connect(r2)
disconnect_entrance_for_randomization(e)
self.assertIsNone(e.connected_region)
self.assertEqual([], r1.entrances)
self.assertEqual(1, len(r1.exits))
self.assertEqual(e, r1.exits[0])
self.assertEqual(1, len(r2.entrances))
self.assertIsNone(r2.entrances[0].parent_region)
self.assertEqual("r2", r2.entrances[0].name)
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
self.assertEqual(1, r2.entrances[0].randomization_group)
def test_disconnect_uses_alternate_group(self):
multiworld = generate_test_multiworld()
r1 = Region("r1", 1, multiworld)
r2 = Region("r2", 1, multiworld)
e = r1.create_exit("e")
e.randomization_type = EntranceType.ONE_WAY
e.randomization_group = 1
e.connect(r2)
disconnect_entrance_for_randomization(e, 2)
self.assertIsNone(e.connected_region)
self.assertEqual([], r1.entrances)
self.assertEqual(1, len(r1.exits))
self.assertEqual(e, r1.exits[0])
self.assertEqual(1, len(r2.entrances))
self.assertIsNone(r2.entrances[0].parent_region)
self.assertEqual("r2", r2.entrances[0].name)
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
self.assertEqual(2, r2.entrances[0].randomization_group)
class TestRandomizeEntrances(unittest.TestCase):
def test_determinism(self):
"""tests that the same output is produced for the same input"""
multiworld1 = generate_test_multiworld()
generate_disconnected_region_grid(multiworld1, 5)
multiworld2 = generate_test_multiworld()
generate_disconnected_region_grid(multiworld2, 5)
result1 = randomize_entrances(multiworld1.worlds[1], False, directionally_matched_group_lookup)
result2 = randomize_entrances(multiworld2.worlds[1], False, directionally_matched_group_lookup)
self.assertEqual(result1.pairings, result2.pairings)
for e1, e2 in zip(result1.placements, result2.placements):
self.assertEqual(e1.name, e2.name)
self.assertEqual(e1.parent_region.name, e1.parent_region.name)
self.assertEqual(e1.connected_region.name, e2.connected_region.name)
def test_all_entrances_placed(self):
"""tests that all entrances and exits were placed, all regions are connected, and no dangling edges exist"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
self.assertEqual([], [entrance for region in multiworld.get_regions()
for entrance in region.entrances if not entrance.parent_region])
self.assertEqual([], [exit_ for region in multiworld.get_regions()
for exit_ in region.exits if not exit_.connected_region])
# 5x5 grid + menu
self.assertEqual(26, len(result.placed_regions))
self.assertEqual(80, len(result.pairings))
self.assertEqual(80, len(result.placements))
def test_coupling(self):
"""tests that in coupled mode, all 2 way transitions have an inverse"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
seen_placement_count = 0
def verify_coupled(_: ERPlacementState, placed_entrances: list[Entrance]):
nonlocal seen_placement_count
seen_placement_count += len(placed_entrances)
self.assertEqual(2, len(placed_entrances))
self.assertEqual(placed_entrances[0].parent_region, placed_entrances[1].connected_region)
self.assertEqual(placed_entrances[1].parent_region, placed_entrances[0].connected_region)
result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup,
on_connect=verify_coupled)
# if we didn't visit every placement the verification on_connect doesn't really mean much
self.assertEqual(len(result.placements), seen_placement_count)
def test_uncoupled(self):
"""tests that in uncoupled mode, no transitions have an (intentional) inverse"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
seen_placement_count = 0
def verify_uncoupled(state: ERPlacementState, placed_entrances: list[Entrance]):
nonlocal seen_placement_count
seen_placement_count += len(placed_entrances)
self.assertEqual(1, len(placed_entrances))
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup,
on_connect=verify_uncoupled)
# if we didn't visit every placement the verification on_connect doesn't really mean much
self.assertEqual(len(result.placements), seen_placement_count)
def test_oneway_twoway_pairing(self):
"""tests that 1 ways are only paired to 1 ways and 2 ways are only paired to 2 ways"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
region26 = Region("region26", 1, multiworld)
multiworld.regions.append(region26)
for index, region in enumerate(["region4", "region20", "region24"]):
x = multiworld.get_region(region, 1).create_exit(f"{region}_bottom_1way")
x.randomization_type = EntranceType.ONE_WAY
x.randomization_group = ERTestGroups.BOTTOM
e = region26.create_er_target(f"region26_top_1way{index}")
e.randomization_type = EntranceType.ONE_WAY
e.randomization_group = ERTestGroups.TOP
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
for exit_name, entrance_name in result.pairings:
# we have labeled our entrances in such a way that all the 1 way entrances have 1way in the name,
# so test for that since the ER target will have been discarded
if "1way" in exit_name:
self.assertIn("1way", entrance_name)
def test_group_constraints_satisfied(self):
"""tests that all grouping constraints are satisfied"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
for exit_name, entrance_name in result.pairings:
# we have labeled our entrances in such a way that all the entrances contain their group in the name
# so test for that since the ER target will have been discarded
if "top" in exit_name:
self.assertIn("bottom", entrance_name)
if "bottom" in exit_name:
self.assertIn("top", entrance_name)
if "left" in exit_name:
self.assertIn("right", entrance_name)
if "right" in exit_name:
self.assertIn("left", entrance_name)
def test_minimal_entrance_rando(self):
"""tests that entrance randomization can complete with minimal accessibility and unreachable exits"""
multiworld = generate_test_multiworld()
multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal)
multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1)
generate_disconnected_region_grid(multiworld, 5, 1)
prog_items = generate_items(10, 1, True)
multiworld.itempool += prog_items
filler_items = generate_items(15, 1, False)
multiworld.itempool += filler_items
e = multiworld.get_entrance("region1_right", 1)
set_rule(e, lambda state: False)
randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
self.assertEqual([], [entrance for region in multiworld.get_regions()
for entrance in region.entrances if not entrance.parent_region])
self.assertEqual([], [exit_ for region in multiworld.get_regions()
for exit_ in region.exits if not exit_.connected_region])
def test_minimal_entrance_rando_with_collect_override(self):
"""
tests that entrance randomization can complete with minimal accessibility and unreachable exits
when the world defines a collect override that add extra values to prog_items
"""
multiworld = generate_test_multiworld()
multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal)
multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1)
generate_disconnected_region_grid(multiworld, 5, 1)
prog_items = generate_items(10, 1, True)
multiworld.itempool += prog_items
filler_items = generate_items(15, 1, False)
multiworld.itempool += filler_items
e = multiworld.get_entrance("region1_right", 1)
set_rule(e, lambda state: False)
old_collect = multiworld.worlds[1].collect
def new_collect(state, item):
old_collect(state, item)
state.prog_items[item.player]["counter"] += 300
multiworld.worlds[1].collect = new_collect
randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
self.assertEqual([], [entrance for region in multiworld.get_regions()
for entrance in region.entrances if not entrance.parent_region])
self.assertEqual([], [exit_ for region in multiworld.get_regions()
for exit_ in region.exits if not exit_.connected_region])
def test_restrictive_region_requirement_does_not_fail(self):
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 2, 1)
region = Region("region4", 1, multiworld)
multiworld.regions.append(region)
generate_entrance_pair(multiworld.get_region("region0", 1), "_right2", ERTestGroups.RIGHT)
generate_entrance_pair(region, "_left", ERTestGroups.LEFT)
blocked_exits = ["region1_left", "region1_bottom",
"region2_top", "region2_right",
"region3_left", "region3_top"]
for exit_name in blocked_exits:
blocked_exit = multiworld.get_entrance(exit_name, 1)
blocked_exit.access_rule = lambda state: state.can_reach_region("region4", 1)
multiworld.register_indirect_condition(region, blocked_exit)
result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup)
# verifying that we did in fact place region3 adjacent to region0 to unblock all the other connections
# (and implicitly, that ER didn't fail)
self.assertTrue(("region0_right", "region4_left") in result.pairings
or ("region0_right2", "region4_left") in result.pairings)
def test_fails_when_mismatched_entrance_and_exit_count(self):
"""tests that entrance randomization fast-fails if the input exit and entrance count do not match"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
multiworld.get_region("region1", 1).create_exit("extra")
self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False,
directionally_matched_group_lookup)
def test_fails_when_some_unreachable_exit(self):
"""tests that entrance randomization fails if an exit is never reachable (non-minimal accessibility)"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
e = multiworld.get_entrance("region1_right", 1)
set_rule(e, lambda state: False)
self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False,
directionally_matched_group_lookup)
def test_fails_when_some_unconnectable_exit(self):
"""tests that entrance randomization fails if an exit can't be made into a valid placement (non-minimal)"""
class CustomEntrance(Entrance):
def can_connect_to(self, other: Entrance, dead_end: bool, er_state: "ERPlacementState") -> bool:
if other.name == "region1_right":
return False
class CustomRegion(Region):
entrance_type = CustomEntrance
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5, region_type=CustomRegion)
self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False,
directionally_matched_group_lookup)
def test_minimal_er_fails_when_not_enough_locations_to_fit_progression(self):
"""
tests that entrance randomization fails in minimal accessibility if there are not enough locations
available to place all progression items locally
"""
multiworld = generate_test_multiworld()
multiworld.worlds[1].options.accessibility = Accessibility.from_any(Accessibility.option_minimal)
multiworld.completion_condition[1] = lambda state: state.can_reach("region24", player=1)
generate_disconnected_region_grid(multiworld, 5, 1)
prog_items = generate_items(30, 1, True)
multiworld.itempool += prog_items
e = multiworld.get_entrance("region1_right", 1)
set_rule(e, lambda state: False)
self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False,
directionally_matched_group_lookup)

View File

@@ -0,0 +1,63 @@
import unittest
from worlds.AutoWorld import AutoWorldRegister, call_all, World
from . import setup_solo_multiworld
class TestBase(unittest.TestCase):
def test_entrance_connection_steps(self):
"""Tests that Entrances are connected and not changed after connect_entrances."""
def get_entrance_name_to_source_and_target_dict(world: World):
return [
(entrance.name, entrance.parent_region, entrance.connected_region)
for entrance in world.get_entrances()
]
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
additional_steps = ("generate_basic", "pre_fill")
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
original_entrances = get_entrance_name_to_source_and_target_dict(multiworld.worlds[1])
self.assertTrue(
all(entrance[1] is not None and entrance[2] is not None for entrance in original_entrances),
f"{game_name} had unconnected entrances after connect_entrances"
)
for step in additional_steps:
with self.subTest("Step", step=step):
call_all(multiworld, step)
step_entrances = get_entrance_name_to_source_and_target_dict(multiworld.worlds[1])
self.assertEqual(
original_entrances, step_entrances, f"{game_name} modified entrances during {step}"
)
def test_all_state_before_connect_entrances(self):
"""Before connect_entrances, Entrance objects may be unconnected.
Thus, we test that get_all_state is performed with allow_partial_entrances if used before or during
connect_entrances."""
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances")
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, ())
original_get_all_state = multiworld.get_all_state
def patched_get_all_state(use_cache: bool, allow_partial_entrances: bool = False):
self.assertTrue(allow_partial_entrances, (
"Before the connect_entrances step finishes, other worlds might still have partial entrances. "
"As such, any call to get_all_state must use allow_partial_entrances = True."
))
return original_get_all_state(use_cache, allow_partial_entrances)
multiworld.get_all_state = patched_get_all_state
for step in gen_steps:
with self.subTest("Step", step=step):
call_all(multiworld, step)

View File

@@ -1,6 +1,8 @@
import unittest
from typing import Callable, Dict, Optional
from typing_extensions import override
from BaseClasses import CollectionState, MultiWorld, Region
@@ -8,6 +10,7 @@ class TestHelpers(unittest.TestCase):
multiworld: MultiWorld
player: int = 1
@override
def setUp(self) -> None:
self.multiworld = MultiWorld(self.player)
self.multiworld.game[self.player] = "helper_test_game"
@@ -38,15 +41,15 @@ class TestHelpers(unittest.TestCase):
"TestRegion1": {"TestRegion2": "connection"},
"TestRegion2": {"TestRegion1": None},
}
reg_exit_set: Dict[str, set[str]] = {
"TestRegion1": {"TestRegion3"}
}
exit_rules: Dict[str, Callable[[CollectionState], bool]] = {
"TestRegion1": lambda state: state.has("test_item", self.player)
}
self.multiworld.regions += [Region(region, self.player, self.multiworld, regions[region]) for region in regions]
with self.subTest("Test Location Creation Helper"):
@@ -73,7 +76,7 @@ class TestHelpers(unittest.TestCase):
entrance_name = exit_name if exit_name else f"{parent} -> {exit_reg}"
self.assertEqual(exit_rules[exit_reg],
self.multiworld.get_entrance(entrance_name, self.player).access_rule)
for region in reg_exit_set:
current_region = self.multiworld.get_region(region, self.player)
current_region.add_exits(reg_exit_set[region])

View File

@@ -39,7 +39,7 @@ class TestImplemented(unittest.TestCase):
"""Tests that if a world creates slot data, it's json serializable."""
for game_name, world_type in AutoWorldRegister.world_types.items():
# has an await for generate_output which isn't being called
if game_name in {"Ocarina of Time", "Zillion"}:
if game_name in {"Ocarina of Time"}:
continue
multiworld = setup_solo_multiworld(world_type)
with self.subTest(game=game_name, seed=multiworld.seed):
@@ -52,3 +52,77 @@ class TestImplemented(unittest.TestCase):
def test_no_failed_world_loads(self):
if failed_world_loads:
self.fail(f"The following worlds failed to load: {failed_world_loads}")
def test_explicit_indirect_conditions_spheres(self):
"""Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit
indirect conditions"""
# Because the iteration order of blocked_connections in CollectionState.update_reachable_regions() is
# nondeterministic, this test may sometimes pass with the same seed even when there are missing indirect
# conditions.
for game_name, world_type in AutoWorldRegister.world_types.items():
multiworld = setup_solo_multiworld(world_type)
world = multiworld.get_game_worlds(game_name)[0]
if not world.explicit_indirect_conditions:
# The world does not use explicit indirect conditions, so it can be skipped.
continue
# The world may override explicit_indirect_conditions as a property that cannot be set, so try modifying it.
try:
world.explicit_indirect_conditions = False
world.explicit_indirect_conditions = True
except Exception:
# Could not modify the attribute, so skip this world.
with self.subTest(game=game_name, skipped="world.explicit_indirect_conditions could not be set"):
continue
with self.subTest(game=game_name, seed=multiworld.seed):
distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill")
# Note: `multiworld.get_spheres()` iterates a set of locations, so the order that locations are checked
# is nondeterministic and may vary between runs with the same seed.
explicit_spheres = list(multiworld.get_spheres())
# Disable explicit indirect conditions and produce a second list of spheres.
world.explicit_indirect_conditions = False
implicit_spheres = list(multiworld.get_spheres())
# Both lists should be identical.
if explicit_spheres == implicit_spheres:
# Test passed.
continue
# Find the first sphere that was different and provide a useful failure message.
zipped = zip(explicit_spheres, implicit_spheres)
for sphere_num, (sphere_explicit, sphere_implicit) in enumerate(zipped, start=1):
# Each sphere created with explicit indirect conditions should be identical to the sphere created
# with implicit indirect conditions.
if sphere_explicit != sphere_implicit:
reachable_only_with_implicit = sorted(sphere_implicit - sphere_explicit)
if reachable_only_with_implicit:
locations_and_parents = [(loc, loc.parent_region) for loc in reachable_only_with_implicit]
self.fail(f"Sphere {sphere_num} created with explicit indirect conditions did not contain"
f" the same locations as sphere {sphere_num} created with implicit indirect"
f" conditions. There may be missing indirect conditions for connections to the"
f" locations' parent regions or connections from other regions which connect to"
f" these regions."
f"\nLocations that should have been reachable in sphere {sphere_num} and their"
f" parent regions:"
f"\n{locations_and_parents}")
else:
# Some locations were only present in the sphere created with explicit indirect conditions.
# This should not happen because missing indirect conditions should only reduce
# accessibility, not increase accessibility.
reachable_only_with_explicit = sorted(sphere_explicit - sphere_implicit)
self.fail(f"Sphere {sphere_num} created with explicit indirect conditions contained more"
f" locations than sphere {sphere_num} created with implicit indirect conditions."
f" This should not happen."
f"\nUnexpectedly reachable locations in sphere {sphere_num}:"
f"\n{reachable_only_with_explicit}")
self.fail("Unreachable")
def test_no_items_or_locations_or_regions_submitted_in_init(self):
"""Test that worlds don't submit items/locations/regions to the multiworld in __init__"""
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, ())
self.assertEqual(len(multiworld.itempool), 0)
self.assertEqual(len(multiworld.get_locations()), 0)
self.assertEqual(len(multiworld.get_regions()), 0)

View File

@@ -1,5 +1,6 @@
import unittest
from BaseClasses import CollectionState
from worlds.AutoWorld import AutoWorldRegister, call_all
from . import setup_solo_multiworld
@@ -8,12 +9,31 @@ class TestBase(unittest.TestCase):
def test_create_item(self):
"""Test that a world can successfully create all items in its datapackage"""
for game_name, world_type in AutoWorldRegister.world_types.items():
proxy_world = setup_solo_multiworld(world_type, ()).worlds[1]
multiworld = setup_solo_multiworld(world_type, steps=("generate_early", "create_regions", "create_items"))
proxy_world = multiworld.worlds[1]
for item_name in world_type.item_name_to_id:
test_state = CollectionState(multiworld)
with self.subTest("Create Item", item_name=item_name, game_name=game_name):
item = proxy_world.create_item(item_name)
with self.subTest("Item Name", item_name=item_name, game_name=game_name):
self.assertEqual(item.name, item_name)
if item.advancement:
with self.subTest("Item State Collect", item_name=item_name, game_name=game_name):
test_state.collect(item, True)
with self.subTest("Item State Remove", item_name=item_name, game_name=game_name):
test_state.remove(item)
self.assertEqual(test_state.prog_items, multiworld.state.prog_items,
"Item Collect -> Remove should restore empty state.")
else:
with self.subTest("Item State Collect No Change", item_name=item_name, game_name=game_name):
# Non-Advancement should not modify state.
test_state.collect(item)
self.assertEqual(test_state.prog_items, multiworld.state.prog_items)
def test_item_name_group_has_valid_item(self):
"""Test that all item name groups contain valid items. """
# This cannot test for Event names that you may have declared for logic, only sendable Items.
@@ -67,7 +87,7 @@ class TestBase(unittest.TestCase):
def test_itempool_not_modified(self):
"""Test that worlds don't modify the itempool after `create_items`"""
gen_steps = ("generate_early", "create_regions", "create_items")
additional_steps = ("set_rules", "generate_basic", "pre_fill")
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
excluded_games = ("Links Awakening DX", "Ocarina of Time", "SMZ3")
worlds_to_test = {game: world
for game, world in AutoWorldRegister.world_types.items() if game not in excluded_games}
@@ -80,3 +100,21 @@ class TestBase(unittest.TestCase):
call_all(multiworld, step)
self.assertEqual(created_items, multiworld.itempool,
f"{game_name} modified the itempool during {step}")
def test_locality_not_modified(self):
"""Test that worlds don't modify the locality of items after duplicates are resolved"""
gen_steps = ("generate_early", "create_regions", "create_items")
additional_steps = ("set_rules", "connect_entrances", "generate_basic", "pre_fill")
worlds_to_test = {game: world for game, world in AutoWorldRegister.world_types.items()}
for game_name, world_type in worlds_to_test.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
local_items = multiworld.worlds[1].options.local_items.value.copy()
non_local_items = multiworld.worlds[1].options.non_local_items.value.copy()
for step in additional_steps:
with self.subTest("step", step=step):
call_all(multiworld, step)
self.assertEqual(local_items, multiworld.worlds[1].options.local_items.value,
f"{game_name} modified local_items during {step}")
self.assertEqual(non_local_items, multiworld.worlds[1].options.non_local_items.value,
f"{game_name} modified non_local_items during {step}")

View File

@@ -45,6 +45,12 @@ class TestBase(unittest.TestCase):
self.assertEqual(location_count, len(multiworld.get_locations()),
f"{game_name} modified locations count during rule creation")
call_all(multiworld, "connect_entrances")
self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during rule creation")
self.assertEqual(location_count, len(multiworld.get_locations()),
f"{game_name} modified locations count during rule creation")
call_all(multiworld, "generate_basic")
self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during generate_basic")

View File

@@ -1,16 +1,21 @@
import unittest
from BaseClasses import MultiWorld
from worlds.AutoWorld import AutoWorldRegister
from . import setup_solo_multiworld
class TestWorldMemory(unittest.TestCase):
def test_leak(self):
def test_leak(self) -> None:
"""Tests that worlds don't leak references to MultiWorld or themselves with default options."""
import gc
import weakref
refs: dict[str, weakref.ReferenceType[MultiWorld]] = {}
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game_name=game_name):
with self.subTest("Game creation", game_name=game_name):
weak = weakref.ref(setup_solo_multiworld(world_type))
gc.collect()
refs[game_name] = weak
gc.collect()
for game_name, weak in refs.items():
with self.subTest("Game cleanup", game_name=game_name):
self.assertFalse(weak(), "World leaked a reference")

View File

@@ -3,7 +3,7 @@ from worlds.AutoWorld import AutoWorldRegister
class TestNames(unittest.TestCase):
def test_item_names_format(self):
def test_item_names_format(self) -> None:
"""Item names must not be all numeric in order to differentiate between ID and name in !hint"""
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):
@@ -11,7 +11,7 @@ class TestNames(unittest.TestCase):
self.assertFalse(item_name.isnumeric(),
f"Item name \"{item_name}\" is invalid. It must not be numeric.")
def test_location_name_format(self):
def test_location_name_format(self) -> None:
"""Location names must not be all numeric in order to differentiate between ID and name in !hint_location"""
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):

View File

@@ -2,11 +2,11 @@ import unittest
from BaseClasses import CollectionState
from worlds.AutoWorld import AutoWorldRegister
from . import setup_solo_multiworld
from . import setup_solo_multiworld, gen_steps
class TestBase(unittest.TestCase):
gen_steps = ["generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill"]
gen_steps = gen_steps
default_settings_unreachable_regions = {
"A Link to the Past": {

View File

@@ -0,0 +1,16 @@
from unittest import TestCase
from settings import Group
from worlds.AutoWorld import AutoWorldRegister
class TestSettings(TestCase):
def test_settings_can_update(self) -> None:
"""
Test that world settings can update.
"""
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=game_name):
if world_type.settings is not None:
assert isinstance(world_type.settings, Group)
world_type.settings.update({}) # a previous bug had a crash in this call to update

View File

@@ -0,0 +1,29 @@
import unittest
from worlds.AutoWorld import AutoWorldRegister, call_all
from . import setup_solo_multiworld
class TestBase(unittest.TestCase):
gen_steps = (
"generate_early",
"create_regions",
)
test_steps = (
"create_items",
"set_rules",
"connect_entrances",
"generate_basic",
"pre_fill",
)
def test_all_state_is_available(self):
"""Ensure all_state can be created at certain steps."""
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game=game_name):
multiworld = setup_solo_multiworld(world_type, self.gen_steps)
for step in self.test_steps:
with self.subTest("Step", step=step):
call_all(multiworld, step)
self.assertTrue(multiworld.get_all_state(False, True))

View File

@@ -115,6 +115,7 @@ class Base:
def test_get_for_player(self) -> None:
self.assertEqual(self.store.get_for_player(3), {4: {9}})
self.assertEqual(self.store.get_for_player(1), {1: {13}, 2: {22, 23}})
self.assertEqual(self.store.get_for_player(9999), {})
def test_get_checked(self) -> None:
self.assertEqual(self.store.get_checked(full_state, 0, 1), [11, 12, 13])
@@ -122,18 +123,48 @@ class Base:
self.assertEqual(self.store.get_checked(empty_state, 0, 1), [])
self.assertEqual(self.store.get_checked(full_state, 0, 3), [9])
def test_get_checked_exception(self) -> None:
with self.assertRaises(KeyError):
self.store.get_checked(empty_state, 0, 9999)
bad_state = {(0, 6): {1}}
with self.assertRaises(KeyError):
self.store.get_checked(bad_state, 0, 6)
bad_state = {(0, 9999): set()}
with self.assertRaises(KeyError):
self.store.get_checked(bad_state, 0, 9999)
def test_get_missing(self) -> None:
self.assertEqual(self.store.get_missing(full_state, 0, 1), [])
self.assertEqual(self.store.get_missing(one_state, 0, 1), [11, 13])
self.assertEqual(self.store.get_missing(empty_state, 0, 1), [11, 12, 13])
self.assertEqual(self.store.get_missing(empty_state, 0, 3), [9])
def test_get_missing_exception(self) -> None:
with self.assertRaises(KeyError):
self.store.get_missing(empty_state, 0, 9999)
bad_state = {(0, 6): {1}}
with self.assertRaises(KeyError):
self.store.get_missing(bad_state, 0, 6)
bad_state = {(0, 9999): set()}
with self.assertRaises(KeyError):
self.store.get_missing(bad_state, 0, 9999)
def test_get_remaining(self) -> None:
self.assertEqual(self.store.get_remaining(full_state, 0, 1), [])
self.assertEqual(self.store.get_remaining(one_state, 0, 1), [(1, 13), (2, 21)])
self.assertEqual(self.store.get_remaining(empty_state, 0, 1), [(1, 13), (2, 21), (2, 22)])
self.assertEqual(self.store.get_remaining(empty_state, 0, 3), [(4, 99)])
def test_get_remaining_exception(self) -> None:
with self.assertRaises(KeyError):
self.store.get_remaining(empty_state, 0, 9999)
bad_state = {(0, 6): {1}}
with self.assertRaises(KeyError):
self.store.get_missing(bad_state, 0, 6)
bad_state = {(0, 9999): set()}
with self.assertRaises(KeyError):
self.store.get_remaining(bad_state, 0, 9999)
def test_location_set_intersection(self) -> None:
locations = {10, 11, 12}
locations.intersection_update(self.store[1])
@@ -181,6 +212,16 @@ class Base:
})
self.assertEqual(len(store), 1)
self.assertEqual(len(store[1]), 0)
self.assertEqual(sorted(store.find_item(set(), 1)), [])
self.assertEqual(sorted(store.find_item({1}, 1)), [])
self.assertEqual(sorted(store.find_item({1, 2}, 1)), [])
self.assertEqual(store.get_for_player(1), {})
self.assertEqual(store.get_checked(empty_state, 0, 1), [])
self.assertEqual(store.get_checked(full_state, 0, 1), [])
self.assertEqual(store.get_missing(empty_state, 0, 1), [])
self.assertEqual(store.get_missing(full_state, 0, 1), [])
self.assertEqual(store.get_remaining(empty_state, 0, 1), [])
self.assertEqual(store.get_remaining(full_state, 0, 1), [])
def test_no_locations_for_1(self) -> None:
store = self.type({

View File

@@ -2,9 +2,7 @@
from __future__ import annotations
import abc
import logging
from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, Tuple, Any, Optional, Union
from typing_extensions import TypeGuard
from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, Tuple, Any, Optional, Union, TypeGuard
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components
@@ -88,3 +86,7 @@ class SNIClient(abc.ABC, metaclass=AutoSNIClientRegister):
async def deathlink_kill_player(self, ctx: SNIContext) -> None:
""" override this with implementation to kill player """
pass
def on_package(self, ctx: SNIContext, cmd: str, args: Dict[str, Any]) -> None:
""" override this with code to handle packages from the server """
pass

View File

@@ -7,7 +7,7 @@ import sys
import time
from random import Random
from dataclasses import make_dataclass
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, List, Mapping, Optional, Set, TextIO, Tuple,
from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Mapping, Optional, Set, TextIO, Tuple,
TYPE_CHECKING, Type, Union)
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
@@ -33,7 +33,10 @@ class AutoWorldRegister(type):
# lazy loading + caching to minimize runtime cost
if cls.__settings is None:
from settings import get_settings
cls.__settings = get_settings()[cls.settings_key]
try:
cls.__settings = get_settings()[cls.settings_key]
except AttributeError:
return None
return cls.__settings
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoWorldRegister:
@@ -375,6 +378,10 @@ class World(metaclass=AutoWorldRegister):
"""Method for setting the rules on the World's regions and locations."""
pass
def connect_entrances(self) -> None:
"""Method to finalize the source and target regions of the World's entrances"""
pass
def generate_basic(self) -> None:
"""
Useful for randomizing things that don't affect logic but are better to be determined before the output stage.
@@ -531,12 +538,24 @@ class World(metaclass=AutoWorldRegister):
def get_location(self, location_name: str) -> "Location":
return self.multiworld.get_location(location_name, self.player)
def get_locations(self) -> "Iterable[Location]":
return self.multiworld.get_locations(self.player)
def get_entrance(self, entrance_name: str) -> "Entrance":
return self.multiworld.get_entrance(entrance_name, self.player)
def get_entrances(self) -> "Iterable[Entrance]":
return self.multiworld.get_entrances(self.player)
def get_region(self, region_name: str) -> "Region":
return self.multiworld.get_region(region_name, self.player)
def get_regions(self) -> "Iterable[Region]":
return self.multiworld.get_regions(self.player)
def push_precollected(self, item: Item) -> None:
self.multiworld.push_precollected(item)
@property
def player_name(self) -> str:
return self.multiworld.get_player_name(self.player)

View File

@@ -18,16 +18,42 @@ class Type(Enum):
class Component:
"""
A Component represents a process launchable by Archipelago Launcher, either by a User action in the GUI,
by resolving an archipelago://user:pass@host:port link from the WebHost, by resolving a patch file's metadata,
or by using a component name arg while running the Launcher in CLI i.e. `ArchipelagoLauncher.exe "Text Client"`
Expected to be appended to LauncherComponents.component list to be used.
"""
display_name: str
"""Used as the GUI button label and the component name in the CLI args"""
type: Type
"""
Enum "Type" classification of component intent, for filtering in the Launcher GUI
If not set in the constructor, it will be inferred by display_name
"""
script_name: Optional[str]
"""Recommended to use func instead; Name of file to run when the component is called"""
frozen_name: Optional[str]
"""Recommended to use func instead; Name of the frozen executable file for this component"""
icon: str # just the name, no suffix
"""Lookup ID for the icon path in LauncherComponents.icon_paths"""
cli: bool
"""Bool to control if the component gets launched in an appropriate Terminal for the OS"""
func: Optional[Callable]
"""
Function that gets called when the component gets launched
Any arg besides the component name arg is passed into the func as well, so handling *args is suggested
"""
file_identifier: Optional[Callable[[str], bool]]
"""
Function that is run against patch file arg to identify which component is appropriate to launch
If the function is an Instance of SuffixIdentifier the suffixes will also be valid for the Open Patch component
"""
game_name: Optional[str]
"""Game name to identify component when handling launch links from WebHost"""
supports_uri: Optional[bool]
"""Bool to identify if a component supports being launched by launch links from WebHost"""
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None,
@@ -61,7 +87,7 @@ class Component:
processes = weakref.WeakSet()
def launch_subprocess(func: Callable, name: str = None, args: Tuple[str, ...] = ()) -> None:
def launch_subprocess(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
global processes
import multiprocessing
process = multiprocessing.Process(target=func, name=name, args=args)
@@ -69,6 +95,14 @@ def launch_subprocess(func: Callable, name: str = None, args: Tuple[str, ...] =
processes.add(process)
def launch(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
from Utils import is_kivy_running
if is_kivy_running():
launch_subprocess(func, name, args)
else:
func(*args)
class SuffixIdentifier:
suffixes: Iterable[str]
@@ -85,7 +119,7 @@ class SuffixIdentifier:
def launch_textclient(*args):
import CommonClient
launch_subprocess(CommonClient.run_as_textclient, name="TextClient", args=args)
launch(CommonClient.run_as_textclient, name="TextClient", args=args)
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
@@ -103,7 +137,7 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path
try:
import zipfile
zip = zipfile.ZipFile(apworld_path)
directories = [f.filename.strip('/') for f in zip.filelist if f.CRC == 0 and f.file_size == 0 and f.filename.count('/') == 1]
directories = [f.name for f in zipfile.Path(zip).iterdir() if f.is_dir()]
if len(directories) == 1 and directories[0] in apworld_path.stem:
module_name = directories[0]
apworld_name = module_name + ".apworld"
@@ -207,6 +241,7 @@ components: List[Component] = [
]
# if registering an icon from within an apworld, the format "ap:module.name/path/to/file.png" can be used
icon_paths = {
'icon': local_path('data', 'icon.png'),
'mcicon': local_path('data', 'mcicon.png'),

View File

@@ -66,19 +66,12 @@ class WorldSource:
start = time.perf_counter()
if self.is_zip:
importer = zipimport.zipimporter(self.resolved_path)
if hasattr(importer, "find_spec"): # new in Python 3.10
spec = importer.find_spec(os.path.basename(self.path).rsplit(".", 1)[0])
assert spec, f"{self.path} is not a loadable module"
mod = importlib.util.module_from_spec(spec)
else: # TODO: remove with 3.8 support
mod = importer.load_module(os.path.basename(self.path).rsplit(".", 1)[0])
spec = importer.find_spec(os.path.basename(self.path).rsplit(".", 1)[0])
assert spec, f"{self.path} is not a loadable module"
mod = importlib.util.module_from_spec(spec)
mod.__package__ = f"worlds.{mod.__package__}"
if mod.__package__ is not None:
mod.__package__ = f"worlds.{mod.__package__}"
else:
# load_module does not populate package, we'll have to assume mod.__name__ is correct here
# probably safe to remove with 3.8 support
mod.__package__ = f"worlds.{mod.__name__}"
mod.__name__ = f"worlds.{mod.__name__}"
sys.modules[mod.__name__] = mod
with warnings.catch_warnings():

View File

@@ -55,6 +55,7 @@ async def lock(ctx) -> None
async def unlock(ctx) -> None
async def get_hash(ctx) -> str
async def get_memory_size(ctx, domain: str) -> int
async def get_system(ctx) -> str
async def get_cores(ctx) -> dict[str, str]
async def ping(ctx) -> None
@@ -168,9 +169,10 @@ select dialog and they will be associated with BizHawkClient. This does not affe
associate the file extension with Archipelago.
`validate_rom` is called to figure out whether a given ROM belongs to your client. It will only be called when a ROM is
running on a system you specified in your `system` class variable. In most cases, that will be a single system and you
can be sure that you're not about to try to read from nonexistent domains or out of bounds. If you decide to claim this
ROM as yours, this is where you should do setup for things like `items_handling`.
running on a system you specified in your `system` class variable. Take extra care here, because your code will run
against ROMs that you have no control over. If you're reading an address deep in ROM, you might want to check the size
of ROM before you attempt to read it using `get_memory_size`. If you decide to claim this ROM as yours, this is where
you should do setup for things like `items_handling`.
`game_watcher` is the "main loop" of your client where you should be checking memory and sending new items to the ROM.
`BizHawkClient` will make sure that your `game_watcher` only runs when your client has validated the ROM, and will do
@@ -268,6 +270,8 @@ server connection before trying to interact with it.
- By default, the player will be asked to provide their slot name after connecting to the server and validating, and
that input will be used to authenticate with the `Connect` command. You can override `set_auth` in your own client to
set it automatically based on data in the ROM or on your client instance.
- Use `get_memory_size` inside `validate_rom` if you need to read at large addresses, in case some other game has a
smaller ROM size.
- You can override `on_package` in your client to watch raw packages, but don't forget you also have access to a
subclass of `CommonContext` and its API.
- You can import `BizHawkClientContext` for type hints using `typing.TYPE_CHECKING`. Importing it without conditions at

View File

@@ -10,7 +10,7 @@ import base64
import enum
import json
import sys
import typing
from typing import Any, Sequence
BIZHAWK_SOCKET_PORT_RANGE_START = 43055
@@ -44,10 +44,10 @@ class SyncError(Exception):
class BizHawkContext:
streams: typing.Optional[typing.Tuple[asyncio.StreamReader, asyncio.StreamWriter]]
streams: tuple[asyncio.StreamReader, asyncio.StreamWriter] | None
connection_status: ConnectionStatus
_lock: asyncio.Lock
_port: typing.Optional[int]
_port: int | None
def __init__(self) -> None:
self.streams = None
@@ -122,12 +122,12 @@ async def get_script_version(ctx: BizHawkContext) -> int:
return int(await ctx._send_message("VERSION"))
async def send_requests(ctx: BizHawkContext, req_list: typing.List[typing.Dict[str, typing.Any]]) -> typing.List[typing.Dict[str, typing.Any]]:
async def send_requests(ctx: BizHawkContext, req_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Sends a list of requests to the BizHawk connector and returns their responses.
It's likely you want to use the wrapper functions instead of this."""
responses = json.loads(await ctx._send_message(json.dumps(req_list)))
errors: typing.List[ConnectorError] = []
errors: list[ConnectorError] = []
for response in responses:
if response["type"] == "ERROR":
@@ -151,7 +151,7 @@ async def ping(ctx: BizHawkContext) -> None:
async def get_hash(ctx: BizHawkContext) -> str:
"""Gets the system name for the currently loaded ROM"""
"""Gets the hash value of the currently loaded ROM"""
res = (await send_requests(ctx, [{"type": "HASH"}]))[0]
if res["type"] != "HASH_RESPONSE":
@@ -160,6 +160,16 @@ async def get_hash(ctx: BizHawkContext) -> str:
return res["value"]
async def get_memory_size(ctx: BizHawkContext, domain: str) -> int:
"""Gets the size in bytes of the specified memory domain"""
res = (await send_requests(ctx, [{"type": "MEMORY_SIZE", "domain": domain}]))[0]
if res["type"] != "MEMORY_SIZE_RESPONSE":
raise SyncError(f"Expected response of type MEMORY_SIZE_RESPONSE but got {res['type']}")
return res["value"]
async def get_system(ctx: BizHawkContext) -> str:
"""Gets the system name for the currently loaded ROM"""
res = (await send_requests(ctx, [{"type": "SYSTEM"}]))[0]
@@ -170,7 +180,7 @@ async def get_system(ctx: BizHawkContext) -> str:
return res["value"]
async def get_cores(ctx: BizHawkContext) -> typing.Dict[str, str]:
async def get_cores(ctx: BizHawkContext) -> dict[str, str]:
"""Gets the preferred cores for systems with multiple cores. Only systems with multiple available cores have
entries."""
res = (await send_requests(ctx, [{"type": "PREFERRED_CORES"}]))[0]
@@ -223,8 +233,8 @@ async def set_message_interval(ctx: BizHawkContext, value: float) -> None:
raise SyncError(f"Expected response of type SET_MESSAGE_INTERVAL_RESPONSE but got {res['type']}")
async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]],
guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> typing.Optional[typing.List[bytes]]:
async def guarded_read(ctx: BizHawkContext, read_list: Sequence[tuple[int, int, str]],
guard_list: Sequence[tuple[int, Sequence[int], str]]) -> list[bytes] | None:
"""Reads an array of bytes at 1 or more addresses if and only if every byte in guard_list matches its expected
value.
@@ -252,7 +262,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tu
"domain": domain
} for address, size, domain in read_list])
ret: typing.List[bytes] = []
ret: list[bytes] = []
for item in res:
if item["type"] == "GUARD_RESPONSE":
if not item["value"]:
@@ -266,7 +276,7 @@ async def guarded_read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tu
return ret
async def read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int, int, str]]) -> typing.List[bytes]:
async def read(ctx: BizHawkContext, read_list: Sequence[tuple[int, int, str]]) -> list[bytes]:
"""Reads data at 1 or more addresses.
Items in `read_list` should be organized `(address, size, domain)` where
@@ -278,8 +288,8 @@ async def read(ctx: BizHawkContext, read_list: typing.Sequence[typing.Tuple[int,
return await guarded_read(ctx, read_list, [])
async def guarded_write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]],
guard_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> bool:
async def guarded_write(ctx: BizHawkContext, write_list: Sequence[tuple[int, Sequence[int], str]],
guard_list: Sequence[tuple[int, Sequence[int], str]]) -> bool:
"""Writes data to 1 or more addresses if and only if every byte in guard_list matches its expected value.
Items in `write_list` should be organized `(address, value, domain)` where
@@ -316,7 +326,7 @@ async def guarded_write(ctx: BizHawkContext, write_list: typing.Sequence[typing.
return True
async def write(ctx: BizHawkContext, write_list: typing.Sequence[typing.Tuple[int, typing.Sequence[int], str]]) -> None:
async def write(ctx: BizHawkContext, write_list: Sequence[tuple[int, Sequence[int], str]]) -> None:
"""Writes data to 1 or more addresses.
Items in write_list should be organized `(address, value, domain)` where

View File

@@ -5,9 +5,9 @@ A module containing the BizHawkClient base class and metaclass
from __future__ import annotations
import abc
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional, Tuple, Union
from typing import TYPE_CHECKING, Any, ClassVar
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch_subprocess
from worlds.LauncherComponents import Component, SuffixIdentifier, Type, components, launch as launch_component
if TYPE_CHECKING:
from .context import BizHawkClientContext
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
def launch_client(*args) -> None:
from .context import launch
launch_subprocess(launch, name="BizHawkClient", args=args)
launch_component(launch, name="BizHawkClient", args=args)
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
@@ -24,9 +24,9 @@ components.append(component)
class AutoBizHawkClientRegister(abc.ABCMeta):
game_handlers: ClassVar[Dict[Tuple[str, ...], Dict[str, BizHawkClient]]] = {}
game_handlers: ClassVar[dict[tuple[str, ...], dict[str, BizHawkClient]]] = {}
def __new__(cls, name: str, bases: Tuple[type, ...], namespace: Dict[str, Any]) -> AutoBizHawkClientRegister:
def __new__(cls, name: str, bases: tuple[type, ...], namespace: dict[str, Any]) -> AutoBizHawkClientRegister:
new_class = super().__new__(cls, name, bases, namespace)
# Register handler
@@ -54,7 +54,7 @@ class AutoBizHawkClientRegister(abc.ABCMeta):
return new_class
@staticmethod
async def get_handler(ctx: "BizHawkClientContext", system: str) -> Optional[BizHawkClient]:
async def get_handler(ctx: "BizHawkClientContext", system: str) -> BizHawkClient | None:
for systems, handlers in AutoBizHawkClientRegister.game_handlers.items():
if system in systems:
for handler in handlers.values():
@@ -65,13 +65,13 @@ class AutoBizHawkClientRegister(abc.ABCMeta):
class BizHawkClient(abc.ABC, metaclass=AutoBizHawkClientRegister):
system: ClassVar[Union[str, Tuple[str, ...]]]
system: ClassVar[str | tuple[str, ...]]
"""The system(s) that the game this client is for runs on"""
game: ClassVar[str]
"""The game this client is for"""
patch_suffix: ClassVar[Optional[Union[str, Tuple[str, ...]]]]
patch_suffix: ClassVar[str | tuple[str, ...] | None]
"""The file extension(s) this client is meant to open and patch (e.g. ".apz3")"""
@abc.abstractmethod

View File

@@ -6,7 +6,7 @@ checking or launching the client, otherwise it will probably cause circular impo
import asyncio
import enum
import subprocess
from typing import Any, Dict, Optional
from typing import Any
from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled
import Patch
@@ -41,17 +41,18 @@ class BizHawkClientCommandProcessor(ClientCommandProcessor):
class BizHawkClientContext(CommonContext):
command_processor = BizHawkClientCommandProcessor
server_seed_name: str | None = None
auth_status: AuthStatus
password_requested: bool
client_handler: Optional[BizHawkClient]
slot_data: Optional[Dict[str, Any]] = None
rom_hash: Optional[str] = None
client_handler: BizHawkClient | None
slot_data: dict[str, Any] | None = None
rom_hash: str | None = None
bizhawk_ctx: BizHawkContext
watcher_timeout: float
"""The maximum amount of time the game watcher loop will wait for an update from the server before executing"""
def __init__(self, server_address: Optional[str], password: Optional[str]):
def __init__(self, server_address: str | None, password: str | None):
super().__init__(server_address, password)
self.auth_status = AuthStatus.NOT_AUTHENTICATED
self.password_requested = False
@@ -68,6 +69,8 @@ class BizHawkClientContext(CommonContext):
if cmd == "Connected":
self.slot_data = args.get("slot_data", None)
self.auth_status = AuthStatus.AUTHENTICATED
elif cmd == "RoomInfo":
self.server_seed_name = args.get("seed_name", None)
if self.client_handler is not None:
self.client_handler.on_package(self, cmd, args)
@@ -100,6 +103,7 @@ class BizHawkClientContext(CommonContext):
async def disconnect(self, allow_autoreconnect: bool=False):
self.auth_status = AuthStatus.NOT_AUTHENTICATED
self.server_seed_name = None
await super().disconnect(allow_autoreconnect)
@@ -231,20 +235,28 @@ async def _run_game(rom: str):
)
async def _patch_and_run_game(patch_file: str):
def _patch_and_run_game(patch_file: str):
try:
metadata, output_file = Patch.create_rom_file(patch_file)
Utils.async_start(_run_game(output_file))
return metadata
except Exception as exc:
logger.exception(exc)
Utils.messagebox("Error Patching Game", str(exc), True)
return {}
def launch(*launch_args) -> None:
def launch(*launch_args: str) -> None:
async def main():
parser = get_base_parser()
parser.add_argument("patch_file", default="", type=str, nargs="?", help="Path to an Archipelago patch file")
args = parser.parse_args(launch_args)
if args.patch_file != "":
metadata = _patch_and_run_game(args.patch_file)
if "server" in metadata:
args.connect = metadata["server"]
ctx = BizHawkClientContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
@@ -252,9 +264,6 @@ def launch(*launch_args) -> None:
ctx.run_gui()
ctx.run_cli()
if args.patch_file != "":
Utils.async_start(_patch_and_run_game(args.patch_file))
watcher_task = asyncio.create_task(_game_watcher(ctx), name="GameWatcher")
try:

View File

@@ -47,8 +47,6 @@ class LocationData:
self.local_item: int = None
def get_random_position(self, random):
x: int = None
y: int = None
if self.world_positions is None or len(self.world_positions) == 0:
if self.room_id is None:
return None

View File

@@ -1,9 +1,8 @@
from __future__ import annotations
from typing import Dict
from dataclasses import dataclass
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
from Options import Choice, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
class FreeincarnateMax(Range):

View File

@@ -1,6 +1,6 @@
from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType
from Options import PerGameCommonOptions
from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region
from .Locations import location_table, AdventureLocation, dragon_room_to_region
def connect(world: MultiWorld, player: int, source: str, target: str, rule: callable = lambda state: True,
@@ -76,10 +76,9 @@ def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player
multiworld.regions.append(credits_room_far_side)
dragon_slay_check = options.dragon_slay_check.value
priority_locations = determine_priority_locations(multiworld, dragon_slay_check)
priority_locations = determine_priority_locations()
for name, location_data in location_table.items():
require_sword = False
if location_data.region == "Varies":
if location_data.name == "Slay Yorgle":
if not dragon_slay_check:
@@ -154,6 +153,7 @@ def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player
# Placeholder for adding sets of priority locations at generation, possibly as an option in the future
def determine_priority_locations(world: MultiWorld, dragon_slay_check: bool) -> {}:
# def determine_priority_locations(multiworld: MultiWorld, dragon_slay_check: bool) -> {}:
def determine_priority_locations() -> {}:
priority_locations = {}
return priority_locations

View File

@@ -2,15 +2,15 @@ import hashlib
import json
import os
import zipfile
from typing import Optional, Any
import Utils
from .Locations import AdventureLocation, LocationData
from settings import get_settings
from worlds.Files import APPatch, AutoPatchRegister
from typing import Any
import bsdiff4
import Utils
from settings import get_settings
from worlds.Files import APPatch, AutoPatchRegister
from .Locations import LocationData
ADVENTUREHASH: str = "157bddb7192754a45372be196797f284"
@@ -86,9 +86,7 @@ class AdventureDeltaPatch(APPatch, metaclass=AutoPatchRegister):
# locations: [], autocollect: [], seed_name: bytes,
def __init__(self, *args: Any, **kwargs: Any) -> None:
patch_only = True
if "autocollect" in kwargs:
patch_only = False
self.foreign_items: [AdventureForeignItemInfo] = [AdventureForeignItemInfo(loc.short_location_id, loc.room_id, loc.room_x, loc.room_y)
for loc in kwargs["locations"]]

View File

@@ -1,35 +1,24 @@
import base64
import copy
import itertools
import math
import os
import settings
import typing
from enum import IntFlag
from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple
from typing import ClassVar, Dict, Optional, Tuple
from BaseClasses import Entrance, Item, ItemClassification, MultiWorld, Region, Tutorial, \
LocationProgressType
import settings
from BaseClasses import Item, ItemClassification, MultiWorld, Tutorial, LocationProgressType
from Utils import __version__
from Options import AssembleOptions
from worlds.AutoWorld import WebWorld, World
from Fill import fill_restrictive
from worlds.generic.Rules import add_rule, set_rule
from .Options import DragonRandoType, DifficultySwitchA, DifficultySwitchB, \
AdventureOptions
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, \
AdventureAutoCollectLocation
from worlds.LauncherComponents import Component, components, SuffixIdentifier
from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max
from .Locations import location_table, base_location_id, LocationData, get_random_room_in_regions
from .Offsets import static_item_data_location, items_ram_start, static_item_element_size, item_position_table, \
static_first_dragon_index, connector_port_offset, yorgle_speed_data_location, grundle_speed_data_location, \
rhindle_speed_data_location, item_ram_addresses, start_castle_values, start_castle_offset
from .Options import DragonRandoType, DifficultySwitchA, DifficultySwitchB, AdventureOptions
from .Regions import create_regions
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, AdventureAutoCollectLocation
from .Rules import set_rules
from worlds.LauncherComponents import Component, components, SuffixIdentifier
# Adventure
components.append(Component('Adventure Client', 'AdventureClient', file_identifier=SuffixIdentifier('.apadvn')))
@@ -446,7 +435,7 @@ class AdventureWorld(World):
# end of ordered Main.py calls
def create_item(self, name: str) -> Item:
item_data: ItemData = item_table.get(name)
item_data: ItemData = item_table[name]
return AdventureItem(name, item_data.classification, item_data.id, self.player)
def create_event(self, name: str, classification: ItemClassification) -> Item:

View File

@@ -141,9 +141,12 @@ def set_dw_rules(world: "HatInTimeWorld"):
add_dw_rules(world, all_clear)
add_rule(main_stamp, main_objective.access_rule)
add_rule(all_clear, main_objective.access_rule)
# Only set bonus stamp rules if we don't auto complete bonuses
# Only set bonus stamp rules to require All Clear if we don't auto complete bonuses
if not world.options.DWAutoCompleteBonuses and not world.is_bonus_excluded(all_clear.name):
add_rule(bonus_stamps, all_clear.access_rule)
else:
# As soon as the Main Objective is completed, the bonuses auto-complete.
add_rule(bonus_stamps, main_objective.access_rule)
if world.options.DWShuffle:
for i in range(len(world.dw_shuffle)-1):
@@ -343,6 +346,7 @@ def create_enemy_events(world: "HatInTimeWorld"):
def set_enemy_rules(world: "HatInTimeWorld"):
no_tourist = "Camera Tourist" in world.excluded_dws or "Camera Tourist" in world.excluded_bonuses
difficulty = get_difficulty(world)
for enemy, regions in hit_list.items():
if no_tourist and enemy in bosses:
@@ -372,6 +376,14 @@ def set_enemy_rules(world: "HatInTimeWorld"):
or state.has("Zipline Unlock - The Lava Cake Path", world.player)
or state.has("Zipline Unlock - The Windmill Path", world.player))
elif enemy == "Toilet":
if area == "Toilet of Doom":
# The boss firewall is in the way and can only be skipped on Expert logic using a cherry hover.
add_rule(event, lambda state: has_paintings(state, world, 1, allow_skip=difficulty == Difficulty.EXPERT))
if difficulty < Difficulty.HARD:
# Hard logic and above can cross the boss arena gap with a cherry bridge.
add_rule(event, lambda state: can_use_hookshot(state, world))
elif enemy == "Director":
if area == "Dead Bird Studio Basement":
add_rule(event, lambda state: can_use_hookshot(state, world))
@@ -430,7 +442,7 @@ hit_list = {
# Bosses
"Mafia Boss": ["Down with the Mafia!", "Encore! Encore!", "Boss Rush"],
"Conductor": ["Dead Bird Studio Basement", "Killing Two Birds", "Boss Rush"],
"Director": ["Dead Bird Studio Basement", "Killing Two Birds", "Boss Rush"],
"Toilet": ["Toilet of Doom", "Boss Rush"],
"Snatcher": ["Your Contract has Expired", "Breaching the Contract", "Boss Rush",
@@ -454,7 +466,7 @@ triple_enemy_locations = [
bosses = [
"Mafia Boss",
"Conductor",
"Director",
"Toilet",
"Snatcher",
"Toxic Flower",

View File

@@ -264,7 +264,6 @@ ahit_locations = {
required_hats=[HatType.DWELLER], paintings=3),
"Subcon Forest - Tall Tree Hookshot Swing": LocData(2000324766, "Subcon Forest Area",
required_hats=[HatType.DWELLER],
hookshot=True,
paintings=3),
@@ -323,7 +322,7 @@ ahit_locations = {
"Alpine Skyline - The Twilight Path": LocData(2000334434, "Alpine Skyline Area", required_hats=[HatType.DWELLER]),
"Alpine Skyline - The Twilight Bell: Wide Purple Platform": LocData(2000336478, "The Twilight Bell"),
"Alpine Skyline - The Twilight Bell: Ice Platform": LocData(2000335826, "The Twilight Bell"),
"Alpine Skyline - Goat Outpost Horn": LocData(2000334760, "Alpine Skyline Area"),
"Alpine Skyline - Goat Outpost Horn": LocData(2000334760, "Alpine Skyline Area (TIHS)", hookshot=True),
"Alpine Skyline - Windy Passage": LocData(2000334776, "Alpine Skyline Area (TIHS)", hookshot=True),
"Alpine Skyline - The Windmill: Inside Pon Cluster": LocData(2000336395, "The Windmill"),
"Alpine Skyline - The Windmill: Entrance": LocData(2000335783, "The Windmill"),
@@ -407,7 +406,7 @@ act_completions = {
hit_type=HitType.umbrella_or_brewing, hookshot=True, paintings=1),
"Act Completion (Queen Vanessa's Manor)": LocData(2000312017, "Queen Vanessa's Manor",
hit_type=HitType.umbrella, paintings=1),
hit_type=HitType.dweller_bell, paintings=1),
"Act Completion (Mail Delivery Service)": LocData(2000312032, "Mail Delivery Service",
required_hats=[HatType.SPRINT]),
@@ -878,7 +877,7 @@ snatcher_coins = {
dlc_flags=HatDLC.death_wish),
"Snatcher Coin - Top of HQ (DW: BTH)": LocData(0, "Beat the Heat", snatcher_coin="Snatcher Coin - Top of HQ",
dlc_flags=HatDLC.death_wish),
hit_type=HitType.umbrella, dlc_flags=HatDLC.death_wish),
"Snatcher Coin - Top of Tower": LocData(0, "Mafia Town Area (HUMT)", snatcher_coin="Snatcher Coin - Top of Tower",
dlc_flags=HatDLC.death_wish),

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