Compare commits

...

81 Commits

Author SHA1 Message Date
Fabian Dill
dc8de5696b add typing 2024-11-29 23:26:45 +01:00
Fabian Dill
29591f614d Core: dynamically build loglevel_mapping 2024-11-29 23:25:20 +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
Ziktofel
0dade05133 SC2: Fix wrongly classified location type (#4249) 2024-11-26 00:35:24 +01:00
Exempt-Medic
fcaba14b62 Zillion: Add display_name to ZillionSkill #4241 2024-11-25 19:27:31 +01:00
Exempt-Medic
6073d5e37e Lufia2: Fix Nondeterministic Behavior #4243 2024-11-25 19:26:44 +01:00
Exempt-Medic
41a7d7eeee HK: Fix Nondeterministic Behavior #4244 2024-11-25 19:26:21 +01:00
Exempt-Medic
d3a3c29bc9 Landstalker: Fix Nondeterministic Behavior #4245 2024-11-25 19:25:55 +01:00
wildham
0ad5b0ade8 [FFMQ] Fix all checks sending on hard reset + stronger read validation check (#4242)
* Fix all checks sending on hard reset

* stronger validation

* Fix typo

* remove extraneous else

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

* fix style

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-25 19:25:29 +01:00
Exempt-Medic
e6e31a27e6 SC2: Fix Nondeterministic Behavior (#4246)
* Add < for sorting

* Sorting for determinism

* id instead of value
2024-11-25 19:25:00 +01:00
Scipio Wright
a650e90b57 TUNIC: Add clarifying comment to item links handling #4233 2024-11-24 18:43:28 +01:00
gaithern
36f17111bf Kingdom Hearts: Minor Logic Fixes (#4236)
* Update Rules.py

* Update worlds/kh1/Rules.py

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

* Update worlds/kh1/Rules.py

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

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2024-11-24 18:42:21 +01:00
Jarno
03b90cf39b Timespinner: Re-added missing enmemy rando option #4235 2024-11-24 15:57:39 +01:00
Scipio Wright
5729b78504 TUNIC: Fix it so item linked locations are correct in slot data (#4105)
* Fix it so item linked locations are correct in slot data

* List -> Set

* Cache the locations instead

* Just loop the multiworld once

* Move it all to fill slot data and pretend we're doing a stage

* Move groups up so it doesn't loop over the multiworld locations if no item links are present

* Update worlds/tunic/__init__.py

Co-authored-by: Mysteryem <Mysteryem@users.noreply.github.com>

---------

Co-authored-by: Mysteryem <Mysteryem@users.noreply.github.com>
2024-11-23 01:42:44 +01:00
Mysteryem
ba50c947ba AHiT: Fix reconnecting rift access regions for starting and plando acts (#4200)
Reconnecting an act in a telescope to a time rift removes the entrances
to the time rift from its access regions because it will be accessible
from the telescope instead.

By doing so early on, as a starting act with insanity act randomizer or
as a plando-ed act, this can happen before the time rift itself has been
reconnected to an act or other time rift. In which case, when later
attempting to connect that time rift to an act or other time rift, the
entrances from the rift access regions will no longer exist, so must be
re-created. The original code was mistakenly re-creating the entrances
from the time rift being reconnected, instead of from the rift access
regions.
2024-11-23 00:13:57 +01:00
digiholic
2424b79626 OSRS: Fixes to Logic errors related to Max Skill Level determining when Regions are accessible (#4188)
* Removes explicit indirect conditions

* Changes special rules function add rule instead of setting, and call it unconditionally

* Fixes issues in rule generation that have been around but unused the whole time

* Finally moves rules out into a separate file. Fixes level-related logic

* Removes redundant max skill level checks on canoes, since they're in the skill training rules now

* For some reason, canoe logic assumed you could always walk from lumbridge to south varrock without farms. This has been fixed

* Apply suggestions from code review

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

* Quests now respect skill limits and can be excluded. Tasks that take multiple skills how actually check all skills

* Adds alternative route for cooking that doesn't require fishing

* Remove debug code

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2024-11-22 16:33:27 +01:00
Mysteryem
d4b1351c99 Aquaria: Remove BaseException handling from create_item (#4218)
* Aquaria: Remove BaseException handling from create_item

Catching `BaseException` without re-raising the exception should almost
never be done because `BaseException` includes exit exceptions, such as
`SystemExit` and `KeyboardInterrupt`.

Ideally, the caught exception types should be as narrow as possible to
not mask bugs from catching unexpected exceptions. Having narrow
exception types can also help indicate to other developers what
exceptions are expected to be raisable by the code within the `try`
clause.

Similarly, the `try` clause should ideally contain the minimum code
necessary, to avoid masking bugs in the case that code within the `try`
clause that is not expected to raise an exception does so.

In this case, the only expected exception that can occur appears to be
`item_table[name]` that can raise a `KeyError` when `create_item()` is
passed an unexpected `name` argument. So this patch moves the other code
out of the `try` clause and changes the caught exception types to only
`KeyError`.

* Remove try-except

The KeyError that would be raised will be propagated as-is rather than
raising a new exception in its place.

* Remove extra newline

The original code did not have this newline, so it has been removed.
2024-11-21 20:43:37 +01:00
qwint
859ae87ec9 Launcher: ports the _stop fix in the Launcher kivy App to handle_url Popup App (#4213)
* fixes url launched popup so it can close cleanly after spawning another kivy app like text client

* whoops
2024-11-21 17:43:01 +01:00
Doug Hoskisson
124ce13da7 Core: improve error message for missing "game" entry in yaml (#4185) 2024-11-20 09:45:41 +01:00
qwint
48ea274655 MultiServer: persist hints even if previously found (#4214)
* change to persist all hints to ctx.hints regardless of found status

* remove if not found entirely as it seems like it was added to not double charge hint points
9842399d8b
2024-11-19 21:16:10 +01:00
Aaron Wagener
85a713771b Tests: have option preset validation test do full validation (#4208)
* Tests: have option preset validation test do full validation

* sum on an IntFlag is a thing apparently
2024-11-18 18:09:27 +01:00
black-sliver
3ae8992fb6 Clients: fix high CPU usage when launched via MultiProcessing (#4209)
* Core: make Utils.stream_input not consume all CPU for non-blocking streams

* Clients: ignore MultiProcessing pipe as input console
2024-11-18 15:59:17 +01:00
Scipio Wright
01c6037562 TUNIC: Fix a few missing tricks in logic (#4132)
* Add missing connection to the furnace entry by west garden

* Add missing connection to the furnace entry by west garden

* Add missing hard ls for ruined passage door

* Allow shield for LS

* Split PR into two

* Split PR into two

* Split PR into two

* Add dark tomb ice grapple through the wall
2024-11-18 14:39:58 +01:00
agilbert1412
4b80b786e2 Stardew Valley: Removed Walnutsanity and Filler buffs from the all random preset (#4206) 2024-11-18 08:45:04 +01:00
Silvris
bd5c8ec172 MM2: minor bugfixes (#4190)
* move special cases to be outside strict

* Update text.py

* fix wily machine edge case, incorrect weapons, and time stopper failsafe

* bump world version

* weakness checking is inclusive

* Update __init__.py

* add air shooter to edge case validation
2024-11-18 02:22:25 +01:00
t3hf1gm3nt
baf291d7a2 TLOZ: Assorted Logic Fixes (#4203)
* TLOZ: Assorded Logic Fixes

- Add needing arrows for Pols Voice rule. Not super necessary at the moment since wooden arrows are always accessible in one of the opening shops, but future proofing for future plans

- Create Gohma Locations and make sure all Gohma blocked locations have the required rule (was missing at least one location before)

- Remove the rule requiring Bow for all locations of level 8 (not sure why that was there, it's theoretically redundant now that Gohma and Pols Voice are properly marked)

- Make sure Digdogger locations properly require Recorder, and clean up redundant Level 7 rules as level 7 currently requires Recorder to access the entrance

* Update worlds/tloz/Rules.py

forgor that has_any exists

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

* Remove world = multiworld

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-11-18 02:19:26 +01:00
NewSoupVi
9c102da901 The Witness: Allow setting the puzzle randomization seed yourself (#4196)
* Allow setting the puzzle randomization seed yourself

* longer tooltip

* Oh

* Also actually have the correct values that the client will accept (lol, thanks Medic)

* Update worlds/witness/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-18 02:16:14 +01:00
Louis M
75e18e3cc9 Aquaria: Fixing no progression bug (#4199) 2024-11-17 16:59:50 +01:00
CaitSith2
a3d6036939 Factorio: energy link bridge improvements (#4182)
* improve energy link performance on large surfaces

* Add Energy link bridge storage table to initialization.

* Fix event based energy link for Factorio 2.0

* Adjust energy link bridge for quality.
2024-11-17 16:58:14 +01:00
Mysteryem
7eb12174b7 Core: Fix empty rule comparisons with subclasses (#4201)
If a world uses a `Location` or `Entrance` subclass that overrides the
`item_rule`/`access_rule` class attribute, then
`spot.__class__.item_rule`/`spot.__class__.access_rule` will get the
overridden rule, which may not be an empty rule.

Uses of `spot.__class__` have been replaced with getting the class
attribute rule belonging to the `Location` or `Entrance` class.
2024-11-17 16:55:42 +01:00
NewSoupVi
73146ef30c Tests: Use Option.from_any instead of Option() in test_pickle_dumps, which is currently preventing Range options from using default: "random" #4197 2024-11-17 01:52:49 +01:00
Fabian Dill
66314de965 Subnautica: compose DeathLink custom text instead of overwriting (#4172)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2024-11-17 00:55:18 +01:00
Zach "Phar" Parks
5141f36e95 WebHost: Fix 500 server errors for hints involving ItemLink slots on tracker pages (#4198)
* Also makes adjustments to the style for these slots by italicizing its names (including multi-tracker).
* Player-specific trackers do not link to ItemLink player trackers (they do not exist).
* Fixes a bug on Factorio multi-tracker when item links exist.
2024-11-16 16:16:09 +00:00
black-sliver
9ba613277e Launcher: change import order to fix ModuleUpdate (#4194) 2024-11-16 03:00:34 +01:00
178 changed files with 6365 additions and 3123 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

@@ -16,7 +16,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,14 @@ 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'
- 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 +111,10 @@ jobs:
- name: Get a recent python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.12'
- 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

View File

@@ -44,10 +44,10 @@ jobs:
- name: Get a recent python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.12'
- 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

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,7 @@ import NetUtils
import Options
import Utils
if typing.TYPE_CHECKING:
if TYPE_CHECKING:
from worlds import AutoWorld
@@ -231,7 +229,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})
@@ -975,7 +973,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 +1073,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.
@@ -1112,7 +1110,7 @@ class Region:
return exit_
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 +1120,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})'
@@ -1264,6 +1266,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 +1392,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 +1545,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
@@ -412,6 +412,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 """
@@ -551,7 +552,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],
@@ -710,6 +718,11 @@ class CommonContext:
def run_cli(self):
if sys.stdin:
if sys.stdin.fileno() != 0:
from multiprocessing import parent_process
if parent_process():
return # ignore MultiProcessing pipe
# steam overlay breaks when starting console_loop
if 'gameoverlayrenderer' in os.environ.get('LD_PRELOAD', ''):
logger.info("Skipping terminal input, due to conflicting Steam Overlay detected. Please use GUI only.")

39
Fill.py
View File

@@ -978,15 +978,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 +1015,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 +1023,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

@@ -453,6 +453,10 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
raise Exception(f"Option {option_key} has to be in a game's section, not on its own.")
ret.game = get_choice("game", weights)
if not isinstance(ret.game, str):
if ret.game is None:
raise Exception('"game" not specified')
raise Exception(f"Invalid game: {ret.game}")
if ret.game not in AutoWorldRegister.world_types:
from worlds import failed_world_loads
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]

View File

@@ -22,16 +22,15 @@ from os.path import isfile
from shutil import which
from typing import Callable, Optional, Sequence, Tuple, Union
import Utils
import settings
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \
is_windows, is_macos, is_linux
import settings
import Utils
from Utils import (init_logging, is_frozen, is_linux, is_macos, is_windows, local_path, messagebox, open_filename,
user_path)
from worlds.LauncherComponents import Component, components, icon_paths, SuffixIdentifier, Type
def open_host_yaml():
@@ -182,6 +181,11 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
App.get_running_app().stop()
Window.close()
def _stop(self, *largs):
# see run_gui Launcher _stop comment for details
self.root_window.close()
super()._stop(*largs)
Popup().run()

71
Main.py
View File

@@ -153,45 +153,38 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
# 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()
@@ -276,7 +269,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
def precollect_hint(location):
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
hint = NetUtils.Hint(location.item.player, location.player, location.address,
location.item.code, False, entrance, location.item.flags)
location.item.code, False, entrance, location.item.flags, False)
precollected_hints[location.player].add(hint)
if location.item.player not in multiworld.groups:
precollected_hints[location.item.player].add(hint)

View File

@@ -5,8 +5,8 @@ import multiprocessing
import warnings
if sys.version_info < (3, 8, 6):
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
if sys.version_info < (3, 10, 11):
raise RuntimeError(f"Incompatible Python Version found: {sys.version_info}. 3.10.11+ 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

@@ -41,7 +41,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()
@@ -228,7 +229,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
@@ -656,13 +657,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 +728,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:
@@ -727,15 +744,15 @@ class Context:
if not hint.local and data not in concerns[hint.finding_player]:
concerns[hint.finding_player].append(data)
# remember hints in all cases
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)
# 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:
@@ -749,6 +766,17 @@ class Context:
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:
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):
@@ -1050,14 +1078,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 +1096,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, slot, 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 +1155,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 +1560,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 +1586,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 +1608,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)
@@ -1832,13 +1889,51 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
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 hint.receiving_player != client.slot:
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
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"])
@@ -2143,9 +2238,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 +2274,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:

View File

@@ -29,6 +29,14 @@ class ClientStatus(ByValue, enum.IntEnum):
CLIENT_GOAL = 30
class HintStatus(enum.IntEnum):
HINT_FOUND = 0
HINT_UNSPECIFIED = 1
HINT_NO_PRIORITY = 10
HINT_AVOID = 20
HINT_PRIORITY = 30
class SlotType(ByValue, enum.IntFlag):
spectator = 0b00
player = 0b01
@@ -297,6 +305,20 @@ 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",
}
class Hint(typing.NamedTuple):
receiving_player: int
finding_player: int
@@ -305,14 +327,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 +363,8 @@ 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_text(parts, status_names.get(self.status, "(unknown)"), type="color",
color=status_colors.get(self.status, "red"))
return {"cmd": "PrintJSON", "data": parts, "type": "Hint",
"receiving": self.receiving_player,

View File

@@ -828,7 +828,10 @@ class VerifyKeys(metaclass=FreezeValidKeys):
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

View File

@@ -76,6 +76,7 @@ Currently, the following games are supported:
* Kingdom Hearts 1
* Mega Man 2
* Yacht Dice
* Faxanadu
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

@@ -18,8 +18,8 @@ import warnings
from argparse import Namespace
from settings import Settings, get_settings
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
from typing_extensions import TypeGuard
from time import sleep
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard
from yaml import load, load_all, dump
try:
@@ -47,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")
@@ -421,7 +421,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":
@@ -481,7 +482,7 @@ def get_text_after(text: str, start: str) -> str:
return text[text.index(start) + len(start):]
loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}
loglevel_mapping: dict[str, int] = {name.lower(): level for name, level in logging.getLevelNamesMapping().items()}
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
@@ -514,10 +515,13 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
return self.condition(record)
file_handler.addFilter(Filter("NoStream", lambda record: not getattr(record, "NoFile", False)))
file_handler.addFilter(Filter("NoCarriageReturn", lambda record: '\r' not in record.msg))
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)))
stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler)
# Relay unhandled exceptions to logger.
@@ -568,6 +572,8 @@ def stream_input(stream: typing.TextIO, queue: "asyncio.Queue[str]"):
else:
if text:
queue.put_nowait(text)
else:
sleep(0.01) # non-blocking stream
from threading import Thread
thread = Thread(target=queuer, name=f"Stream handler for {stream.name}", daemon=True)
@@ -852,11 +858,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):
@@ -870,10 +875,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)

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

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

View File

@@ -98,6 +98,8 @@
<td>
{% if hint.finding_player == player %}
<b>{{ player_names_with_alias[(team, hint.finding_player)] }}</b>
{% elif get_slot_info(team, hint.finding_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
{% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.finding_player) }}">
{{ player_names_with_alias[(team, hint.finding_player)] }}
@@ -107,6 +109,8 @@
<td>
{% if hint.receiving_player == player %}
<b>{{ player_names_with_alias[(team, hint.receiving_player)] }}</b>
{% elif get_slot_info(team, hint.receiving_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
{% else %}
<a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=team, tracked_player=hint.receiving_player) }}">
{{ player_names_with_alias[(team, hint.receiving_player)] }}

View File

@@ -21,8 +21,20 @@
)
-%}
<tr>
<td>{{ player_names_with_alias[(team, hint.finding_player)] }}</td>
<td>{{ player_names_with_alias[(team, hint.receiving_player)] }}</td>
<td>
{% if get_slot_info(team, hint.finding_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.finding_player)] }}</i>
{% else %}
{{ player_names_with_alias[(team, hint.finding_player)] }}
{% endif %}
</td>
<td>
{% if get_slot_info(team, hint.receiving_player).type == 2 %}
<i>{{ player_names_with_alias[(team, hint.receiving_player)] }}</i>
{% else %}
{{ player_names_with_alias[(team, hint.receiving_player)] }}
{% endif %}
</td>
<td>{{ item_id_to_name[games[(team, hint.receiving_player)]][hint.item] }}</td>
<td>{{ location_id_to_name[games[(team, hint.finding_player)]][hint.location] }}</td>
<td>{{ games[(team, hint.finding_player)] }}</td>

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

@@ -423,6 +423,7 @@ def render_generic_tracker(tracker_data: TrackerData, team: int, player: int) ->
template_name_or_list="genericTracker.html",
game_specific_tracker=game in _player_trackers,
room=tracker_data.room,
get_slot_info=tracker_data.get_slot_info,
team=team,
player=player,
player_name=tracker_data.get_room_long_player_names()[team, player],
@@ -446,6 +447,7 @@ def render_generic_multiworld_tracker(tracker_data: TrackerData, enabled_tracker
enabled_trackers=enabled_trackers,
current_tracker="Generic",
room=tracker_data.room,
get_slot_info=tracker_data.get_slot_info,
all_slots=tracker_data.get_all_slots(),
room_players=tracker_data.get_all_players(),
locations=tracker_data.get_room_locations(),
@@ -497,7 +499,7 @@ if "Factorio" in network_data_package["games"]:
(team, player): collections.Counter({
tracker_data.item_id_to_name["Factorio"][item_id]: count
for item_id, count in tracker_data.get_player_inventory_counts(team, player).items()
}) for team, players in tracker_data.get_all_slots().items() for player in players
}) for team, players in tracker_data.get_all_players().items() for player in players
if tracker_data.get_player_game(team, player) == "Factorio"
}
@@ -506,6 +508,7 @@ if "Factorio" in network_data_package["games"]:
enabled_trackers=enabled_trackers,
current_tracker="Factorio",
room=tracker_data.room,
get_slot_info=tracker_data.get_slot_info,
all_slots=tracker_data.get_all_slots(),
room_players=tracker_data.get_all_players(),
locations=tracker_data.get_room_locations(),
@@ -638,6 +641,7 @@ if "A Link to the Past" in network_data_package["games"]:
enabled_trackers=enabled_trackers,
current_tracker="A Link to the Past",
room=tracker_data.room,
get_slot_info=tracker_data.get_slot_info,
all_slots=tracker_data.get_all_slots(),
room_players=tracker_data.get_all_players(),
locations=tracker_data.get_room_locations(),

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}

View File

@@ -55,19 +55,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

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

@@ -272,6 +272,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 +343,29 @@ 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. |
#### HintStatus
An enumeration containing the possible hint states.
```python
import enum
class HintStatus(enum.IntEnum):
HINT_FOUND = 0
HINT_UNSPECIFIED = 1
HINT_NO_PRIORITY = 10
HINT_AVOID = 20
HINT_PRIORITY = 30
```
### 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)

View File

@@ -7,7 +7,7 @@ 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.15 or newer](https://www.python.org/downloads/), not the Windows Store version
* 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

View File

@@ -288,8 +288,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#L295-L296)),
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 +328,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#L298-L301),
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 +466,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
@@ -516,7 +519,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...]

101
kvui.py
View File

@@ -12,10 +12,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"
@@ -55,6 +52,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
@@ -66,7 +64,7 @@ from kivy.uix.popup import Popup
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 +301,11 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
""" Respond to the selection of items in the view. """
self.selected = is_selected
class HintLabel(RecycleDataViewBehavior, BoxLayout):
selected = BooleanProperty(False)
striped = BooleanProperty(False)
index = None
dropdown: DropDown
def __init__(self):
super(HintLabel, self).__init__()
@@ -316,10 +314,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 +351,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 +362,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 == 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 +390,16 @@ 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: 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. """
@@ -666,7 +693,7 @@ 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}"]
hints = self.ctx.stored_data.get(f"_read_hints_{self.ctx.team}_{self.ctx.slot}", [])
self.log_panels["Hints"].refresh_hints(hints)
# default F1 keybind, opens a settings menu, that seems to break the layout engine once closed
@@ -722,6 +749,22 @@ class UILog(RecycleView):
element.height = element.texture_size[1]
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",
}
class HintLog(RecycleView):
header = {
"receiving": {"text": "[u]Receiving Player[/u]"},
@@ -729,12 +772,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 +786,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 hint["receiving_player"] == ctx.slot:
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 +815,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 +829,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"""

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
@@ -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"

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

@@ -80,3 +80,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", "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

@@ -78,4 +78,4 @@ class TestOptions(unittest.TestCase):
if not world_type.hidden:
for option_key, option in world_type.options_dataclass.type_hints.items():
with self.subTest(game=gamename, option=option_key):
pickle.dumps(option(option.default))
pickle.dumps(option.from_any(option.default))

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

@@ -1,5 +1,6 @@
import unittest
from BaseClasses import PlandoOptions
from worlds import AutoWorldRegister
from Options import ItemDict, NamedRange, NumericOption, OptionList, OptionSet
@@ -14,6 +15,10 @@ class TestOptionPresets(unittest.TestCase):
with self.subTest(game=game_name, preset=preset_name, option=option_name):
try:
option = world_type.options_dataclass.type_hints[option_name].from_any(option_value)
# some options may need verification to ensure the provided option is actually valid
# pass in all plando options in case a preset wants to require certain plando options
# for some reason
option.verify(world_type, "Test Player", PlandoOptions(sum(PlandoOptions)))
supported_types = [NumericOption, OptionSet, OptionList, ItemDict]
if not any([issubclass(option.__class__, t) for t in supported_types]):
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "

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

View File

@@ -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:

View File

@@ -103,7 +103,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"

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

@@ -740,17 +740,20 @@ def is_valid_first_act(world: "HatInTimeWorld", act: Region) -> bool:
def connect_time_rift(world: "HatInTimeWorld", time_rift: Region, exit_region: Region):
i = 1
while i <= len(rift_access_regions[time_rift.name]):
for i, access_region in enumerate(rift_access_regions[time_rift.name], start=1):
# Matches the naming convention and iteration order in `create_rift_connections()`.
name = f"{time_rift.name} Portal - Entrance {i}"
entrance: Entrance
try:
entrance = world.multiworld.get_entrance(name, world.player)
entrance = world.get_entrance(name)
# Reconnect the rift access region to the new exit region.
reconnect_regions(entrance, entrance.parent_region, exit_region)
except KeyError:
time_rift.connect(exit_region, name)
i += 1
# The original entrance to the time rift has been deleted by already reconnecting a telescope act to the
# time rift, so create a new entrance from the original rift access region to the new exit region.
# Normally, acts and time rifts are sorted such that time rifts are reconnected to acts/rifts first, but
# starting acts/rifts and act-plando can reconnect acts to time rifts before this happens.
world.get_region(access_region).connect(exit_region, name)
def get_shuffleable_act_regions(world: "HatInTimeWorld") -> List[Region]:

View File

@@ -1,7 +1,7 @@
import typing
from dataclasses import dataclass
from BaseClasses import MultiWorld
from Options import Choice, Range, DeathLink, DefaultOnToggle, FreeText, ItemsAccessibility, Option, \
from Options import Choice, Range, DeathLink, DefaultOnToggle, FreeText, ItemsAccessibility, PerGameCommonOptions, \
PlandoBosses, PlandoConnections, PlandoTexts, Removed, StartInventoryPool, Toggle
from .EntranceShuffle import default_connections, default_dungeon_connections, \
inverted_default_connections, inverted_default_dungeon_connections
@@ -742,86 +742,86 @@ class ALttPPlandoTexts(PlandoTexts):
valid_keys = TextTable.valid_keys
alttp_options: typing.Dict[str, type(Option)] = {
"accessibility": ItemsAccessibility,
"plando_connections": ALttPPlandoConnections,
"plando_texts": ALttPPlandoTexts,
"start_inventory_from_pool": StartInventoryPool,
"goal": Goal,
"mode": Mode,
"glitches_required": GlitchesRequired,
"dark_room_logic": DarkRoomLogic,
"open_pyramid": OpenPyramid,
"crystals_needed_for_gt": CrystalsTower,
"crystals_needed_for_ganon": CrystalsGanon,
"triforce_pieces_mode": TriforcePiecesMode,
"triforce_pieces_percentage": TriforcePiecesPercentage,
"triforce_pieces_required": TriforcePiecesRequired,
"triforce_pieces_available": TriforcePiecesAvailable,
"triforce_pieces_extra": TriforcePiecesExtra,
"entrance_shuffle": EntranceShuffle,
"entrance_shuffle_seed": EntranceShuffleSeed,
"big_key_shuffle": big_key_shuffle,
"small_key_shuffle": small_key_shuffle,
"key_drop_shuffle": key_drop_shuffle,
"compass_shuffle": compass_shuffle,
"map_shuffle": map_shuffle,
"restrict_dungeon_item_on_boss": RestrictBossItem,
"item_pool": ItemPool,
"item_functionality": ItemFunctionality,
"enemy_health": EnemyHealth,
"enemy_damage": EnemyDamage,
"progressive": Progressive,
"swordless": Swordless,
"dungeon_counters": DungeonCounters,
"retro_bow": RetroBow,
"retro_caves": RetroCaves,
"hints": Hints,
"scams": Scams,
"boss_shuffle": LTTPBosses,
"pot_shuffle": PotShuffle,
"enemy_shuffle": EnemyShuffle,
"killable_thieves": KillableThieves,
"bush_shuffle": BushShuffle,
"shop_item_slots": ShopItemSlots,
"randomize_shop_inventories": RandomizeShopInventories,
"shuffle_shop_inventories": ShuffleShopInventories,
"include_witch_hut": IncludeWitchHut,
"randomize_shop_prices": RandomizeShopPrices,
"randomize_cost_types": RandomizeCostTypes,
"shop_price_modifier": ShopPriceModifier,
"shuffle_capacity_upgrades": ShuffleCapacityUpgrades,
"bombless_start": BomblessStart,
"shuffle_prizes": ShufflePrizes,
"tile_shuffle": TileShuffle,
"misery_mire_medallion": MiseryMireMedallion,
"turtle_rock_medallion": TurtleRockMedallion,
"glitch_boots": GlitchBoots,
"beemizer_total_chance": BeemizerTotalChance,
"beemizer_trap_chance": BeemizerTrapChance,
"timer": Timer,
"countdown_start_time": CountdownStartTime,
"red_clock_time": RedClockTime,
"blue_clock_time": BlueClockTime,
"green_clock_time": GreenClockTime,
"death_link": DeathLink,
"allow_collect": AllowCollect,
"ow_palettes": OWPalette,
"uw_palettes": UWPalette,
"hud_palettes": HUDPalette,
"sword_palettes": SwordPalette,
"shield_palettes": ShieldPalette,
# "link_palettes": LinkPalette,
"heartbeep": HeartBeep,
"heartcolor": HeartColor,
"quickswap": QuickSwap,
"menuspeed": MenuSpeed,
"music": Music,
"reduceflashing": ReduceFlashing,
"triforcehud": TriforceHud,
@dataclass
class ALTTPOptions(PerGameCommonOptions):
accessibility: ItemsAccessibility
plando_connections: ALttPPlandoConnections
plando_texts: ALttPPlandoTexts
start_inventory_from_pool: StartInventoryPool
goal: Goal
mode: Mode
glitches_required: GlitchesRequired
dark_room_logic: DarkRoomLogic
open_pyramid: OpenPyramid
crystals_needed_for_gt: CrystalsTower
crystals_needed_for_ganon: CrystalsGanon
triforce_pieces_mode: TriforcePiecesMode
triforce_pieces_percentage: TriforcePiecesPercentage
triforce_pieces_required: TriforcePiecesRequired
triforce_pieces_available: TriforcePiecesAvailable
triforce_pieces_extra: TriforcePiecesExtra
entrance_shuffle: EntranceShuffle
entrance_shuffle_seed: EntranceShuffleSeed
big_key_shuffle: big_key_shuffle
small_key_shuffle: small_key_shuffle
key_drop_shuffle: key_drop_shuffle
compass_shuffle: compass_shuffle
map_shuffle: map_shuffle
restrict_dungeon_item_on_boss: RestrictBossItem
item_pool: ItemPool
item_functionality: ItemFunctionality
enemy_health: EnemyHealth
enemy_damage: EnemyDamage
progressive: Progressive
swordless: Swordless
dungeon_counters: DungeonCounters
retro_bow: RetroBow
retro_caves: RetroCaves
hints: Hints
scams: Scams
boss_shuffle: LTTPBosses
pot_shuffle: PotShuffle
enemy_shuffle: EnemyShuffle
killable_thieves: KillableThieves
bush_shuffle: BushShuffle
shop_item_slots: ShopItemSlots
randomize_shop_inventories: RandomizeShopInventories
shuffle_shop_inventories: ShuffleShopInventories
include_witch_hut: IncludeWitchHut
randomize_shop_prices: RandomizeShopPrices
randomize_cost_types: RandomizeCostTypes
shop_price_modifier: ShopPriceModifier
shuffle_capacity_upgrades: ShuffleCapacityUpgrades
bombless_start: BomblessStart
shuffle_prizes: ShufflePrizes
tile_shuffle: TileShuffle
misery_mire_medallion: MiseryMireMedallion
turtle_rock_medallion: TurtleRockMedallion
glitch_boots: GlitchBoots
beemizer_total_chance: BeemizerTotalChance
beemizer_trap_chance: BeemizerTrapChance
timer: Timer
countdown_start_time: CountdownStartTime
red_clock_time: RedClockTime
blue_clock_time: BlueClockTime
green_clock_time: GreenClockTime
death_link: DeathLink
allow_collect: AllowCollect
ow_palettes: OWPalette
uw_palettes: UWPalette
hud_palettes: HUDPalette
sword_palettes: SwordPalette
shield_palettes: ShieldPalette
# link_palettes: LinkPalette
heartbeep: HeartBeep
heartcolor: HeartColor
quickswap: QuickSwap
menuspeed: MenuSpeed
music: Music
reduceflashing: ReduceFlashing
triforcehud: TriforceHud
# removed:
"goals": Removed,
"smallkey_shuffle": Removed,
"bigkey_shuffle": Removed,
}
goals: Removed
smallkey_shuffle: Removed
bigkey_shuffle: Removed

View File

@@ -782,8 +782,8 @@ def get_nonnative_item_sprite(code: int) -> int:
def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
local_random = world.per_slot_randoms[player]
local_world = world.worlds[player]
local_random = local_world.random
# patch items
@@ -1867,7 +1867,7 @@ def apply_oof_sfx(rom, oof: str):
def apply_rom_settings(rom, beep, color, quickswap, menuspeed, music: bool, sprite: str, oof: str, palettes_options,
world=None, player=1, allow_random_on_event=False, reduceflashing=False,
triforcehud: str = None, deathlink: bool = False, allowcollect: bool = False):
local_random = random if not world else world.per_slot_randoms[player]
local_random = random if not world else world.worlds[player].random
disable_music: bool = not music
# enable instant item menu
if menuspeed == 'instant':
@@ -2197,8 +2197,9 @@ def write_string_to_rom(rom, target, string):
def write_strings(rom, world, player):
from . import ALTTPWorld
local_random = world.per_slot_randoms[player]
w: ALTTPWorld = world.worlds[player]
local_random = w.random
tt = TextTable()
tt.removeUnwantedText()
@@ -2425,7 +2426,7 @@ def write_strings(rom, world, player):
if world.worlds[player].has_progressive_bows and (w.difficulty_requirements.progressive_bow_limit >= 2 or (
world.swordless[player] or world.glitches_required[player] == 'no_glitches')):
prog_bow_locs = world.find_item_locations('Progressive Bow', player, True)
world.per_slot_randoms[player].shuffle(prog_bow_locs)
local_random.shuffle(prog_bow_locs)
found_bow = False
found_bow_alt = False
while prog_bow_locs and not (found_bow and found_bow_alt):

View File

@@ -1,28 +1,27 @@
import logging
import os
import random
import settings
import threading
import typing
import Utils
import settings
from BaseClasses import Item, CollectionState, Tutorial, MultiWorld
from worlds.AutoWorld import World, WebWorld, LogicMixin
from .Client import ALTTPSNIClient
from .Dungeons import create_dungeons, Dungeon
from .EntranceShuffle import link_entrances, link_inverted_entrances, plando_connect
from .InvertedRegions import create_inverted_regions, mark_dark_world_regions
from .ItemPool import generate_itempool, difficulties
from .Items import item_init_table, item_name_groups, item_table, GetBeemizerItem
from .Options import alttp_options, small_key_shuffle
from .Options import ALTTPOptions, small_key_shuffle
from .Regions import lookup_name_to_id, create_regions, mark_light_world_regions, lookup_vanilla_location_to_entrance, \
is_main_entrance, key_drop_data
from .Client import ALTTPSNIClient
from .Rom import LocalRom, patch_rom, patch_race_rom, check_enemizer, patch_enemizer, apply_rom_settings, \
get_hash_string, get_base_rom_path, LttPDeltaPatch
from .Rules import set_rules
from .Shops import create_shops, Shop, push_shop_inventories, ShopType, price_rate_display, price_type_display_name
from .SubClasses import ALttPItem, LTTPRegionType
from worlds.AutoWorld import World, WebWorld, LogicMixin
from .StateHelpers import can_buy_unlimited
from .SubClasses import ALttPItem, LTTPRegionType
lttp_logger = logging.getLogger("A Link to the Past")
@@ -132,7 +131,8 @@ class ALTTPWorld(World):
Ganon!
"""
game = "A Link to the Past"
option_definitions = alttp_options
options_dataclass = ALTTPOptions
options: ALTTPOptions
settings_key = "lttp_options"
settings: typing.ClassVar[ALTTPSettings]
topology_present = True
@@ -286,13 +286,22 @@ class ALTTPWorld(World):
if not os.path.exists(rom_file):
raise FileNotFoundError(rom_file)
if multiworld.is_race:
import xxtea
import xxtea # noqa
for player in multiworld.get_game_players(cls.game):
if multiworld.worlds[player].use_enemizer:
check_enemizer(multiworld.worlds[player].enemizer_path)
break
def generate_early(self):
# write old options
import dataclasses
is_first = self.player == min(self.multiworld.get_game_players(self.game))
for field in dataclasses.fields(self.options_dataclass):
if is_first:
setattr(self.multiworld, field.name, {})
getattr(self.multiworld, field.name)[self.player] = getattr(self.options, field.name)
# end of old options re-establisher
player = self.player
multiworld = self.multiworld
@@ -536,12 +545,10 @@ class ALTTPWorld(World):
@property
def use_enemizer(self) -> bool:
world = self.multiworld
player = self.player
return bool(world.boss_shuffle[player] or world.enemy_shuffle[player]
or world.enemy_health[player] != 'default' or world.enemy_damage[player] != 'default'
or world.pot_shuffle[player] or world.bush_shuffle[player]
or world.killable_thieves[player])
return bool(self.options.boss_shuffle or self.options.enemy_shuffle
or self.options.enemy_health != 'default' or self.options.enemy_damage != 'default'
or self.options.pot_shuffle or self.options.bush_shuffle
or self.options.killable_thieves)
def generate_output(self, output_directory: str):
multiworld = self.multiworld

View File

@@ -0,0 +1,32 @@
# A Link to the Past
## Où se trouve la page des paramètres ?
La [page des paramètres du joueur pour ce jeu](../player-options) contient tous les paramètres dont vous avez besoin
pour configurer et exporter le fichier.
## Quel est l'effet de la randomisation sur ce jeu ?
Les objets que le joueur devrait normalement obtenir au cours du jeu ont été déplacés. Il y a tout de même une logique
pour que le jeu puisse être terminé, mais dû au mélange des objets, le joueur peut avoir besoin d'accéder à certaines
zones plus tôt que dans le jeu original.
## Quels sont les objets et endroits mélangés ?
Tous les objets principaux, les collectibles et munitions peuvent être mélangés, et tous les endroits qui
pourraient contenir un de ces objets peuvent avoir leur contenu modifié.
## Quels objets peuvent être dans le monde d'un autre joueur ?
Un objet pouvant être mélangé peut être aussi placé dans le monde d'un autre joueur. Il est possible de limiter certains
objets à votre propre monde.
## À quoi ressemble un objet d'un autre monde dans LttP ?
Les objets appartenant à d'autres mondes sont représentés par une Étoile de Super Mario World.
## Quand le joueur reçoit un objet, que ce passe-t-il ?
Quand le joueur reçoit un objet, Link montrera l'objet au monde en le mettant au-dessus de sa tête. C'est bon pour
les affaires !

View File

@@ -1,41 +1,28 @@
# Guide d'installation du MultiWorld de A Link to the Past Randomizer
<div id="tutorial-video-container">
<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/mJKEHaiyR_Y" frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" allowfullscreen>
</iframe>
</div>
## Logiciels requis
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases)
- [QUsb2Snes](https://github.com/Skarsnik/QUsb2snes/releases) (Inclus dans les utilitaires précédents)
- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases).
- [SNI](https://github.com/alttpo/sni/releases). Inclus avec l'installation d'Archipelago ci-dessus.
- SNI n'est pas compatible avec (Q)Usb2Snes.
- Une solution logicielle ou matérielle capable de charger et de lancer des fichiers ROM de SNES
- Un émulateur capable d'éxécuter des scripts Lua
([snes9x rr](https://github.com/gocha/snes9x-rr/releases),
[BizHawk](https://tasvideos.org/BizHawk))
- Un SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), ou une autre solution matérielle
compatible
- Le fichier ROM de la v1.0 japonaise, sûrement nommé `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
- Un émulateur capable de se connecter à SNI
[snes9x-nwa](https://github.com/Skarsnik/snes9x-emunwa/releases), ([snes9x rr](https://github.com/gocha/snes9x-rr/releases),
[BSNES-plus](https://github.com/black-sliver/bsnes-plus),
[BizHawk](https://tasvideos.org/BizHawk), ou
[RetroArch](https://retroarch.com?page=platforms) 1.10.1 ou plus récent). Ou,
- Un SD2SNES, [FXPak Pro](https://krikzz.com/store/home/54-fxpak-pro.html), ou une autre solution matérielle compatible. **À noter:
les SNES minis ne sont pas encore supportés par SNI. Certains utilisateurs rapportent avoir du succès avec QUsb2Snes pour ce système,
mais ce n'est pas supporté.**
- Le fichier ROM de la v1.0 japonaise, habituellement nommé `Zelda no Densetsu - Kamigami no Triforce (Japan).sfc`
## Procédure d'installation
### Installation sur Windows
1. Téléchargez et installez [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases). **L'installateur se situe dans la section "assets" en bas des informations de version**.
2. Si c'est la première fois que vous faites une génération locale ou un patch, il vous sera demandé votre fichier ROM de base. Il s'agit de votre fichier ROM Link to the Past japonais. Cet étape n'a besoin d'être faite qu'une seule fois.
1. Téléchargez et installez les utilitaires du MultiWorld à l'aide du lien au-dessus, faites attention à bien installer
la version la plus récente.
**Le fichier se situe dans la section "assets" en bas des informations de version**. Si vous voulez jouer des parties
classiques de multiworld, téléchargez `Setup.BerserkerMultiWorld.exe`
- Si vous voulez jouer à la version alternative avec le mélangeur de portes dans les donjons, vous téléchargez le
fichier
`Setup.BerserkerMultiWorld.Doors.exe`.
- Durant le processus d'installation, il vous sera demandé de localiser votre ROM v1.0 japonaise. Si vous avez déjà
installé le logiciel auparavant et qu'il s'agit simplement d'une mise à jour, la localisation de la ROM originale
ne sera pas requise.
- Il vous sera peut-être également demandé d'installer Microsoft Visual C++. Si vous le possédez déjà (possiblement
parce qu'un jeu Steam l'a déjà installé), l'installateur ne reproposera pas de l'installer.
2. Si vous utilisez un émulateur, il est recommandé d'assigner votre émulateur capable d'éxécuter des scripts Lua comme
3. Si vous utilisez un émulateur, il est recommandé d'assigner votre émulateur capable d'éxécuter des scripts Lua comme
programme par défaut pour ouvrir vos ROMs.
1. Extrayez votre dossier d'émulateur sur votre Bureau, ou à un endroit dont vous vous souviendrez.
2. Faites un clic droit sur un fichier ROM et sélectionnez **Ouvrir avec...**
@@ -44,58 +31,6 @@
5. Naviguez dans les dossiers jusqu'au fichier `.exe` de votre émulateur et choisissez **Ouvrir**. Ce fichier
devrait se trouver dans le dossier que vous avez extrait à la première étape.
### Installation sur Mac
- Des volontaires sont recherchés pour remplir cette section ! Contactez **Farrak Kilhn** sur Discord si vous voulez
aider.
## Configurer son fichier YAML
### Qu'est-ce qu'un fichier YAML et pourquoi en ai-je besoin ?
Votre fichier YAML contient un ensemble d'options de configuration qui fournissent au générateur des informations sur
comment il devrait générer votre seed. Chaque joueur d'un multiwolrd devra fournir son propre fichier YAML. Cela permet
à chaque joueur d'apprécier une expérience customisée selon ses goûts, et les différents joueurs d'un même multiworld
peuvent avoir différentes options.
### Où est-ce que j'obtiens un fichier YAML ?
La page [Génération de partie](/games/A%20Link%20to%20the%20Past/player-options) vous permet de configurer vos
paramètres personnels et de les exporter vers un fichier YAML.
### Configuration avancée du fichier YAML
Une version plus avancée du fichier YAML peut être créée en utilisant la page
des [paramètres de pondération](/games/A Link to the Past/weighted-options), qui vous permet de configurer jusqu'à
trois préréglages. Cette page a de nombreuses options qui sont essentiellement représentées avec des curseurs
glissants. Cela vous permet de choisir quelles sont les chances qu'une certaine option apparaisse par rapport aux
autres disponibles dans une même catégorie.
Par exemple, imaginez que le générateur crée un seau étiqueté "Mélange des cartes", et qu'il place un morceau de papier
pour chaque sous-option. Imaginez également que la valeur pour "On" est 20 et la valeur pour "Off" est 40.
Dans cet exemple, il y a soixante morceaux de papier dans le seau : vingt pour "On" et quarante pour "Off". Quand le
générateur décide s'il doit oui ou non activer le mélange des cartes pour votre partie, , il tire aléatoirement un
papier dans le seau. Dans cet exemple, il y a de plus grandes chances d'avoir le mélange de cartes désactivé.
S'il y a une option dont vous ne voulez jamais, mettez simplement sa valeur à zéro. N'oubliez pas qu'il faut que pour
chaque paramètre il faut au moins une option qui soit paramétrée sur un nombre strictement positif.
### Vérifier son fichier YAML
Si vous voulez valider votre fichier YAML pour être sûr qu'il fonctionne, vous pouvez le vérifier sur la page du
[Validateur de YAML](/check).
## Générer une partie pour un joueur
1. Aller sur la page [Génération de partie](/games/A%20Link%20to%20the%20Past/player-options), configurez vos options,
et cliquez sur le bouton "Generate Game".
2. Il vous sera alors présenté une page d'informations sur la seed, où vous pourrez télécharger votre patch.
3. Double-cliquez sur le patch et l'émulateur devrait se lancer automatiquement avec la seed. Etant donné que le client
n'est pas requis pour les parties à un joueur, vous pouvez le fermer ainsi que l'interface Web (WebUI).
## Rejoindre un MultiWorld
### Obtenir son patch et créer sa ROM
Quand vous rejoignez un multiworld, il vous sera demandé de fournir votre fichier YAML à celui qui héberge la partie ou
@@ -109,35 +44,58 @@ automatiquement le client, et devrait créer la ROM dans le même dossier que vo
#### Avec un émulateur
Quand le client se lance automatiquement, QUsb2Snes devrait se lancer automatiquement également en arrière-plan. Si
Quand le client se lance automatiquement, SNI devrait se lancer automatiquement également en arrière-plan. Si
c'est la première fois qu'il démarre, il vous sera peut-être demandé de l'autoriser à communiquer à travers le pare-feu
Windows.
#### snes9x-nwa
1. Cliquez sur 'Network Menu' et cochez **Enable Emu Network Control**
2. Chargez votre ROM si ce n'est pas déjà fait.
##### snes9x-rr
1. Chargez votre ROM si ce n'est pas déjà fait.
2. Cliquez sur le menu "File" et survolez l'option **Lua Scripting**
3. Cliquez alors sur **New Lua Script Window...**
4. Dans la nouvelle fenêtre, sélectionnez **Browse...**
5. Dirigez vous vers le dossier où vous avez extrait snes9x-rr, allez dans le dossier `lua`, puis
choisissez `multibridge.lua`
6. Remarquez qu'un nom vous a été assigné, et que l'interface Web affiche "SNES Device: Connected", avec ce même nom
dans le coin en haut à gauche.
5. Sélectionnez le fichier lua connecteur inclus avec votre client
- Recherchez `/SNI/lua/` dans votre fichier Archipelago.
6. Si vous avez une erreur en chargeant le script indiquant `socket.dll missing` ou similaire, naviguez vers le fichier du
lua que vous utilisez dans votre explorateur de fichiers et copiez le `socket.dll` à la base de votre installation snes9x.
#### BSNES-Plus
1. Chargez votre ROM si ce n'est pas déjà fait.
2. L'émulateur devrait automatiquement se connecter lorsque SNI se lancera.
##### BizHawk
1. Assurez vous d'avoir le coeur BSNES chargé. Cela est possible en cliquant sur le menu "Tools" de BizHawk et suivant
1. Assurez vous d'avoir le cœur BSNES chargé. Cela est possible en cliquant sur le menu "Tools" de BizHawk et suivant
ces options de menu :
`Config --> Cores --> SNES --> BSNES`
Une fois le coeur changé, vous devez redémarrer BizHawk.
- (≤ 2.8) `Config``Cores``SNES``BSNES`
- (≥ 2.9) `Config``Preferred Cores``SNES``BSNESv115+`
Une fois le cœur changé, rechargez le avec Ctrl+R (par défaut).
2. Chargez votre ROM si ce n'est pas déjà fait.
3. Cliquez sur le menu "Tools" et cliquez sur **Lua Console**
4. Cliquez sur le bouton pour ouvrir un nouveau script Lua.
5. Dirigez vous vers le dossier d'installation des utilitaires du MultiWorld, puis dans les dossiers suivants :
`QUsb2Snes/Qusb2Snes/LuaBridge`
6. Sélectionnez `luabridge.lua` et cliquez sur "Open".
7. Remarquez qu'un nom vous a été assigné, et que l'interface Web affiche "SNES Device: Connected", avec ce même nom
dans le coin en haut à gauche.
3. Glissez et déposez le fichier `Connector.lua` que vous avez téléchargé ci-dessus sur la fenêtre principale EmuHawk.
- Recherchez `/SNI/lua/` dans votre fichier Archipelago.
- Vous pouvez aussi ouvrir la console Lua manuellement, cliquez sur `Script``Open Script`, et naviguez sur `Connecteur.lua`
avec le sélecteur de fichiers.
##### RetroArch 1.10.1 ou plus récent
Vous n'avez qu'à faire ces étapes qu'une fois.
1. Entrez dans le menu principal RetroArch
2. Allez dans Réglages --> Interface utilisateur. Mettez "Afficher les réglages avancés" sur ON.
3. Allez dans Réglages --> Réseau. Mettez "Commandes Réseau" sur ON. (trouvé sous Request Device 16.) Laissez le
Port des commandes réseau à 555355.
![Screenshot of Network Commands setting](/static/generated/docs/A%20Link%20to%20the%20Past/retroarch-network-commands-fr.png)
4. Allez dans Menu Principal --> Mise à jour en ligne --> Téléchargement de cœurs. Descendez jusqu'a"Nintendo - SNES / SFC (bsnes-mercury Performance)" et
sélectionnez le.
Quand vous chargez une ROM, veillez a sélectionner un cœur **bsnes-mercury**. Ce sont les seuls cœurs qui autorisent les outils externs à lire les données d'une ROM.
#### Avec une solution matérielle
@@ -147,10 +105,7 @@ le maintenant. Les utilisateurs de SD2SNES et de FXPak Pro peuvent télécharger
[sur cette page](http://usb2snes.com/#supported-platforms).
1. Fermez votre émulateur, qui s'est potentiellement lancé automatiquement.
2. Fermez QUsb2Snes, qui s'est lancé automatiquement avec le client.
3. Lancez la version appropriée de QUsb2Snes (v0.7.16).
4. Lancer votre console et chargez la ROM.
5. Remarquez que l'interface Web affiche "SNES Device: Connected", avec le nom de votre appareil.
2. Lancez votre console et chargez la ROM.
### Se connecter au MultiServer
@@ -165,47 +120,6 @@ l'interface Web.
### Jouer au jeu
Une fois que l'interface Web affiche que la SNES et le serveur sont connectés, vous êtes prêt à jouer. Félicitations
pour avoir rejoint un multiworld !
## Héberger un MultiWorld
La méthode recommandée pour héberger une partie est d'utiliser le service d'hébergement fourni par
[le site](https://berserkermulti.world/generate). Le processus est relativement simple :
1. Récupérez les fichiers YAML des joueurs.
2. Créez une archive zip contenant ces fichiers YAML.
3. Téléversez l'archive zip sur le lien ci-dessus.
4. Attendez un moment que les seed soient générées.
5. Lorsque les seeds sont générées, vous serez redirigé vers une page d'informations "Seed Info".
6. Cliquez sur "Create New Room". Cela vous amènera à la page du serveur. Fournissez le lien de cette page aux autres
joueurs afin qu'ils puissent récupérer leurs patchs.
**Note:** Les patchs fournis sur cette page permettront aux joueurs de se connecteur automatiquement au serveur,
tandis que ceux de la page "Seed Info" non.
7. Remarquez qu'un lien vers le traqueur du MultiWorld est en haut de la page de la salle. Vous devriez également
fournir ce lien aux joueurs pour qu'ils puissent suivre la progression de la partie. N'importe quel personne voulant
observer devrait avoir accès à ce lien.
8. Une fois que tous les joueurs ont rejoint, vous pouvez commencer à jouer.
## Auto-tracking
Si vous voulez utiliser l'auto-tracking, plusieurs logiciels offrent cette possibilité.
Le logiciel recommandé pour l'auto-tracking actuellement est
[OpenTracker](https://github.com/trippsc2/OpenTracker/releases).
### Installation
1. Téléchargez le fichier d'installation approprié pour votre ordinateur (Les utilisateurs Windows voudront le
fichier `.msi`).
2. Durant le processus d'installation, il vous sera peut-être demandé d'installer les outils "Microsoft Visual Studio
Build Tools". Un lien est fourni durant l'installation d'OpenTracker, et celle des outils doit se faire manuellement.
### Activer l'auto-tracking
1. Une fois OpenTracker démarré, cliquez sur le menu "Tracking" en haut de la fenêtre, puis choisissez **
AutoTracker...**
2. Appuyez sur le bouton **Get Devices**
3. Sélectionnez votre appareil SNES dans la liste déroulante.
4. Si vous voulez tracquer les petites clés ainsi que les objets des donjons, cochez la case **Race Illegal Tracking**
5. Cliquez sur le bouton **Start Autotracking**
6. Fermez la fenêtre "AutoTracker" maintenant, elle n'est plus nécessaire
Une fois que l'interface Web affiche que la SNES et le serveur sont connectés, vous êtes prêt à jouer. Félicitations,
vous venez de rejoindre un multiworld ! Vous pouvez exécuter différentes commandes dans votre client. Pour plus d'informations
sur ces commandes, vous pouvez utiliser `/help` pour les commandes locales et `!help` pour les commandes serveur.

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1152,79 +1152,79 @@ class AquariaRegions:
def __no_progression_hard_or_hidden_location(self) -> None:
self.multiworld.get_location("Energy Temple boss area, Fallen God Tooth",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Mithalas boss area, beating Mithalan God",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Kelp Forest boss area, beating Drunian God",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Sun Temple boss area, beating Sun God",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Sunken City, bulb on top of the boss area",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Home Water, Nautilus Egg",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Energy Temple blaster room, Blaster Egg",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Mithalas City Castle, beating the Priests",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Mermog cave, Piranha Egg",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Octopus Cave, Dumbo Egg",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("King Jellyfish Cave, bulb in the right path from King Jelly",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("King Jellyfish Cave, Jellyfish Costume",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Final Boss area, bulb in the boss third form room",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Sun Worm path, first cliff bulb",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Sun Worm path, second cliff bulb",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("The Veil top right area, bulb at the top of the waterfall",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Bubble Cave, bulb in the left cave wall",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Bubble Cave, bulb in the right cave wall (behind the ice crystal)",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Bubble Cave, Verse Egg",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Kelp Forest bottom left area, bulb close to the spirit crystals",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Kelp Forest bottom left area, Walker Baby",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Sun Temple, Sun Key",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("The Body bottom area, Mutant Costume",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Sun Temple, bulb in the hidden room of the right part",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
self.multiworld.get_location("Arnassi Ruins, Arnassi Armor",
self.player).item_rule = \
lambda item: item.classification != ItemClassification.progression
lambda item: not item.advancement
def adjusting_rules(self, options: AquariaOptions) -> None:
"""

View File

@@ -117,16 +117,13 @@ class AquariaWorld(World):
Create an AquariaItem using 'name' as item name.
"""
result: AquariaItem
try:
data = item_table[name]
classification: ItemClassification = ItemClassification.useful
if data.type == ItemType.JUNK:
classification = ItemClassification.filler
elif data.type == ItemType.PROGRESSION:
classification = ItemClassification.progression
result = AquariaItem(name, classification, data.id, self.player)
except BaseException:
raise Exception('The item ' + name + ' is not valid.')
data = item_table[name]
classification: ItemClassification = ItemClassification.useful
if data.type == ItemType.JUNK:
classification = ItemClassification.filler
elif data.type == ItemType.PROGRESSION:
classification = ItemClassification.progression
result = AquariaItem(name, classification, data.id, self.player)
return result

View File

@@ -49,7 +49,7 @@ class UNoProgressionHardHiddenTest(AquariaTestBase):
for location in self.unfillable_locations:
for item_name in self.world.item_names:
item = self.get_item_by_name(item_name)
if item.classification == ItemClassification.progression:
if item.advancement:
self.assertFalse(
self.world.get_location(location).can_fill(self.multiworld.state, item, False),
"The location \"" + location + "\" can be filled with \"" + item_name + "\"")

View File

@@ -684,38 +684,37 @@ class CV64PatchExtensions(APPatchExtension):
# Disable the 3HBs checking and setting flags when breaking them and enable their individual items checking and
# setting flags instead.
if options["multi_hit_breakables"]:
rom_data.write_int32(0xE87F8, 0x00000000) # NOP
rom_data.write_int16(0xE836C, 0x1000)
rom_data.write_int32(0xE8B40, 0x0C0FF3CD) # JAL 0x803FCF34
rom_data.write_int32s(0xBFCF34, patches.three_hit_item_flags_setter)
# Villa foyer chandelier-specific functions (yeah, IDK why KCEK made different functions for this one)
rom_data.write_int32(0xE7D54, 0x00000000) # NOP
rom_data.write_int16(0xE7908, 0x1000)
rom_data.write_byte(0xE7A5C, 0x10)
rom_data.write_int32(0xE7F08, 0x0C0FF3DF) # JAL 0x803FCF7C
rom_data.write_int32s(0xBFCF7C, patches.chandelier_item_flags_setter)
rom_data.write_int32(0xE87F8, 0x00000000) # NOP
rom_data.write_int16(0xE836C, 0x1000)
rom_data.write_int32(0xE8B40, 0x0C0FF3CD) # JAL 0x803FCF34
rom_data.write_int32s(0xBFCF34, patches.three_hit_item_flags_setter)
# Villa foyer chandelier-specific functions (yeah, IDK why KCEK made different functions for this one)
rom_data.write_int32(0xE7D54, 0x00000000) # NOP
rom_data.write_int16(0xE7908, 0x1000)
rom_data.write_byte(0xE7A5C, 0x10)
rom_data.write_int32(0xE7F08, 0x0C0FF3DF) # JAL 0x803FCF7C
rom_data.write_int32s(0xBFCF7C, patches.chandelier_item_flags_setter)
# New flag values to put in each 3HB vanilla flag's spot
rom_data.write_int32(0x10C7C8, 0x8000FF48) # FoS dirge maiden rock
rom_data.write_int32(0x10C7B0, 0x0200FF48) # FoS S1 bridge rock
rom_data.write_int32(0x10C86C, 0x0010FF48) # CW upper rampart save nub
rom_data.write_int32(0x10C878, 0x4000FF49) # CW Dracula switch slab
rom_data.write_int32(0x10CAD8, 0x0100FF49) # Tunnel twin arrows slab
rom_data.write_int32(0x10CAE4, 0x0004FF49) # Tunnel lonesome bucket pit rock
rom_data.write_int32(0x10CB54, 0x4000FF4A) # UW poison parkour ledge
rom_data.write_int32(0x10CB60, 0x0080FF4A) # UW skeleton crusher ledge
rom_data.write_int32(0x10CBF0, 0x0008FF4A) # CC Behemoth crate
rom_data.write_int32(0x10CC2C, 0x2000FF4B) # CC elevator pedestal
rom_data.write_int32(0x10CC70, 0x0200FF4B) # CC lizard locker slab
rom_data.write_int32(0x10CD88, 0x0010FF4B) # ToE pre-midsavepoint platforms ledge
rom_data.write_int32(0x10CE6C, 0x4000FF4C) # ToSci invisible bridge crate
rom_data.write_int32(0x10CF20, 0x0080FF4C) # CT inverted battery slab
rom_data.write_int32(0x10CF2C, 0x0008FF4C) # CT inverted door slab
rom_data.write_int32(0x10CF38, 0x8000FF4D) # CT final room door slab
rom_data.write_int32(0x10CF44, 0x1000FF4D) # CT Renon slab
rom_data.write_int32(0x10C908, 0x0008FF4D) # Villa foyer chandelier
rom_data.write_byte(0x10CF37, 0x04) # pointer for CT final room door slab item data
# New flag values to put in each 3HB vanilla flag's spot
rom_data.write_int32(0x10C7C8, 0x8000FF48) # FoS dirge maiden rock
rom_data.write_int32(0x10C7B0, 0x0200FF48) # FoS S1 bridge rock
rom_data.write_int32(0x10C86C, 0x0010FF48) # CW upper rampart save nub
rom_data.write_int32(0x10C878, 0x4000FF49) # CW Dracula switch slab
rom_data.write_int32(0x10CAD8, 0x0100FF49) # Tunnel twin arrows slab
rom_data.write_int32(0x10CAE4, 0x0004FF49) # Tunnel lonesome bucket pit rock
rom_data.write_int32(0x10CB54, 0x4000FF4A) # UW poison parkour ledge
rom_data.write_int32(0x10CB60, 0x0080FF4A) # UW skeleton crusher ledge
rom_data.write_int32(0x10CBF0, 0x0008FF4A) # CC Behemoth crate
rom_data.write_int32(0x10CC2C, 0x2000FF4B) # CC elevator pedestal
rom_data.write_int32(0x10CC70, 0x0200FF4B) # CC lizard locker slab
rom_data.write_int32(0x10CD88, 0x0010FF4B) # ToE pre-midsavepoint platforms ledge
rom_data.write_int32(0x10CE6C, 0x4000FF4C) # ToSci invisible bridge crate
rom_data.write_int32(0x10CF20, 0x0080FF4C) # CT inverted battery slab
rom_data.write_int32(0x10CF2C, 0x0008FF4C) # CT inverted door slab
rom_data.write_int32(0x10CF38, 0x8000FF4D) # CT final room door slab
rom_data.write_int32(0x10CF44, 0x1000FF4D) # CT Renon slab
rom_data.write_int32(0x10C908, 0x0008FF4D) # Villa foyer chandelier
rom_data.write_byte(0x10CF37, 0x04) # pointer for CT final room door slab item data
# Once-per-frame gameplay checks
rom_data.write_int32(0x6C848, 0x080FF40D) # J 0x803FD034

View File

@@ -72,8 +72,16 @@ class DLCqworld(World):
self.multiworld.itempool += created_items
if self.options.campaign == Options.Campaign.option_basic or self.options.campaign == Options.Campaign.option_both:
self.multiworld.early_items[self.player]["Movement Pack"] = 1
campaign = self.options.campaign
has_both = campaign == Options.Campaign.option_both
has_base = campaign == Options.Campaign.option_basic or has_both
has_big_bundles = self.options.coinsanity and self.options.coinbundlequantity > 50
early_items = self.multiworld.early_items
if has_base:
if has_both and has_big_bundles:
early_items[self.player]["Incredibly Important Pack"] = 1
else:
early_items[self.player]["Movement Pack"] = 1
for item in items_to_exclude:
if item in self.multiworld.itempool:
@@ -82,7 +90,7 @@ class DLCqworld(World):
def precollect_coinsanity(self):
if self.options.campaign == Options.Campaign.option_basic:
if self.options.coinsanity == Options.CoinSanity.option_coin and self.options.coinbundlequantity >= 5:
self.multiworld.push_precollected(self.create_item("Movement Pack"))
self.multiworld.push_precollected(self.create_item("DLC Quest: Coin Bundle"))
def create_item(self, item: Union[str, ItemData], classification: ItemClassification = None) -> DLCQuestItem:
if isinstance(item, str):

View File

@@ -112,7 +112,7 @@ class StartWithComputerAreaMaps(Toggle):
class ResetLevelOnDeath(DefaultOnToggle):
"""When dying, levels are reset and monsters respawned. But inventory and checks are kept.
Turning this setting off is considered easy mode. Good for new players that don't know the levels well."""
display_name="Reset Level on Death"
display_name = "Reset Level on Death"
class Episode1(DefaultOnToggle):

View File

@@ -102,7 +102,7 @@ class StartWithComputerAreaMaps(Toggle):
class ResetLevelOnDeath(DefaultOnToggle):
"""When dying, levels are reset and monsters respawned. But inventory and checks are kept.
Turning this setting off is considered easy mode. Good for new players that don't know the levels well."""
display_message="Reset level on death"
display_name = "Reset Level on Death"
class Episode1(DefaultOnToggle):

View File

@@ -105,8 +105,8 @@ function on_player_changed_position(event)
end
local target_direction = exit_table[outbound_direction]
local target_position = {(CHUNK_OFFSET[target_direction][1] + last_x_chunk) * 32 + 16,
(CHUNK_OFFSET[target_direction][2] + last_y_chunk) * 32 + 16}
local target_position = {(CHUNK_OFFSET[target_direction][1] + last_x_chunk) * 32 + 16,
(CHUNK_OFFSET[target_direction][2] + last_y_chunk) * 32 + 16}
target_position = character.surface.find_non_colliding_position(character.prototype.name,
target_position, 32, 0.5)
if target_position ~= nil then
@@ -134,40 +134,96 @@ end
script.on_event(defines.events.on_player_changed_position, on_player_changed_position)
{% endif %}
function count_energy_bridges()
local count = 0
for i, bridge in pairs(storage.energy_link_bridges) do
if validate_energy_link_bridge(i, bridge) then
count = count + 1 + (bridge.quality.level * 0.3)
end
end
return count
end
function get_energy_increment(bridge)
return ENERGY_INCREMENT + (ENERGY_INCREMENT * 0.3 * bridge.quality.level)
end
function on_check_energy_link(event)
--- assuming 1 MJ increment and 5MJ battery:
--- first 2 MJ request fill, last 2 MJ push energy, middle 1 MJ does nothing
if event.tick % 60 == 30 then
local surface = game.get_surface(1)
local force = "player"
local bridges = surface.find_entities_filtered({name="ap-energy-bridge", force=force})
local bridgecount = table_size(bridges)
local bridges = storage.energy_link_bridges
local bridgecount = count_energy_bridges()
storage.forcedata[force].energy_bridges = bridgecount
if storage.forcedata[force].energy == nil then
storage.forcedata[force].energy = 0
end
if storage.forcedata[force].energy < ENERGY_INCREMENT * bridgecount * 5 then
for i, bridge in ipairs(bridges) do
if bridge.energy > ENERGY_INCREMENT*3 then
storage.forcedata[force].energy = storage.forcedata[force].energy + (ENERGY_INCREMENT * ENERGY_LINK_EFFICIENCY)
bridge.energy = bridge.energy - ENERGY_INCREMENT
for i, bridge in pairs(bridges) do
if validate_energy_link_bridge(i, bridge) then
energy_increment = get_energy_increment(bridge)
if bridge.energy > energy_increment*3 then
storage.forcedata[force].energy = storage.forcedata[force].energy + (energy_increment * ENERGY_LINK_EFFICIENCY)
bridge.energy = bridge.energy - energy_increment
end
end
end
end
for i, bridge in ipairs(bridges) do
if storage.forcedata[force].energy < ENERGY_INCREMENT then
break
end
if bridge.energy < ENERGY_INCREMENT*2 and storage.forcedata[force].energy > ENERGY_INCREMENT then
storage.forcedata[force].energy = storage.forcedata[force].energy - ENERGY_INCREMENT
bridge.energy = bridge.energy + ENERGY_INCREMENT
for i, bridge in pairs(bridges) do
if validate_energy_link_bridge(i, bridge) then
energy_increment = get_energy_increment(bridge)
if storage.forcedata[force].energy < energy_increment and bridge.quality.level == 0 then
break
end
if bridge.energy < energy_increment*2 and storage.forcedata[force].energy > energy_increment then
storage.forcedata[force].energy = storage.forcedata[force].energy - energy_increment
bridge.energy = bridge.energy + energy_increment
end
end
end
end
end
function string_starts_with(str, start)
return str:sub(1, #start) == start
end
function validate_energy_link_bridge(unit_number, entity)
if not entity then
if storage.energy_link_bridges[unit_number] == nil then return false end
storage.energy_link_bridges[unit_number] = nil
return false
end
if not entity.valid then
if storage.energy_link_bridges[unit_number] == nil then return false end
storage.energy_link_bridges[unit_number] = nil
return false
end
return true
end
function on_energy_bridge_constructed(entity)
if entity and entity.valid then
if string_starts_with(entity.prototype.name, "ap-energy-bridge") then
storage.energy_link_bridges[entity.unit_number] = entity
end
end
end
function on_energy_bridge_removed(entity)
if string_starts_with(entity.prototype.name, "ap-energy-bridge") then
if storage.energy_link_bridges[entity.unit_number] == nil then return end
storage.energy_link_bridges[entity.unit_number] = nil
end
end
if (ENERGY_INCREMENT) then
script.on_event(defines.events.on_tick, on_check_energy_link)
script.on_event({defines.events.on_built_entity}, function(event) on_energy_bridge_constructed(event.entity) end)
script.on_event({defines.events.on_robot_built_entity}, function(event) on_energy_bridge_constructed(event.entity) end)
script.on_event({defines.events.on_entity_cloned}, function(event) on_energy_bridge_constructed(event.destination) end)
script.on_event({defines.events.script_raised_revive}, function(event) on_energy_bridge_constructed(event.entity) end)
script.on_event({defines.events.script_raised_built}, function(event) on_energy_bridge_constructed(event.entity) end)
script.on_event({defines.events.on_entity_died}, function(event) on_energy_bridge_removed(event.entity) end)
script.on_event({defines.events.on_player_mined_entity}, function(event) on_energy_bridge_removed(event.entity) end)
script.on_event({defines.events.on_robot_mined_entity}, function(event) on_energy_bridge_removed(event.entity) end)
end
{% if not imported_blueprints -%}
@@ -410,6 +466,7 @@ script.on_init(function()
{% if not imported_blueprints %}set_permissions(){% endif %}
storage.forcedata = {}
storage.playerdata = {}
storage.energy_link_bridges = {}
-- Fire dummy events for all currently existing forces.
local e = {}
for name, _ in pairs(game.forces) do

58
worlds/faxanadu/Items.py Normal file
View File

@@ -0,0 +1,58 @@
from BaseClasses import ItemClassification
from typing import List, Optional
class ItemDef:
def __init__(self,
id: Optional[int],
name: str,
classification: ItemClassification,
count: int,
progression_count: int,
prefill_location: Optional[str]):
self.id = id
self.name = name
self.classification = classification
self.count = count
self.progression_count = progression_count
self.prefill_location = prefill_location
items: List[ItemDef] = [
ItemDef(400000, 'Progressive Sword', ItemClassification.progression, 4, 0, None),
ItemDef(400001, 'Progressive Armor', ItemClassification.progression, 3, 0, None),
ItemDef(400002, 'Progressive Shield', ItemClassification.useful, 4, 0, None),
ItemDef(400003, 'Spring Elixir', ItemClassification.progression, 1, 0, None),
ItemDef(400004, 'Mattock', ItemClassification.progression, 1, 0, None),
ItemDef(400005, 'Unlock Wingboots', ItemClassification.progression, 1, 0, None),
ItemDef(400006, 'Key Jack', ItemClassification.progression, 1, 0, None),
ItemDef(400007, 'Key Queen', ItemClassification.progression, 1, 0, None),
ItemDef(400008, 'Key King', ItemClassification.progression, 1, 0, None),
ItemDef(400009, 'Key Joker', ItemClassification.progression, 1, 0, None),
ItemDef(400010, 'Key Ace', ItemClassification.progression, 1, 0, None),
ItemDef(400011, 'Ring of Ruby', ItemClassification.progression, 1, 0, None),
ItemDef(400012, 'Ring of Dworf', ItemClassification.progression, 1, 0, None),
ItemDef(400013, 'Demons Ring', ItemClassification.progression, 1, 0, None),
ItemDef(400014, 'Black Onyx', ItemClassification.progression, 1, 0, None),
ItemDef(None, 'Sky Spring Flow', ItemClassification.progression, 1, 0, 'Sky Spring'),
ItemDef(None, 'Tower of Fortress Spring Flow', ItemClassification.progression, 1, 0, 'Tower of Fortress Spring'),
ItemDef(None, 'Joker Spring Flow', ItemClassification.progression, 1, 0, 'Joker Spring'),
ItemDef(400015, 'Deluge', ItemClassification.progression, 1, 0, None),
ItemDef(400016, 'Thunder', ItemClassification.useful, 1, 0, None),
ItemDef(400017, 'Fire', ItemClassification.useful, 1, 0, None),
ItemDef(400018, 'Death', ItemClassification.useful, 1, 0, None),
ItemDef(400019, 'Tilte', ItemClassification.useful, 1, 0, None),
ItemDef(400020, 'Ring of Elf', ItemClassification.useful, 1, 0, None),
ItemDef(400021, 'Magical Rod', ItemClassification.useful, 1, 0, None),
ItemDef(400022, 'Pendant', ItemClassification.useful, 1, 0, None),
ItemDef(400023, 'Hourglass', ItemClassification.filler, 6, 0, None),
# We need at least 4 red potions for the Tower of Red Potion. Up to the player to save them up!
ItemDef(400024, 'Red Potion', ItemClassification.filler, 15, 4, None),
ItemDef(400025, 'Elixir', ItemClassification.filler, 4, 0, None),
ItemDef(400026, 'Glove', ItemClassification.filler, 5, 0, None),
ItemDef(400027, 'Ointment', ItemClassification.filler, 8, 0, None),
ItemDef(400028, 'Poison', ItemClassification.trap, 13, 0, None),
ItemDef(None, 'Killed Evil One', ItemClassification.progression, 1, 0, 'Evil One'),
# Placeholder item so the game knows which shop slot to prefill wingboots
ItemDef(400029, 'Wingboots', ItemClassification.useful, 0, 0, None),
]

View File

@@ -0,0 +1,199 @@
from typing import List, Optional
class LocationType():
world = 1 # Just standing there in the world
hidden = 2 # Kill all monsters in the room to reveal, each "item room" counter tick.
boss_reward = 3 # Kill a boss to reveal the item
shop = 4 # Buy at a shop
give = 5 # Given by an NPC
spring = 6 # Activatable spring
boss = 7 # Entity to kill to trigger the check
class ItemType():
unknown = 0 # Or don't care
red_potion = 1
class LocationDef:
def __init__(self, id: Optional[int], name: str, region: str, type: int, original_item: int):
self.id = id
self.name = name
self.region = region
self.type = type
self.original_item = original_item
locations: List[LocationDef] = [
# Eolis
LocationDef(400100, 'Eolis Guru', 'Eolis', LocationType.give, ItemType.unknown),
LocationDef(400101, 'Eolis Key Jack', 'Eolis', LocationType.shop, ItemType.unknown),
LocationDef(400102, 'Eolis Hand Dagger', 'Eolis', LocationType.shop, ItemType.unknown),
LocationDef(400103, 'Eolis Red Potion', 'Eolis', LocationType.shop, ItemType.red_potion),
LocationDef(400104, 'Eolis Elixir', 'Eolis', LocationType.shop, ItemType.unknown),
LocationDef(400105, 'Eolis Deluge', 'Eolis', LocationType.shop, ItemType.unknown),
# Path to Apolune
LocationDef(400106, 'Path to Apolune Magic Shield', 'Path to Apolune', LocationType.shop, ItemType.unknown),
LocationDef(400107, 'Path to Apolune Death', 'Path to Apolune', LocationType.shop, ItemType.unknown),
# Apolune
LocationDef(400108, 'Apolune Small Shield', 'Apolune', LocationType.shop, ItemType.unknown),
LocationDef(400109, 'Apolune Hand Dagger', 'Apolune', LocationType.shop, ItemType.unknown),
LocationDef(400110, 'Apolune Deluge', 'Apolune', LocationType.shop, ItemType.unknown),
LocationDef(400111, 'Apolune Red Potion', 'Apolune', LocationType.shop, ItemType.red_potion),
LocationDef(400112, 'Apolune Key Jack', 'Apolune', LocationType.shop, ItemType.unknown),
# Tower of Trunk
LocationDef(400113, 'Tower of Trunk Hidden Mattock', 'Tower of Trunk', LocationType.hidden, ItemType.unknown),
LocationDef(400114, 'Tower of Trunk Hidden Hourglass', 'Tower of Trunk', LocationType.hidden, ItemType.unknown),
LocationDef(400115, 'Tower of Trunk Boss Mattock', 'Tower of Trunk', LocationType.boss_reward, ItemType.unknown),
# Path to Forepaw
LocationDef(400116, 'Path to Forepaw Hidden Red Potion', 'Path to Forepaw', LocationType.hidden, ItemType.red_potion),
LocationDef(400117, 'Path to Forepaw Glove', 'Path to Forepaw', LocationType.world, ItemType.unknown),
# Forepaw
LocationDef(400118, 'Forepaw Long Sword', 'Forepaw', LocationType.shop, ItemType.unknown),
LocationDef(400119, 'Forepaw Studded Mail', 'Forepaw', LocationType.shop, ItemType.unknown),
LocationDef(400120, 'Forepaw Small Shield', 'Forepaw', LocationType.shop, ItemType.unknown),
LocationDef(400121, 'Forepaw Red Potion', 'Forepaw', LocationType.shop, ItemType.red_potion),
LocationDef(400122, 'Forepaw Wingboots', 'Forepaw', LocationType.shop, ItemType.unknown),
LocationDef(400123, 'Forepaw Key Jack', 'Forepaw', LocationType.shop, ItemType.unknown),
LocationDef(400124, 'Forepaw Key Queen', 'Forepaw', LocationType.shop, ItemType.unknown),
# Trunk
LocationDef(400125, 'Trunk Hidden Ointment', 'Trunk', LocationType.hidden, ItemType.unknown),
LocationDef(400126, 'Trunk Hidden Red Potion', 'Trunk', LocationType.hidden, ItemType.red_potion),
LocationDef(400127, 'Trunk Red Potion', 'Trunk', LocationType.world, ItemType.red_potion),
LocationDef(None, 'Sky Spring', 'Trunk', LocationType.spring, ItemType.unknown),
# Joker Spring
LocationDef(400128, 'Joker Spring Ruby Ring', 'Joker Spring', LocationType.give, ItemType.unknown),
LocationDef(None, 'Joker Spring', 'Joker Spring', LocationType.spring, ItemType.unknown),
# Tower of Fortress
LocationDef(400129, 'Tower of Fortress Poison 1', 'Tower of Fortress', LocationType.world, ItemType.unknown),
LocationDef(400130, 'Tower of Fortress Poison 2', 'Tower of Fortress', LocationType.world, ItemType.unknown),
LocationDef(400131, 'Tower of Fortress Hidden Wingboots', 'Tower of Fortress', LocationType.world, ItemType.unknown),
LocationDef(400132, 'Tower of Fortress Ointment', 'Tower of Fortress', LocationType.world, ItemType.unknown),
LocationDef(400133, 'Tower of Fortress Boss Wingboots', 'Tower of Fortress', LocationType.boss_reward, ItemType.unknown),
LocationDef(400134, 'Tower of Fortress Elixir', 'Tower of Fortress', LocationType.world, ItemType.unknown),
LocationDef(400135, 'Tower of Fortress Guru', 'Tower of Fortress', LocationType.give, ItemType.unknown),
LocationDef(None, 'Tower of Fortress Spring', 'Tower of Fortress', LocationType.spring, ItemType.unknown),
# Path to Mascon
LocationDef(400136, 'Path to Mascon Hidden Wingboots', 'Path to Mascon', LocationType.hidden, ItemType.unknown),
# Tower of Red Potion
LocationDef(400137, 'Tower of Red Potion', 'Tower of Red Potion', LocationType.world, ItemType.red_potion),
# Mascon
LocationDef(400138, 'Mascon Large Shield', 'Mascon', LocationType.shop, ItemType.unknown),
LocationDef(400139, 'Mascon Thunder', 'Mascon', LocationType.shop, ItemType.unknown),
LocationDef(400140, 'Mascon Mattock', 'Mascon', LocationType.shop, ItemType.unknown),
LocationDef(400141, 'Mascon Red Potion', 'Mascon', LocationType.shop, ItemType.red_potion),
LocationDef(400142, 'Mascon Key Jack', 'Mascon', LocationType.shop, ItemType.unknown),
LocationDef(400143, 'Mascon Key Queen', 'Mascon', LocationType.shop, ItemType.unknown),
# Path to Victim
LocationDef(400144, 'Misty Shop Death', 'Path to Victim', LocationType.shop, ItemType.unknown),
LocationDef(400145, 'Misty Shop Hourglass', 'Path to Victim', LocationType.shop, ItemType.unknown),
LocationDef(400146, 'Misty Shop Elixir', 'Path to Victim', LocationType.shop, ItemType.unknown),
LocationDef(400147, 'Misty Shop Red Potion', 'Path to Victim', LocationType.shop, ItemType.red_potion),
LocationDef(400148, 'Misty Doctor Office', 'Path to Victim', LocationType.hidden, ItemType.unknown),
# Tower of Suffer
LocationDef(400149, 'Tower of Suffer Hidden Wingboots', 'Tower of Suffer', LocationType.hidden, ItemType.unknown),
LocationDef(400150, 'Tower of Suffer Hidden Hourglass', 'Tower of Suffer', LocationType.hidden, ItemType.unknown),
LocationDef(400151, 'Tower of Suffer Pendant', 'Tower of Suffer', LocationType.boss_reward, ItemType.unknown),
# Victim
LocationDef(400152, 'Victim Full Plate', 'Victim', LocationType.shop, ItemType.unknown),
LocationDef(400153, 'Victim Mattock', 'Victim', LocationType.shop, ItemType.unknown),
LocationDef(400154, 'Victim Red Potion', 'Victim', LocationType.shop, ItemType.red_potion),
LocationDef(400155, 'Victim Key King', 'Victim', LocationType.shop, ItemType.unknown),
LocationDef(400156, 'Victim Key Queen', 'Victim', LocationType.shop, ItemType.unknown),
LocationDef(400157, 'Victim Tavern', 'Mist', LocationType.give, ItemType.unknown),
# Mist
LocationDef(400158, 'Mist Hidden Poison 1', 'Mist', LocationType.hidden, ItemType.unknown),
LocationDef(400159, 'Mist Hidden Poison 2', 'Mist', LocationType.hidden, ItemType.unknown),
LocationDef(400160, 'Mist Hidden Wingboots', 'Mist', LocationType.hidden, ItemType.unknown),
LocationDef(400161, 'Misty Magic Hall', 'Mist', LocationType.give, ItemType.unknown),
LocationDef(400162, 'Misty House', 'Mist', LocationType.give, ItemType.unknown),
# Useless Tower
LocationDef(400163, 'Useless Tower', 'Useless Tower', LocationType.hidden, ItemType.unknown),
# Tower of Mist
LocationDef(400164, 'Tower of Mist Hidden Ointment', 'Tower of Mist', LocationType.hidden, ItemType.unknown),
LocationDef(400165, 'Tower of Mist Elixir', 'Tower of Mist', LocationType.world, ItemType.unknown),
LocationDef(400166, 'Tower of Mist Black Onyx', 'Tower of Mist', LocationType.boss_reward, ItemType.unknown),
# Path to Conflate
LocationDef(400167, 'Path to Conflate Hidden Ointment', 'Path to Conflate', LocationType.hidden, ItemType.unknown),
LocationDef(400168, 'Path to Conflate Poison', 'Path to Conflate', LocationType.hidden, ItemType.unknown),
# Helm Branch
LocationDef(400169, 'Helm Branch Hidden Glove', 'Helm Branch', LocationType.hidden, ItemType.unknown),
LocationDef(400170, 'Helm Branch Battle Helmet', 'Helm Branch', LocationType.boss_reward, ItemType.unknown),
# Conflate
LocationDef(400171, 'Conflate Giant Blade', 'Conflate', LocationType.shop, ItemType.unknown),
LocationDef(400172, 'Conflate Magic Shield', 'Conflate', LocationType.shop, ItemType.unknown),
LocationDef(400173, 'Conflate Wingboots', 'Conflate', LocationType.shop, ItemType.unknown),
LocationDef(400174, 'Conflate Red Potion', 'Conflate', LocationType.shop, ItemType.red_potion),
LocationDef(400175, 'Conflate Guru', 'Conflate', LocationType.give, ItemType.unknown),
# Branches
LocationDef(400176, 'Branches Hidden Ointment', 'Branches', LocationType.hidden, ItemType.unknown),
LocationDef(400177, 'Branches Poison', 'Branches', LocationType.world, ItemType.unknown),
LocationDef(400178, 'Branches Hidden Mattock', 'Branches', LocationType.hidden, ItemType.unknown),
LocationDef(400179, 'Branches Hidden Hourglass', 'Branches', LocationType.hidden, ItemType.unknown),
# Path to Daybreak
LocationDef(400180, 'Path to Daybreak Hidden Wingboots 1', 'Path to Daybreak', LocationType.hidden, ItemType.unknown),
LocationDef(400181, 'Path to Daybreak Magical Rod', 'Path to Daybreak', LocationType.world, ItemType.unknown),
LocationDef(400182, 'Path to Daybreak Hidden Wingboots 2', 'Path to Daybreak', LocationType.hidden, ItemType.unknown),
LocationDef(400183, 'Path to Daybreak Poison', 'Path to Daybreak', LocationType.world, ItemType.unknown),
LocationDef(400184, 'Path to Daybreak Glove', 'Path to Daybreak', LocationType.world, ItemType.unknown),
LocationDef(400185, 'Path to Daybreak Battle Suit', 'Path to Daybreak', LocationType.boss_reward, ItemType.unknown),
# Daybreak
LocationDef(400186, 'Daybreak Tilte', 'Daybreak', LocationType.shop, ItemType.unknown),
LocationDef(400187, 'Daybreak Giant Blade', 'Daybreak', LocationType.shop, ItemType.unknown),
LocationDef(400188, 'Daybreak Red Potion', 'Daybreak', LocationType.shop, ItemType.red_potion),
LocationDef(400189, 'Daybreak Key King', 'Daybreak', LocationType.shop, ItemType.unknown),
LocationDef(400190, 'Daybreak Key Queen', 'Daybreak', LocationType.shop, ItemType.unknown),
# Dartmoor Castle
LocationDef(400191, 'Dartmoor Castle Hidden Hourglass', 'Dartmoor Castle', LocationType.hidden, ItemType.unknown),
LocationDef(400192, 'Dartmoor Castle Hidden Red Potion', 'Dartmoor Castle', LocationType.hidden, ItemType.red_potion),
# Dartmoor
LocationDef(400193, 'Dartmoor Giant Blade', 'Dartmoor', LocationType.shop, ItemType.unknown),
LocationDef(400194, 'Dartmoor Red Potion', 'Dartmoor', LocationType.shop, ItemType.red_potion),
LocationDef(400195, 'Dartmoor Key King', 'Dartmoor', LocationType.shop, ItemType.unknown),
# Fraternal Castle
LocationDef(400196, 'Fraternal Castle Hidden Ointment', 'Fraternal Castle', LocationType.hidden, ItemType.unknown),
LocationDef(400197, 'Fraternal Castle Shop Hidden Ointment', 'Fraternal Castle', LocationType.hidden, ItemType.unknown),
LocationDef(400198, 'Fraternal Castle Poison 1', 'Fraternal Castle', LocationType.world, ItemType.unknown),
LocationDef(400199, 'Fraternal Castle Poison 2', 'Fraternal Castle', LocationType.world, ItemType.unknown),
LocationDef(400200, 'Fraternal Castle Poison 3', 'Fraternal Castle', LocationType.world, ItemType.unknown),
# LocationDef(400201, 'Fraternal Castle Red Potion', 'Fraternal Castle', LocationType.world, ItemType.red_potion), # This location is inaccessible. Keeping commented for context.
LocationDef(400202, 'Fraternal Castle Hidden Hourglass', 'Fraternal Castle', LocationType.hidden, ItemType.unknown),
LocationDef(400203, 'Fraternal Castle Dragon Slayer', 'Fraternal Castle', LocationType.boss_reward, ItemType.unknown),
LocationDef(400204, 'Fraternal Castle Guru', 'Fraternal Castle', LocationType.give, ItemType.unknown),
# Evil Fortress
LocationDef(400205, 'Evil Fortress Ointment', 'Evil Fortress', LocationType.world, ItemType.unknown),
LocationDef(400206, 'Evil Fortress Poison 1', 'Evil Fortress', LocationType.world, ItemType.unknown),
LocationDef(400207, 'Evil Fortress Glove', 'Evil Fortress', LocationType.world, ItemType.unknown),
LocationDef(400208, 'Evil Fortress Poison 2', 'Evil Fortress', LocationType.world, ItemType.unknown),
LocationDef(400209, 'Evil Fortress Poison 3', 'Evil Fortress', LocationType.world, ItemType.unknown),
LocationDef(400210, 'Evil Fortress Hidden Glove', 'Evil Fortress', LocationType.hidden, ItemType.unknown),
LocationDef(None, 'Evil One', 'Evil Fortress', LocationType.boss, ItemType.unknown),
]

107
worlds/faxanadu/Options.py Normal file
View File

@@ -0,0 +1,107 @@
from Options import PerGameCommonOptions, Toggle, DefaultOnToggle, StartInventoryPool, Choice
from dataclasses import dataclass
class KeepShopRedPotions(Toggle):
"""
Prevents the Shop's Red Potions from being shuffled. Those locations
will have purchasable Red Potion as usual for their usual price.
"""
display_name = "Keep Shop Red Potions"
class IncludePendant(Toggle):
"""
Pendant is an item that boosts your attack power permanently when picked up.
However, due to a programming error in the original game, it has the reverse
effect. You start with the Pendant power, and lose it when picking
it up. So this item is essentially a trap.
There is a setting in the client to reverse the effect back to its original intend.
This could be used in conjunction with this option to increase or lower difficulty.
"""
display_name = "Include Pendant"
class IncludePoisons(DefaultOnToggle):
"""
Whether or not to include Poison Potions in the pool of items. Including them
effectively turn them into traps in multiplayer.
"""
display_name = "Include Poisons"
class RequireDragonSlayer(Toggle):
"""
Requires the Dragon Slayer to be available before fighting the final boss is required.
Turning this on will turn Progressive Shields into progression items.
This setting does not force you to use Dragon Slayer to kill the final boss.
Instead, it ensures that you will have the Dragon Slayer and be able to equip
it before you are expected to beat the final boss.
"""
display_name = "Require Dragon Slayer"
class RandomMusic(Toggle):
"""
All levels' music is shuffled. Except the title screen because it's finite.
This is an aesthetic option and doesn't affect gameplay.
"""
display_name = "Random Musics"
class RandomSound(Toggle):
"""
All sounds are shuffled.
This is an aesthetic option and doesn't affect gameplay.
"""
display_name = "Random Sounds"
class RandomNPC(Toggle):
"""
NPCs and their portraits are shuffled.
This is an aesthetic option and doesn't affect gameplay.
"""
display_name = "Random NPCs"
class RandomMonsters(Choice):
"""
Choose how monsters are randomized.
"Vanilla": No randomization
"Level Shuffle": Monsters are shuffled within a level
"Level Random": Monsters are picked randomly, balanced based on the ratio of the current level
"World Shuffle": Monsters are shuffled across the entire world
"World Random": Monsters are picked randomly, balanced based on the ratio of the entire world
"Chaotic": Completely random, except big vs small ratio is kept. Big are mini-bosses.
"""
display_name = "Random Monsters"
option_vanilla = 0
option_level_shuffle = 1
option_level_random = 2
option_world_shuffle = 3
option_world_random = 4
option_chaotic = 5
default = 0
class RandomRewards(Toggle):
"""
Monsters drops are shuffled.
"""
display_name = "Random Rewards"
@dataclass
class FaxanaduOptions(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool
keep_shop_red_potions: KeepShopRedPotions
include_pendant: IncludePendant
include_poisons: IncludePoisons
require_dragon_slayer: RequireDragonSlayer
random_musics: RandomMusic
random_sounds: RandomSound
random_npcs: RandomNPC
random_monsters: RandomMonsters
random_rewards: RandomRewards

View File

@@ -0,0 +1,66 @@
from BaseClasses import Region
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from . import FaxanaduWorld
def create_region(name, player, multiworld):
region = Region(name, player, multiworld)
multiworld.regions.append(region)
return region
def create_regions(faxanadu_world: "FaxanaduWorld"):
player = faxanadu_world.player
multiworld = faxanadu_world.multiworld
# Create regions
menu = create_region("Menu", player, multiworld)
eolis = create_region("Eolis", player, multiworld)
path_to_apolune = create_region("Path to Apolune", player, multiworld)
apolune = create_region("Apolune", player, multiworld)
create_region("Tower of Trunk", player, multiworld)
path_to_forepaw = create_region("Path to Forepaw", player, multiworld)
forepaw = create_region("Forepaw", player, multiworld)
trunk = create_region("Trunk", player, multiworld)
create_region("Joker Spring", player, multiworld)
create_region("Tower of Fortress", player, multiworld)
path_to_mascon = create_region("Path to Mascon", player, multiworld)
create_region("Tower of Red Potion", player, multiworld)
mascon = create_region("Mascon", player, multiworld)
path_to_victim = create_region("Path to Victim", player, multiworld)
create_region("Tower of Suffer", player, multiworld)
victim = create_region("Victim", player, multiworld)
mist = create_region("Mist", player, multiworld)
create_region("Useless Tower", player, multiworld)
create_region("Tower of Mist", player, multiworld)
path_to_conflate = create_region("Path to Conflate", player, multiworld)
create_region("Helm Branch", player, multiworld)
create_region("Conflate", player, multiworld)
branches = create_region("Branches", player, multiworld)
path_to_daybreak = create_region("Path to Daybreak", player, multiworld)
daybreak = create_region("Daybreak", player, multiworld)
dartmoor_castle = create_region("Dartmoor Castle", player, multiworld)
create_region("Dartmoor", player, multiworld)
create_region("Fraternal Castle", player, multiworld)
create_region("Evil Fortress", player, multiworld)
# Create connections
menu.add_exits(["Eolis"])
eolis.add_exits(["Path to Apolune"])
path_to_apolune.add_exits(["Apolune"])
apolune.add_exits(["Tower of Trunk", "Path to Forepaw"])
path_to_forepaw.add_exits(["Forepaw"])
forepaw.add_exits(["Trunk"])
trunk.add_exits(["Joker Spring", "Tower of Fortress", "Path to Mascon"])
path_to_mascon.add_exits(["Tower of Red Potion", "Mascon"])
mascon.add_exits(["Path to Victim"])
path_to_victim.add_exits(["Tower of Suffer", "Victim"])
victim.add_exits(["Mist"])
mist.add_exits(["Useless Tower", "Tower of Mist", "Path to Conflate"])
path_to_conflate.add_exits(["Helm Branch", "Conflate", "Branches"])
branches.add_exits(["Path to Daybreak"])
path_to_daybreak.add_exits(["Daybreak"])
daybreak.add_exits(["Dartmoor Castle"])
dartmoor_castle.add_exits(["Dartmoor", "Fraternal Castle", "Evil Fortress"])

79
worlds/faxanadu/Rules.py Normal file
View File

@@ -0,0 +1,79 @@
from typing import TYPE_CHECKING
from worlds.generic.Rules import set_rule
if TYPE_CHECKING:
from . import FaxanaduWorld
def can_buy_in_eolis(state, player):
# Sword or Deluge so we can farm for gold.
# Ring of Elf so we can get 1500 from the King.
return state.has_any(["Progressive Sword", "Deluge", "Ring of Elf"], player)
def has_any_magic(state, player):
return state.has_any(["Deluge", "Thunder", "Fire", "Death", "Tilte"], player)
def set_rules(faxanadu_world: "FaxanaduWorld"):
player = faxanadu_world.player
multiworld = faxanadu_world.multiworld
# Region rules
set_rule(multiworld.get_entrance("Eolis -> Path to Apolune", player), lambda state:
state.has_all(["Key Jack", "Progressive Sword"], player)) # You can't go far with magic only
set_rule(multiworld.get_entrance("Apolune -> Tower of Trunk", player), lambda state: state.has("Key Jack", player))
set_rule(multiworld.get_entrance("Apolune -> Path to Forepaw", player), lambda state: state.has("Mattock", player))
set_rule(multiworld.get_entrance("Trunk -> Joker Spring", player), lambda state: state.has("Key Joker", player))
set_rule(multiworld.get_entrance("Trunk -> Tower of Fortress", player), lambda state: state.has("Key Jack", player))
set_rule(multiworld.get_entrance("Trunk -> Path to Mascon", player), lambda state:
state.has_all(["Key Queen", "Ring of Ruby", "Sky Spring Flow", "Tower of Fortress Spring Flow", "Joker Spring Flow"], player) and
state.has("Progressive Sword", player, 2))
set_rule(multiworld.get_entrance("Path to Mascon -> Tower of Red Potion", player), lambda state:
state.has("Key Queen", player) and
state.has("Red Potion", player, 4)) # It's impossible to go through the tower of Red Potion without at least 1-2 potions. Give them 4 for good measure.
set_rule(multiworld.get_entrance("Path to Victim -> Tower of Suffer", player), lambda state: state.has("Key Queen", player))
set_rule(multiworld.get_entrance("Path to Victim -> Victim", player), lambda state: state.has("Unlock Wingboots", player))
set_rule(multiworld.get_entrance("Mist -> Useless Tower", player), lambda state:
state.has_all(["Key King", "Unlock Wingboots"], player))
set_rule(multiworld.get_entrance("Mist -> Tower of Mist", player), lambda state: state.has("Key King", player))
set_rule(multiworld.get_entrance("Mist -> Path to Conflate", player), lambda state: state.has("Key Ace", player))
set_rule(multiworld.get_entrance("Path to Conflate -> Helm Branch", player), lambda state: state.has("Key King", player))
set_rule(multiworld.get_entrance("Path to Conflate -> Branches", player), lambda state: state.has("Key King", player))
set_rule(multiworld.get_entrance("Daybreak -> Dartmoor Castle", player), lambda state: state.has("Ring of Dworf", player))
set_rule(multiworld.get_entrance("Dartmoor Castle -> Evil Fortress", player), lambda state: state.has("Demons Ring", player))
# Location rules
set_rule(multiworld.get_location("Eolis Key Jack", player), lambda state: can_buy_in_eolis(state, player))
set_rule(multiworld.get_location("Eolis Hand Dagger", player), lambda state: can_buy_in_eolis(state, player))
set_rule(multiworld.get_location("Eolis Elixir", player), lambda state: can_buy_in_eolis(state, player))
set_rule(multiworld.get_location("Eolis Deluge", player), lambda state: can_buy_in_eolis(state, player))
set_rule(multiworld.get_location("Eolis Red Potion", player), lambda state: can_buy_in_eolis(state, player))
set_rule(multiworld.get_location("Path to Apolune Magic Shield", player), lambda state: state.has("Key King", player)) # Mid-late cost, make sure we've progressed
set_rule(multiworld.get_location("Path to Apolune Death", player), lambda state: state.has("Key Ace", player)) # Mid-late cost, make sure we've progressed
set_rule(multiworld.get_location("Tower of Trunk Hidden Mattock", player), lambda state:
# This is actually possible if the monster drop into the stairs and kill it with dagger. But it's a "pro move"
state.has("Deluge", player, 1) or
state.has("Progressive Sword", player, 2))
set_rule(multiworld.get_location("Path to Forepaw Glove", player), lambda state:
state.has_all(["Deluge", "Unlock Wingboots"], player))
set_rule(multiworld.get_location("Trunk Red Potion", player), lambda state: state.has("Unlock Wingboots", player))
set_rule(multiworld.get_location("Sky Spring", player), lambda state: state.has("Unlock Wingboots", player))
set_rule(multiworld.get_location("Tower of Fortress Spring", player), lambda state: state.has("Spring Elixir", player))
set_rule(multiworld.get_location("Tower of Fortress Guru", player), lambda state: state.has("Sky Spring Flow", player))
set_rule(multiworld.get_location("Tower of Suffer Hidden Wingboots", player), lambda state:
state.has("Deluge", player) or
state.has("Progressive Sword", player, 2))
set_rule(multiworld.get_location("Misty House", player), lambda state: state.has("Black Onyx", player))
set_rule(multiworld.get_location("Misty Doctor Office", player), lambda state: has_any_magic(state, player))
set_rule(multiworld.get_location("Conflate Guru", player), lambda state: state.has("Progressive Armor", player, 3))
set_rule(multiworld.get_location("Branches Hidden Mattock", player), lambda state: state.has("Unlock Wingboots", player))
set_rule(multiworld.get_location("Path to Daybreak Glove", player), lambda state: state.has("Unlock Wingboots", player))
set_rule(multiworld.get_location("Dartmoor Castle Hidden Hourglass", player), lambda state: state.has("Unlock Wingboots", player))
set_rule(multiworld.get_location("Dartmoor Castle Hidden Red Potion", player), lambda state: has_any_magic(state, player))
set_rule(multiworld.get_location("Fraternal Castle Guru", player), lambda state: state.has("Progressive Sword", player, 4))
set_rule(multiworld.get_location("Fraternal Castle Shop Hidden Ointment", player), lambda state: has_any_magic(state, player))
if faxanadu_world.options.require_dragon_slayer.value:
set_rule(multiworld.get_location("Evil One", player), lambda state:
state.has_all_counts({"Progressive Sword": 4, "Progressive Armor": 3, "Progressive Shield": 4}, player))

190
worlds/faxanadu/__init__.py Normal file
View File

@@ -0,0 +1,190 @@
from typing import Any, Dict, List
from BaseClasses import Item, Location, Tutorial, ItemClassification, MultiWorld
from worlds.AutoWorld import WebWorld, World
from . import Items, Locations, Regions, Rules
from .Options import FaxanaduOptions
from worlds.generic.Rules import set_rule
DAXANADU_VERSION = "0.3.0"
class FaxanaduLocation(Location):
game: str = "Faxanadu"
class FaxanaduItem(Item):
game: str = "Faxanadu"
class FaxanaduWeb(WebWorld):
tutorials = [Tutorial(
"Multiworld Setup Guide",
"A guide to setting up the Faxanadu randomizer connected to an Archipelago Multiworld",
"English",
"setup_en.md",
"setup/en",
["Daivuk"]
)]
theme = "dirt"
class FaxanaduWorld(World):
"""
Faxanadu is an action role-playing platform video game developed by Hudson Soft for the Nintendo Entertainment System
"""
options_dataclass = FaxanaduOptions
options: FaxanaduOptions
game = "Faxanadu"
web = FaxanaduWeb()
item_name_to_id = {item.name: item.id for item in Items.items if item.id is not None}
item_name_to_item = {item.name: item for item in Items.items}
location_name_to_id = {loc.name: loc.id for loc in Locations.locations if loc.id is not None}
def __init__(self, world: MultiWorld, player: int):
self.filler_ratios: Dict[str, int] = {}
super().__init__(world, player)
def create_regions(self):
Regions.create_regions(self)
# Add locations into regions
for region in self.multiworld.get_regions(self.player):
for loc in [location for location in Locations.locations if location.region == region.name]:
location = FaxanaduLocation(self.player, loc.name, loc.id, region)
# In Faxanadu, Poison hurts you when picked up. It makes no sense to sell them in shops
if loc.type == Locations.LocationType.shop:
location.item_rule = lambda item, player=self.player: not (player == item.player and item.name == "Poison")
region.locations.append(location)
def set_rules(self):
Rules.set_rules(self)
self.multiworld.completion_condition[self.player] = lambda state: state.has("Killed Evil One", self.player)
def create_item(self, name: str) -> FaxanaduItem:
item: Items.ItemDef = self.item_name_to_item[name]
return FaxanaduItem(name, item.classification, item.id, self.player)
# Returns how many red potions were prefilled into shops
def prefill_shop_red_potions(self) -> int:
red_potion_in_shop_count = 0
if self.options.keep_shop_red_potions:
red_potion_item = self.item_name_to_item["Red Potion"]
red_potion_shop_locations = [
loc
for loc in Locations.locations
if loc.type == Locations.LocationType.shop and loc.original_item == Locations.ItemType.red_potion
]
for loc in red_potion_shop_locations:
location = self.get_location(loc.name)
location.place_locked_item(FaxanaduItem(red_potion_item.name, red_potion_item.classification, red_potion_item.id, self.player))
red_potion_in_shop_count += 1
return red_potion_in_shop_count
def put_wingboot_in_shop(self, shops, region_name):
item = self.item_name_to_item["Wingboots"]
shop = shops.pop(region_name)
slot = self.random.randint(0, len(shop) - 1)
loc = shop[slot]
location = self.get_location(loc.name)
location.place_locked_item(FaxanaduItem(item.name, item.classification, item.id, self.player))
# Put a rule right away that we need to have to unlocked.
set_rule(location, lambda state: state.has("Unlock Wingboots", self.player))
# Returns how many wingboots were prefilled into shops
def prefill_shop_wingboots(self) -> int:
# Collect shops
shops: Dict[str, List[Locations.LocationDef]] = {}
for loc in Locations.locations:
if loc.type == Locations.LocationType.shop:
if self.options.keep_shop_red_potions and loc.original_item == Locations.ItemType.red_potion:
continue # Don't override our red potions
shops.setdefault(loc.region, []).append(loc)
shop_count = len(shops)
wingboots_count = round(shop_count / 2.5) # On 10 shops, we should have about 4 shops with wingboots
# At least one should be in the first 4 shops. Because we require wingboots to progress past that point.
must_have_regions = [region for i, region in enumerate(shops) if i < 4]
self.put_wingboot_in_shop(shops, self.random.choice(must_have_regions))
# Fill in the rest randomly in remaining shops
for i in range(wingboots_count - 1): # -1 because we added one already
region = self.random.choice(list(shops.keys()))
self.put_wingboot_in_shop(shops, region)
return wingboots_count
def create_items(self) -> None:
itempool: List[FaxanaduItem] = []
# Prefill red potions in shops if option is set
red_potion_in_shop_count = self.prefill_shop_red_potions()
# Prefill wingboots in shops
wingboots_in_shop_count = self.prefill_shop_wingboots()
# Create the item pool, excluding fillers.
prefilled_count = red_potion_in_shop_count + wingboots_in_shop_count
for item in Items.items:
# Ignore pendant if turned off
if item.name == "Pendant" and not self.options.include_pendant:
continue
# ignore fillers for now, we will fill them later
if item.classification in [ItemClassification.filler, ItemClassification.trap] and \
item.progression_count == 0:
continue
prefill_loc = None
if item.prefill_location:
prefill_loc = self.get_location(item.prefill_location)
# if require dragon slayer is turned on, we need progressive shields to be progression
item_classification = item.classification
if self.options.require_dragon_slayer and item.name == "Progressive Shield":
item_classification = ItemClassification.progression
if prefill_loc:
prefill_loc.place_locked_item(FaxanaduItem(item.name, item_classification, item.id, self.player))
prefilled_count += 1
else:
for i in range(item.count - item.progression_count):
itempool.append(FaxanaduItem(item.name, item_classification, item.id, self.player))
for i in range(item.progression_count):
itempool.append(FaxanaduItem(item.name, ItemClassification.progression, item.id, self.player))
# Set up filler ratios
self.filler_ratios = {
item.name: item.count
for item in Items.items
if item.classification in [ItemClassification.filler, ItemClassification.trap]
}
# If red potions are locked in shops, remove the count from the ratio.
self.filler_ratios["Red Potion"] -= red_potion_in_shop_count
# Remove poisons if not desired
if not self.options.include_poisons:
self.filler_ratios["Poison"] = 0
# Randomly add fillers to the pool with ratios based on og game occurrence counts.
filler_count = len(Locations.locations) - len(itempool) - prefilled_count
for i in range(filler_count):
itempool.append(self.create_item(self.get_filler_item_name()))
self.multiworld.itempool += itempool
def get_filler_item_name(self) -> str:
return self.random.choices(list(self.filler_ratios.keys()), weights=list(self.filler_ratios.values()))[0]
def fill_slot_data(self) -> Dict[str, Any]:
slot_data = self.options.as_dict("keep_shop_red_potions", "random_musics", "random_sounds", "random_npcs", "random_monsters", "random_rewards")
slot_data["daxanadu_version"] = DAXANADU_VERSION
return slot_data

View File

@@ -0,0 +1,27 @@
# Faxanadu
## Where is the settings page?
The [player options page](../player-options) contains the options needed to configure your game session.
## What does randomization do to this game?
All game items collected in the map, shops, and boss drops are randomized.
Keys are unique. Once you get the Jack Key, you can open all Jack doors; the key stays in your inventory.
Wingboots are randomized across shops only. They are LOCKED and cannot be bought until you get the item that unlocks them.
Normal Elixirs don't revive the tower spring. A new item, Spring Elixir, is necessary. This new item is unique.
## What is the goal?
The goal is to kill the Evil One.
## What is a "check" in The Faxanadu?
Shop items, item locations in the world, boss drops, and secret items.
## What "items" can you unlock in Faxanadu?
Keys, Armors, Weapons, Potions, Shields, Magics, Poisons, Gloves, etc.

View File

@@ -0,0 +1,32 @@
# Faxanadu Randomizer Setup
## Required Software
- [Daxanadu](https://github.com/Daivuk/Daxanadu/releases/)
- Faxanadu ROM, English version
## Optional Software
- [ArchipelagoTextClient](https://github.com/ArchipelagoMW/Archipelago/releases)
## Installing Daxanadu
1. Download [Daxanadu.zip](https://github.com/Daivuk/Daxanadu/releases/) and extract it.
2. Copy your rom `Faxanadu (U).nes` into the newly extracted folder.
## Joining a MultiWorld Game
1. Launch Daxanadu.exe
2. From the Main menu, go to the `ARCHIPELAGO` menu. Enter the server's address, slot name, and password. Then select `PLAY`.
3. Enjoy!
To continue a game, follow the same connection steps.
Connecting with a different seed won't erase your progress in other seeds.
## Archipelago Text Client
We recommend having Archipelago's Text Client open on the side to keep track of what items you receive and send.
Daxanadu doesn't display messages. You'll only get popups when picking them up.
## Auto-Tracking
Daxanadu has an integrated tracker that can be toggled in the options.

View File

@@ -47,6 +47,17 @@ def get_flag(data, flag):
bit = int(0x80 / (2 ** (flag % 8)))
return (data[byte] & bit) > 0
def validate_read_state(data1, data2):
validation_array = bytes([0x01, 0x46, 0x46, 0x4D, 0x51, 0x52])
if data1 is None or data2 is None:
return False
for i in range(6):
if data1[i] != validation_array[i] or data2[i] != validation_array[i]:
return False;
return True
class FFMQClient(SNIClient):
game = "Final Fantasy Mystic Quest"
@@ -67,11 +78,11 @@ class FFMQClient(SNIClient):
async def game_watcher(self, ctx):
from SNIClient import snes_buffered_write, snes_flush_writes, snes_read
check_1 = await snes_read(ctx, 0xF53749, 1)
check_1 = await snes_read(ctx, 0xF53749, 6)
received = await snes_read(ctx, RECEIVED_DATA[0], RECEIVED_DATA[1])
data = await snes_read(ctx, READ_DATA_START, READ_DATA_END - READ_DATA_START)
check_2 = await snes_read(ctx, 0xF53749, 1)
if check_1 != b'\x01' or check_2 != b'\x01':
check_2 = await snes_read(ctx, 0xF53749, 6)
if not validate_read_state(check_1, check_2):
return
def get_range(data_range):

View File

@@ -69,7 +69,7 @@ def locality_rules(multiworld: MultiWorld):
if (location.player, location.item_rule) in func_cache:
location.item_rule = func_cache[location.player, location.item_rule]
# empty rule that just returns True, overwrite
elif location.item_rule is location.__class__.item_rule:
elif location.item_rule is Location.item_rule:
func_cache[location.player, location.item_rule] = location.item_rule = \
lambda i, sending_blockers = forbid_data[location.player], \
old_rule = location.item_rule: \
@@ -103,7 +103,7 @@ def set_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.Entrance"],
def add_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.Entrance"], rule: CollectionRule, combine="and"):
old_rule = spot.access_rule
# empty rule, replace instead of add
if old_rule is spot.__class__.access_rule:
if old_rule is Location.access_rule or old_rule is Entrance.access_rule:
spot.access_rule = rule if combine == "and" else old_rule
else:
if combine == "and":
@@ -115,7 +115,7 @@ def add_rule(spot: typing.Union["BaseClasses.Location", "BaseClasses.Entrance"],
def forbid_item(location: "BaseClasses.Location", item: str, player: int):
old_rule = location.item_rule
# empty rule
if old_rule is location.__class__.item_rule:
if old_rule is Location.item_rule:
location.item_rule = lambda i: i.name != item or i.player != player
else:
location.item_rule = lambda i: (i.name != item or i.player != player) and old_rule(i)
@@ -135,7 +135,7 @@ def forbid_items(location: "BaseClasses.Location", items: typing.Set[str]):
def add_item_rule(location: "BaseClasses.Location", rule: ItemRule, combine: str = "and"):
old_rule = location.item_rule
# empty rule, replace instead of add
if old_rule is location.__class__.item_rule:
if old_rule is Location.item_rule:
location.item_rule = rule if combine == "and" else old_rule
else:
if combine == "and":

View File

@@ -2,8 +2,8 @@
Archipelago does not have a compiled release on macOS. However, it is possible to run from source code on macOS. This guide expects you to have some experience with running software from the terminal.
## Prerequisite Software
Here is a list of software to install and source code to download.
1. Python 3.9 "universal2" or newer from the [macOS Python downloads page](https://www.python.org/downloads/macos/).
**Python 3.11 is not supported yet.**
1. Python 3.10 "universal2" or newer from the [macOS Python downloads page](https://www.python.org/downloads/macos/).
**Python 3.13 is not supported yet.**
2. Xcode from the [macOS App Store](https://apps.apple.com/us/app/xcode/id497799835).
3. The source code from the [Archipelago releases page](https://github.com/ArchipelagoMW/Archipelago/releases).
4. The asset with darwin in the name from the [SNI Github releases page](https://github.com/alttpo/sni/releases).

View File

@@ -104,7 +104,7 @@ class StartWithMapScrolls(Toggle):
class ResetLevelOnDeath(DefaultOnToggle):
"""When dying, levels are reset and monsters respawned. But inventory and checks are kept.
Turning this setting off is considered easy mode. Good for new players that don't know the levels well."""
display_message="Reset level on death"
display_name = "Reset Level on Death"
class CheckSanity(Toggle):

View File

@@ -9,11 +9,7 @@ import ast
import jinja2
try:
from ast import unparse
except ImportError:
# Py 3.8 and earlier compatibility module
from astunparse import unparse
from ast import unparse
from Utils import get_text_between

View File

@@ -300,7 +300,7 @@ class PlandoCharmCosts(OptionDict):
display_name = "Charm Notch Cost Plando"
valid_keys = frozenset(charm_names)
schema = Schema({
Optional(name): And(int, lambda n: 6 >= n >= 0) for name in charm_names
Optional(name): And(int, lambda n: 6 >= n >= 0, error="Charm costs must be integers in the range 0-6.") for name in charm_names
})
def get_costs(self, charm_costs: typing.List[int]) -> typing.List[int]:

View File

@@ -231,7 +231,7 @@ class HKWorld(World):
all_event_names.update(set(godhome_event_names))
# Link regions
for event_name in all_event_names:
for event_name in sorted(all_event_names):
#if event_name in wp_exclusions:
# continue
loc = HKLocation(self.player, event_name, None, menu_region)

View File

@@ -1 +0,0 @@
astunparse>=1.6.3; python_version <= '3.8'

View File

@@ -235,6 +235,11 @@ def set_rules(kh1world):
lambda state: (
state.has("Progressive Glide", player)
or
(
state.has("High Jump", player, 2)
and state.has("Footprints", player)
)
or
(
options.advanced_logic
and state.has_all({
@@ -246,6 +251,11 @@ def set_rules(kh1world):
lambda state: (
state.has("Progressive Glide", player)
or
(
state.has("High Jump", player, 2)
and state.has("Footprints", player)
)
or
(
options.advanced_logic
and state.has_all({
@@ -258,7 +268,6 @@ def set_rules(kh1world):
state.has("Footprints", player)
or (options.advanced_logic and state.has("Progressive Glide", player))
or state.has("High Jump", player, 2)
))
add_rule(kh1world.get_location("Wonderland Tea Party Garden Across From Bizarre Room Entrance Chest"),
lambda state: (
@@ -376,7 +385,7 @@ def set_rules(kh1world):
lambda state: state.has("White Trinity", player))
add_rule(kh1world.get_location("Monstro Chamber 6 Other Platform Chest"),
lambda state: (
state.has("High Jump", player)
state.has_all(("High Jump", "Progressive Glide"), player)
or (options.advanced_logic and state.has("Combo Master", player))
))
add_rule(kh1world.get_location("Monstro Chamber 6 Platform Near Chamber 5 Entrance Chest"),
@@ -386,7 +395,7 @@ def set_rules(kh1world):
))
add_rule(kh1world.get_location("Monstro Chamber 6 Raised Area Near Chamber 1 Entrance Chest"),
lambda state: (
state.has("High Jump", player)
state.has_all(("High Jump", "Progressive Glide"), player)
or (options.advanced_logic and state.has("Combo Master", player))
))
add_rule(kh1world.get_location("Halloween Town Moonlight Hill White Trinity Chest"),
@@ -595,6 +604,7 @@ def set_rules(kh1world):
lambda state: (
state.has("Green Trinity", player)
and has_all_magic_lvx(state, player, 2)
and has_defensive_tools(state, player)
))
add_rule(kh1world.get_location("Neverland Hold Flight 2nd Chest"),
lambda state: (
@@ -710,8 +720,7 @@ def set_rules(kh1world):
lambda state: state.has("White Trinity", player))
add_rule(kh1world.get_location("End of the World Giant Crevasse 5th Chest"),
lambda state: (
state.has("High Jump", player)
or state.has("Progressive Glide", player)
state.has("Progressive Glide", player)
))
add_rule(kh1world.get_location("End of the World Giant Crevasse 1st Chest"),
lambda state: (
@@ -1441,10 +1450,11 @@ def set_rules(kh1world):
has_emblems(state, player, options.keyblades_unlock_chests)
and has_x_worlds(state, player, 7, options.keyblades_unlock_chests)
and has_defensive_tools(state, player)
and state.has("Progressive Blizzard", player, 3)
))
add_rule(kh1world.get_location("Agrabah Defeat Kurt Zisa Zantetsuken Event"),
lambda state: (
has_emblems(state, player, options.keyblades_unlock_chests) and has_x_worlds(state, player, 7, options.keyblades_unlock_chests) and has_defensive_tools(state, player)
has_emblems(state, player, options.keyblades_unlock_chests) and has_x_worlds(state, player, 7, options.keyblades_unlock_chests) and has_defensive_tools(state, player) and state.has("Progressive Blizzard", player, 3)
))
if options.super_bosses or options.goal.current_key == "sephiroth":
add_rule(kh1world.get_location("Olympus Coliseum Defeat Sephiroth Ansem's Report 12"),

View File

@@ -34,7 +34,7 @@ def create_locations(player: int, regions_table: Dict[str, LandstalkerRegion], n
for data in WORLD_PATHS_JSON:
if "requiredNodes" in data:
regions_with_entrance_checks.extend([region_id for region_id in data["requiredNodes"]])
regions_with_entrance_checks = list(set(regions_with_entrance_checks))
regions_with_entrance_checks = sorted(set(regions_with_entrance_checks))
for region_id in regions_with_entrance_checks:
region = regions_table[region_id]
location = LandstalkerLocation(player, 'event_visited_' + region_id, None, region, "event")

View File

@@ -118,7 +118,7 @@ class L2ACWorld(World):
L2ACItem("Progressive chest access", ItemClassification.progression, None, self.player))
chest_access.show_in_spoiler = False
ancient_dungeon.locations.append(chest_access)
for iris in self.item_name_groups["Iris treasures"]:
for iris in sorted(self.item_name_groups["Iris treasures"]):
treasure_name: str = f"Iris treasure {self.item_name_to_id[iris] - self.item_name_to_id['Iris sword'] + 1}"
iris_treasure: Location = \
L2ACLocation(self.player, treasure_name, self.location_name_to_id[treasure_name], ancient_dungeon)

View File

@@ -1,5 +1,5 @@
import logging
from typing import Any, ClassVar, Dict, List, Optional, Set, TextIO
from typing import Any, ClassVar, TextIO
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, MultiWorld, Tutorial
from Options import Accessibility
@@ -120,16 +120,16 @@ class MessengerWorld(World):
required_seals: int = 0
created_seals: int = 0
total_shards: int = 0
shop_prices: Dict[str, int]
figurine_prices: Dict[str, int]
_filler_items: List[str]
starting_portals: List[str]
plando_portals: List[str]
spoiler_portal_mapping: Dict[str, str]
portal_mapping: List[int]
transitions: List[Entrance]
shop_prices: dict[str, int]
figurine_prices: dict[str, int]
_filler_items: list[str]
starting_portals: list[str]
plando_portals: list[str]
spoiler_portal_mapping: dict[str, str]
portal_mapping: list[int]
transitions: list[Entrance]
reachable_locs: int = 0
filler: Dict[str, int]
filler: dict[str, int]
def generate_early(self) -> None:
if self.options.goal == Goal.option_power_seal_hunt:
@@ -178,7 +178,7 @@ class MessengerWorld(World):
for reg_name in sub_region]
for region in complex_regions:
region_name = region.name.replace(f"{region.parent} - ", "")
region_name = region.name.removeprefix(f"{region.parent} - ")
connection_data = CONNECTIONS[region.parent][region_name]
for exit_region in connection_data:
region.connect(self.multiworld.get_region(exit_region, self.player))
@@ -191,7 +191,7 @@ class MessengerWorld(World):
# create items that are always in the item pool
main_movement_items = ["Rope Dart", "Wingsuit"]
precollected_names = [item.name for item in self.multiworld.precollected_items[self.player]]
itempool: List[MessengerItem] = [
itempool: list[MessengerItem] = [
self.create_item(item)
for item in self.item_name_to_id
if item not in {
@@ -290,7 +290,7 @@ class MessengerWorld(World):
for portal, output in portal_info:
spoiler.set_entrance(f"{portal} Portal", output, "I can write anything I want here lmao", self.player)
def fill_slot_data(self) -> Dict[str, Any]:
def fill_slot_data(self) -> dict[str, Any]:
slot_data = {
"shop": {SHOP_ITEMS[item].internal_name: price for item, price in self.shop_prices.items()},
"figures": {FIGURINES[item].internal_name: price for item, price in self.figurine_prices.items()},
@@ -316,7 +316,7 @@ class MessengerWorld(World):
return self._filler_items.pop(0)
def create_item(self, name: str) -> MessengerItem:
item_id: Optional[int] = self.item_name_to_id.get(name, None)
item_id: int | None = self.item_name_to_id.get(name, None)
return MessengerItem(
name,
ItemClassification.progression if item_id is None else self.get_item_classification(name),
@@ -351,7 +351,7 @@ class MessengerWorld(World):
return ItemClassification.filler
@classmethod
def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: Set[int]) -> World:
def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: set[int]) -> World:
group = super().create_group(multiworld, new_player_id, players)
assert isinstance(group, MessengerWorld)

View File

@@ -5,7 +5,7 @@ import os.path
import subprocess
import urllib.request
from shutil import which
from typing import Any, Optional
from typing import Any
from zipfile import ZipFile
from Utils import open_file
@@ -17,7 +17,7 @@ from Utils import is_windows, messagebox, tuplize_version
MOD_URL = "https://api.github.com/repos/alwaysintreble/TheMessengerRandomizerModAP/releases/latest"
def ask_yes_no_cancel(title: str, text: str) -> Optional[bool]:
def ask_yes_no_cancel(title: str, text: str) -> bool | None:
"""
Wrapper for tkinter.messagebox.askyesnocancel, that creates a popup dialog box with yes, no, and cancel buttons.
@@ -33,7 +33,6 @@ def ask_yes_no_cancel(title: str, text: str) -> Optional[bool]:
return ret
def launch_game(*args) -> None:
"""Check the game installation, then launch it"""
def courier_installed() -> bool:

View File

@@ -1,6 +1,4 @@
from typing import Dict, List
CONNECTIONS: Dict[str, Dict[str, List[str]]] = {
CONNECTIONS: dict[str, dict[str, list[str]]] = {
"Ninja Village": {
"Right": [
"Autumn Hills - Left",
@@ -640,7 +638,7 @@ CONNECTIONS: Dict[str, Dict[str, List[str]]] = {
},
}
RANDOMIZED_CONNECTIONS: Dict[str, str] = {
RANDOMIZED_CONNECTIONS: dict[str, str] = {
"Ninja Village - Right": "Autumn Hills - Left",
"Autumn Hills - Left": "Ninja Village - Right",
"Autumn Hills - Right": "Forlorn Temple - Left",
@@ -680,7 +678,7 @@ RANDOMIZED_CONNECTIONS: Dict[str, str] = {
"Sunken Shrine - Left": "Howling Grotto - Bottom",
}
TRANSITIONS: List[str] = [
TRANSITIONS: list[str] = [
"Ninja Village - Right",
"Autumn Hills - Left",
"Autumn Hills - Right",

View File

@@ -2,7 +2,7 @@ from .shop import FIGURINES, SHOP_ITEMS
# items
# listing individual groups first for easy lookup
NOTES = [
NOTES: list[str] = [
"Key of Hope",
"Key of Chaos",
"Key of Courage",
@@ -11,7 +11,7 @@ NOTES = [
"Key of Symbiosis",
]
PROG_ITEMS = [
PROG_ITEMS: list[str] = [
"Wingsuit",
"Rope Dart",
"Lightfoot Tabi",
@@ -28,18 +28,18 @@ PROG_ITEMS = [
"Seashell",
]
PHOBEKINS = [
PHOBEKINS: list[str] = [
"Necro",
"Pyro",
"Claustro",
"Acro",
]
USEFUL_ITEMS = [
USEFUL_ITEMS: list[str] = [
"Windmill Shuriken",
]
FILLER = {
FILLER: dict[str, int] = {
"Time Shard": 5,
"Time Shard (10)": 10,
"Time Shard (50)": 20,
@@ -48,13 +48,13 @@ FILLER = {
"Time Shard (500)": 5,
}
TRAPS = {
TRAPS: dict[str, int] = {
"Teleport Trap": 5,
"Prophecy Trap": 10,
}
# item_name_to_id needs to be deterministic and match upstream
ALL_ITEMS = [
ALL_ITEMS: list[str] = [
*NOTES,
"Windmill Shuriken",
"Wingsuit",
@@ -83,7 +83,7 @@ ALL_ITEMS = [
# locations
# the names of these don't actually matter, but using the upstream's names for now
# order must be exactly the same as upstream
ALWAYS_LOCATIONS = [
ALWAYS_LOCATIONS: list[str] = [
# notes
"Sunken Shrine - Key of Love",
"Corrupted Future - Key of Courage",
@@ -160,7 +160,7 @@ ALWAYS_LOCATIONS = [
"Elemental Skylands Seal - Fire",
]
BOSS_LOCATIONS = [
BOSS_LOCATIONS: list[str] = [
"Autumn Hills - Leaf Golem",
"Catacombs - Ruxxtin",
"Howling Grotto - Emerald Golem",

View File

@@ -1,5 +1,4 @@
from dataclasses import dataclass
from typing import Dict
from schema import And, Optional, Or, Schema
@@ -167,7 +166,7 @@ class ShopPrices(Range):
default = 100
def planned_price(location: str) -> Dict[Optional, Or]:
def planned_price(location: str) -> dict[Optional, Or]:
return {
Optional(location): Or(
And(int, lambda n: n >= 0),

View File

@@ -1,5 +1,5 @@
from copy import deepcopy
from typing import List, TYPE_CHECKING
from typing import TYPE_CHECKING
from BaseClasses import CollectionState, PlandoOptions
from Options import PlandoConnection
@@ -8,7 +8,7 @@ if TYPE_CHECKING:
from . import MessengerWorld
PORTALS = [
PORTALS: list[str] = [
"Autumn Hills",
"Riviere Turquoise",
"Howling Grotto",
@@ -18,7 +18,7 @@ PORTALS = [
]
SHOP_POINTS = {
SHOP_POINTS: dict[str, list[str]] = {
"Autumn Hills": [
"Climbing Claws",
"Hope Path",
@@ -113,7 +113,7 @@ SHOP_POINTS = {
}
CHECKPOINTS = {
CHECKPOINTS: dict[str, list[str]] = {
"Autumn Hills": [
"Hope Latch",
"Key of Hope",
@@ -186,7 +186,7 @@ CHECKPOINTS = {
}
REGION_ORDER = [
REGION_ORDER: list[str] = [
"Autumn Hills",
"Forlorn Temple",
"Catacombs",
@@ -228,7 +228,7 @@ def shuffle_portals(world: "MessengerWorld") -> None:
return parent
def handle_planned_portals(plando_connections: List[PlandoConnection]) -> None:
def handle_planned_portals(plando_connections: list[PlandoConnection]) -> None:
"""checks the provided plando connections for portals and connects them"""
nonlocal available_portals

View File

@@ -1,7 +1,4 @@
from typing import Dict, List
LOCATIONS: Dict[str, List[str]] = {
LOCATIONS: dict[str, list[str]] = {
"Ninja Village - Nest": [
"Ninja Village - Candle",
"Ninja Village - Astral Seed",
@@ -201,7 +198,7 @@ LOCATIONS: Dict[str, List[str]] = {
}
SUB_REGIONS: Dict[str, List[str]] = {
SUB_REGIONS: dict[str, list[str]] = {
"Ninja Village": [
"Right",
],
@@ -385,7 +382,7 @@ SUB_REGIONS: Dict[str, List[str]] = {
# order is slightly funky here for back compat
MEGA_SHARDS: Dict[str, List[str]] = {
MEGA_SHARDS: dict[str, list[str]] = {
"Autumn Hills - Lakeside Checkpoint": ["Autumn Hills Mega Shard"],
"Forlorn Temple - Outside Shop": ["Hidden Entrance Mega Shard"],
"Catacombs - Top Left": ["Catacombs Mega Shard"],
@@ -414,7 +411,7 @@ MEGA_SHARDS: Dict[str, List[str]] = {
}
REGION_CONNECTIONS: Dict[str, Dict[str, str]] = {
REGION_CONNECTIONS: dict[str, dict[str, str]] = {
"Menu": {"Tower HQ": "Start Game"},
"Tower HQ": {
"Autumn Hills - Portal": "ToTHQ Autumn Hills Portal",
@@ -436,7 +433,7 @@ REGION_CONNECTIONS: Dict[str, Dict[str, str]] = {
# regions that don't have sub-regions
LEVELS: List[str] = [
LEVELS: list[str] = [
"Menu",
"Tower HQ",
"The Shop",

View File

@@ -1,4 +1,4 @@
from typing import Dict, TYPE_CHECKING
from typing import TYPE_CHECKING
from BaseClasses import CollectionState
from worlds.generic.Rules import CollectionRule, add_rule, allow_self_locking_items
@@ -12,9 +12,9 @@ if TYPE_CHECKING:
class MessengerRules:
player: int
world: "MessengerWorld"
connection_rules: Dict[str, CollectionRule]
region_rules: Dict[str, CollectionRule]
location_rules: Dict[str, CollectionRule]
connection_rules: dict[str, CollectionRule]
region_rules: dict[str, CollectionRule]
location_rules: dict[str, CollectionRule]
maximum_price: int
required_seals: int

View File

@@ -1,11 +1,11 @@
from typing import Dict, List, NamedTuple, Optional, Set, TYPE_CHECKING, Tuple, Union
from typing import NamedTuple, TYPE_CHECKING
if TYPE_CHECKING:
from . import MessengerWorld
else:
MessengerWorld = object
PROG_SHOP_ITEMS: List[str] = [
PROG_SHOP_ITEMS: list[str] = [
"Path of Resilience",
"Meditation",
"Strike of the Ninja",
@@ -14,7 +14,7 @@ PROG_SHOP_ITEMS: List[str] = [
"Aerobatics Warrior",
]
USEFUL_SHOP_ITEMS: List[str] = [
USEFUL_SHOP_ITEMS: list[str] = [
"Karuta Plates",
"Serendipitous Bodies",
"Kusari Jacket",
@@ -29,10 +29,10 @@ class ShopData(NamedTuple):
internal_name: str
min_price: int
max_price: int
prerequisite: Optional[Union[str, Set[str]]] = None
prerequisite: str | set[str] | None = None
SHOP_ITEMS: Dict[str, ShopData] = {
SHOP_ITEMS: dict[str, ShopData] = {
"Karuta Plates": ShopData("HP_UPGRADE_1", 20, 200),
"Serendipitous Bodies": ShopData("ENEMY_DROP_HP", 20, 300, "The Shop - Karuta Plates"),
"Path of Resilience": ShopData("DAMAGE_REDUCTION", 100, 500, "The Shop - Serendipitous Bodies"),
@@ -56,7 +56,7 @@ SHOP_ITEMS: Dict[str, ShopData] = {
"Focused Power Sense": ShopData("POWER_SEAL_WORLD_MAP", 300, 600, "The Shop - Power Sense"),
}
FIGURINES: Dict[str, ShopData] = {
FIGURINES: dict[str, ShopData] = {
"Green Kappa Figurine": ShopData("GREEN_KAPPA", 100, 500),
"Blue Kappa Figurine": ShopData("BLUE_KAPPA", 100, 500),
"Ountarde Figurine": ShopData("OUNTARDE", 100, 500),
@@ -73,12 +73,12 @@ FIGURINES: Dict[str, ShopData] = {
}
def shuffle_shop_prices(world: MessengerWorld) -> Tuple[Dict[str, int], Dict[str, int]]:
def shuffle_shop_prices(world: MessengerWorld) -> tuple[dict[str, int], dict[str, int]]:
shop_price_mod = world.options.shop_price.value
shop_price_planned = world.options.shop_price_plan
shop_prices: Dict[str, int] = {}
figurine_prices: Dict[str, int] = {}
shop_prices: dict[str, int] = {}
figurine_prices: dict[str, int] = {}
for item, price in shop_price_planned.value.items():
if not isinstance(price, int):
price = world.random.choices(list(price.keys()), weights=list(price.values()))[0]

View File

@@ -1,5 +1,5 @@
from functools import cached_property
from typing import Optional, TYPE_CHECKING
from typing import TYPE_CHECKING
from BaseClasses import CollectionState, Entrance, Item, ItemClassification, Location, Region
from .regions import LOCATIONS, MEGA_SHARDS
@@ -10,14 +10,14 @@ if TYPE_CHECKING:
class MessengerEntrance(Entrance):
world: Optional["MessengerWorld"] = None
world: "MessengerWorld | None" = None
class MessengerRegion(Region):
parent: str
entrance_type = MessengerEntrance
def __init__(self, name: str, world: "MessengerWorld", parent: Optional[str] = None) -> None:
def __init__(self, name: str, world: "MessengerWorld", parent: str | None = None) -> None:
super().__init__(name, world.player, world.multiworld)
self.parent = parent
locations = []
@@ -48,7 +48,7 @@ class MessengerRegion(Region):
class MessengerLocation(Location):
game = "The Messenger"
def __init__(self, player: int, name: str, loc_id: Optional[int], parent: MessengerRegion) -> None:
def __init__(self, player: int, name: str, loc_id: int | None, parent: MessengerRegion) -> None:
super().__init__(player, name, loc_id, parent)
if loc_id is None:
if name == "Rescue Phantom":
@@ -59,7 +59,7 @@ class MessengerLocation(Location):
class MessengerShopLocation(MessengerLocation):
@cached_property
def cost(self) -> int:
name = self.name.replace("The Shop - ", "") # TODO use `remove_prefix` when 3.8 finally gets dropped
name = self.name.removeprefix("The Shop - ")
world = self.parent_region.multiworld.worlds[self.player]
shop_data = SHOP_ITEMS[name]
if shop_data.prerequisite:

View File

@@ -77,7 +77,7 @@ class PlandoTest(MessengerTestBase):
loc = f"The Shop - {loc}"
self.assertLessEqual(price, self.multiworld.get_location(loc, self.player).cost)
self.assertTrue(loc.replace("The Shop - ", "") in SHOP_ITEMS)
self.assertTrue(loc.removeprefix("The Shop - ") in SHOP_ITEMS)
self.assertEqual(len(prices), len(SHOP_ITEMS))
figures = self.world.figurine_prices

View File

@@ -96,13 +96,13 @@ class MM2World(World):
location_name_groups = location_groups
web = MM2WebWorld()
rom_name: bytearray
world_version: Tuple[int, int, int] = (0, 3, 1)
world_version: Tuple[int, int, int] = (0, 3, 2)
wily_5_weapons: Dict[int, List[int]]
def __init__(self, world: MultiWorld, player: int):
def __init__(self, multiworld: MultiWorld, player: int):
self.rom_name = bytearray()
self.rom_name_available_event = threading.Event()
super().__init__(world, player)
super().__init__(multiworld, player)
self.weapon_damage = deepcopy(weapon_damage)
self.wily_5_weapons = {}

View File

@@ -133,28 +133,6 @@ def set_rules(world: "MM2World") -> None:
# Wily Machine needs all three weaknesses present, so allow
elif 4 > world.weapon_damage[weapon][i] > 0:
world.weapon_damage[weapon][i] = 0
# handle special cases
for boss in range(14):
for weapon in (1, 3, 6, 8):
if (0 < world.weapon_damage[weapon][boss] < minimum_weakness_requirement[weapon] and
not any(world.weapon_damage[i][boss] > 0 for i in range(1, 8) if i != weapon)):
# Weapon does not have enough possible ammo to kill the boss, raise the damage
if boss == 9:
if weapon != 3:
# Atomic Fire and Crash Bomber cannot be Picopico-kun's only weakness
world.weapon_damage[weapon][boss] = 0
weakness = world.random.choice((2, 3, 4, 5, 7, 8))
world.weapon_damage[weakness][boss] = minimum_weakness_requirement[weakness]
elif boss == 11:
if weapon == 1:
# Atomic Fire cannot be Boobeam Trap's only weakness
world.weapon_damage[weapon][boss] = 0
weakness = world.random.choice((2, 3, 4, 5, 6, 7, 8))
world.weapon_damage[weakness][boss] = minimum_weakness_requirement[weakness]
else:
world.weapon_damage[weapon][boss] = minimum_weakness_requirement[weapon]
starting = world.options.starting_robot_master.value
world.weapon_damage[0][starting] = 1
for p_boss in world.options.plando_weakness:
for p_weapon in world.options.plando_weakness[p_boss]:
@@ -168,6 +146,28 @@ def set_rules(world: "MM2World") -> None:
world.weapon_damage[weapons_to_id[p_weapon]][bosses[p_boss]] \
= world.options.plando_weakness[p_boss][p_weapon]
# handle special cases
for boss in range(14):
for weapon in (1, 2, 3, 6, 8):
if (0 < world.weapon_damage[weapon][boss] < minimum_weakness_requirement[weapon] and
not any(world.weapon_damage[i][boss] >= minimum_weakness_requirement[weapon]
for i in range(9) if i != weapon)):
# Weapon does not have enough possible ammo to kill the boss, raise the damage
if boss == 9:
if weapon in (1, 6):
# Atomic Fire and Crash Bomber cannot be Picopico-kun's only weakness
world.weapon_damage[weapon][boss] = 0
weakness = world.random.choice((2, 3, 4, 5, 7, 8))
world.weapon_damage[weakness][boss] = minimum_weakness_requirement[weakness]
elif boss == 11:
if weapon == 1:
# Atomic Fire cannot be Boobeam Trap's only weakness
world.weapon_damage[weapon][boss] = 0
weakness = world.random.choice((2, 3, 4, 5, 6, 7, 8))
world.weapon_damage[weakness][boss] = minimum_weakness_requirement[weakness]
else:
world.weapon_damage[weapon][boss] = minimum_weakness_requirement[weapon]
if world.weapon_damage[0][world.options.starting_robot_master.value] < 1:
world.weapon_damage[0][world.options.starting_robot_master.value] = weapon_damage[0][world.options.starting_robot_master.value]
@@ -209,11 +209,11 @@ def set_rules(world: "MM2World") -> None:
continue
highest, wp = max(zip(weapon_weight.values(), weapon_weight.keys()))
uses = weapon_energy[wp] // weapon_costs[wp]
used_weapons[boss].add(wp)
if int(uses * boss_damage[wp]) > boss_health[boss]:
used = ceil(boss_health[boss] / boss_damage[wp])
weapon_energy[wp] -= weapon_costs[wp] * used
boss_health[boss] = 0
used_weapons[boss].add(wp)
elif highest <= 0:
# we are out of weapons that can actually damage the boss
# so find the weapon that has the most uses, and apply that as an additional weakness
@@ -221,18 +221,21 @@ def set_rules(world: "MM2World") -> None:
# Quick Boomerang and no other, it would only be 28 off from defeating all 9, which Metal Blade should
# be able to cover
wp, max_uses = max((weapon, weapon_energy[weapon] // weapon_costs[weapon]) for weapon in weapon_weight
if weapon != 0)
if weapon != 0 and (weapon != 8 or boss != 12))
# Wily Machine cannot under any circumstances take damage from Time Stopper, prevent this
world.weapon_damage[wp][boss] = minimum_weakness_requirement[wp]
used = min(int(weapon_energy[wp] // weapon_costs[wp]),
ceil(boss_health[boss] // minimum_weakness_requirement[wp]))
ceil(boss_health[boss] / minimum_weakness_requirement[wp]))
weapon_energy[wp] -= weapon_costs[wp] * used
boss_health[boss] -= int(used * minimum_weakness_requirement[wp])
weapon_weight.pop(wp)
used_weapons[boss].add(wp)
else:
# drain the weapon and continue
boss_health[boss] -= int(uses * boss_damage[wp])
weapon_energy[wp] -= weapon_costs[wp] * uses
weapon_weight.pop(wp)
used_weapons[boss].add(wp)
world.wily_5_weapons = {boss: sorted(used_weapons[boss]) for boss in used_weapons}

View File

@@ -1,7 +1,7 @@
from typing import DefaultDict
from collections import defaultdict
MM2_WEAPON_ENCODING: DefaultDict[str, int] = defaultdict(lambda x: 0x6F, {
MM2_WEAPON_ENCODING: DefaultDict[str, int] = defaultdict(lambda: 0x6F, {
' ': 0x40,
'A': 0x41,
'B': 0x42,

View File

@@ -183,7 +183,7 @@ class MuseDashWorld(World):
if album:
return MuseDashSongItem(name, self.player, album)
song = self.md_collection.song_items.get(name)
song = self.md_collection.song_items[name]
return MuseDashSongItem(name, self.player, song)
def get_filler_item_name(self) -> str:

View File

@@ -1,7 +1,7 @@
import typing
import random
from dataclasses import dataclass
from Options import Option, DefaultOnToggle, Toggle, Range, OptionList, OptionSet, DeathLink, PlandoConnections, \
from Options import Option, DefaultOnToggle, Toggle, Range, OptionSet, DeathLink, PlandoConnections, \
PerGameCommonOptions, OptionGroup
from .EntranceShuffle import entrance_shuffle_table
from .LogicTricks import normalized_name_tricks
@@ -1272,7 +1272,7 @@ sfx_options: typing.Dict[str, type(Option)] = {
}
class LogicTricks(OptionList):
class LogicTricks(OptionSet):
"""Set various tricks for logic in Ocarina of Time.
Format as a comma-separated list of "nice" names: ["Fewer Tunic Requirements", "Hidden Grottos without Stone of Agony"].
A full list of supported tricks can be found at:

View File

@@ -57,11 +57,11 @@ location_rows = [
LocationRow('Catch a Swordfish', 'fishing', ['Lobster Spot', ], [SkillRequirement('Fishing', 50), ], [], 12),
LocationRow('Bake a Redberry Pie', 'cooking', ['Redberry Bush', 'Wheat', 'Windmill', 'Pie Dish', ], [SkillRequirement('Cooking', 10), ], [], 0),
LocationRow('Cook some Stew', 'cooking', ['Bowl', 'Meat', 'Potato', ], [SkillRequirement('Cooking', 25), ], [], 0),
LocationRow('Bake an Apple Pie', 'cooking', ['Cooking Apple', 'Wheat', 'Windmill', 'Pie Dish', ], [SkillRequirement('Cooking', 30), ], [], 2),
LocationRow('Bake an Apple Pie', 'cooking', ['Cooking Apple', 'Wheat', 'Windmill', 'Pie Dish', ], [SkillRequirement('Cooking', 32), ], [], 2),
LocationRow('Bake a Cake', 'cooking', ['Wheat', 'Windmill', 'Egg', 'Milk', 'Cake Tin', ], [SkillRequirement('Cooking', 40), ], [], 6),
LocationRow('Bake a Meat Pizza', 'cooking', ['Wheat', 'Windmill', 'Cheese', 'Tomato', 'Meat', ], [SkillRequirement('Cooking', 45), ], [], 8),
LocationRow('Burn some Oak Logs', 'firemaking', ['Oak Tree', ], [SkillRequirement('Firemaking', 15), ], [], 0),
LocationRow('Burn some Willow Logs', 'firemaking', ['Willow Tree', ], [SkillRequirement('Firemaking', 30), ], [], 0),
LocationRow('Burn some Oak Logs', 'firemaking', ['Oak Tree', ], [SkillRequirement('Firemaking', 15), SkillRequirement('Woodcutting', 15), ], [], 0),
LocationRow('Burn some Willow Logs', 'firemaking', ['Willow Tree', ], [SkillRequirement('Firemaking', 30), SkillRequirement('Woodcutting', 30), ], [], 0),
LocationRow('Travel on a Canoe', 'woodcutting', ['Canoe Tree', ], [SkillRequirement('Woodcutting', 12), ], [], 0),
LocationRow('Cut an Oak Log', 'woodcutting', ['Oak Tree', ], [SkillRequirement('Woodcutting', 15), ], [], 0),
LocationRow('Cut a Willow Log', 'woodcutting', ['Willow Tree', ], [SkillRequirement('Woodcutting', 30), ], [], 0),

View File

@@ -31,7 +31,7 @@ class RegionNames(str, Enum):
Mudskipper_Point = "Mudskipper Point"
Karamja = "Karamja"
Corsair_Cove = "Corsair Cove"
Wilderness = "The Wilderness"
Wilderness = "Wilderness"
Crandor = "Crandor"
# Resource Regions
Egg = "Egg"

337
worlds/osrs/Rules.py Normal file
View File

@@ -0,0 +1,337 @@
"""
Ensures a target level can be reached with available resources
"""
from worlds.generic.Rules import CollectionRule, add_rule
from .Names import RegionNames, ItemNames
def get_fishing_skill_rule(level, player, options) -> CollectionRule:
if options.max_fishing_level < level:
return lambda state: False
if options.brutal_grinds or level < 5:
return lambda state: state.can_reach_region(RegionNames.Shrimp, player)
if level < 20:
return lambda state: state.can_reach_region(RegionNames.Shrimp, player) and \
state.can_reach_region(RegionNames.Port_Sarim, player)
else:
return lambda state: state.can_reach_region(RegionNames.Shrimp, player) and \
state.can_reach_region(RegionNames.Port_Sarim, player) and \
state.can_reach_region(RegionNames.Fly_Fish, player)
def get_mining_skill_rule(level, player, options) -> CollectionRule:
if options.max_mining_level < level:
return lambda state: False
if options.brutal_grinds or level < 15:
return lambda state: state.can_reach_region(RegionNames.Bronze_Ores, player) or \
state.can_reach_region(RegionNames.Clay_Rock, player)
else:
# Iron is the best way to train all the way to 99, so having access to iron is all you need to check for
return lambda state: (state.can_reach_region(RegionNames.Bronze_Ores, player) or
state.can_reach_region(RegionNames.Clay_Rock, player)) and \
state.can_reach_region(RegionNames.Iron_Rock, player)
def get_woodcutting_skill_rule(level, player, options) -> CollectionRule:
if options.max_woodcutting_level < level:
return lambda state: False
if options.brutal_grinds or level < 15:
# I've checked. There is not a single chunk in the f2p that does not have at least one normal tree.
# Even the desert.
return lambda state: True
if level < 30:
return lambda state: state.can_reach_region(RegionNames.Oak_Tree, player)
else:
return lambda state: state.can_reach_region(RegionNames.Oak_Tree, player) and \
state.can_reach_region(RegionNames.Willow_Tree, player)
def get_smithing_skill_rule(level, player, options) -> CollectionRule:
if options.max_smithing_level < level:
return lambda state: False
if options.brutal_grinds:
return lambda state: state.can_reach_region(RegionNames.Bronze_Ores, player) and \
state.can_reach_region(RegionNames.Furnace, player)
if level < 15:
# Lumbridge has a special bronze-only anvil. This is the only anvil of its type so it's not included
# in the "Anvil" resource region. We still need to check for it though.
return lambda state: state.can_reach_region(RegionNames.Bronze_Ores, player) and \
state.can_reach_region(RegionNames.Furnace, player) and \
(state.can_reach_region(RegionNames.Anvil, player) or
state.can_reach_region(RegionNames.Lumbridge, player))
if level < 30:
# For levels between 15 and 30, the lumbridge anvil won't cut it. Only a real one will do
return lambda state: state.can_reach_region(RegionNames.Bronze_Ores, player) and \
state.can_reach_region(RegionNames.Iron_Rock, player) and \
state.can_reach_region(RegionNames.Furnace, player) and \
state.can_reach_region(RegionNames.Anvil, player)
else:
return lambda state: state.can_reach_region(RegionNames.Bronze_Ores, player) and \
state.can_reach_region(RegionNames.Iron_Rock, player) and \
state.can_reach_region(RegionNames.Coal_Rock, player) and \
state.can_reach_region(RegionNames.Furnace, player) and \
state.can_reach_region(RegionNames.Anvil, player)
def get_crafting_skill_rule(level, player, options):
if options.max_crafting_level < level:
return lambda state: False
# Crafting is really complex. Need a lot of sub-rules to make this even remotely readable
def can_spin(state):
return state.can_reach_region(RegionNames.Sheep, player) and \
state.can_reach_region(RegionNames.Spinning_Wheel, player)
def can_pot(state):
return state.can_reach_region(RegionNames.Clay_Rock, player) and \
state.can_reach_region(RegionNames.Barbarian_Village, player)
def can_tan(state):
return state.can_reach_region(RegionNames.Milk, player) and \
state.can_reach_region(RegionNames.Al_Kharid, player)
def mould_access(state):
return state.can_reach_region(RegionNames.Al_Kharid, player) or \
state.can_reach_region(RegionNames.Rimmington, player)
def can_silver(state):
return state.can_reach_region(RegionNames.Silver_Rock, player) and \
state.can_reach_region(RegionNames.Furnace, player) and mould_access(state)
def can_gold(state):
return state.can_reach_region(RegionNames.Gold_Rock, player) and \
state.can_reach_region(RegionNames.Furnace, player) and mould_access(state)
if options.brutal_grinds or level < 5:
return lambda state: can_spin(state) or can_pot(state) or can_tan(state)
can_smelt_gold = get_smithing_skill_rule(40, player, options)
can_smelt_silver = get_smithing_skill_rule(20, player, options)
if level < 16:
return lambda state: can_pot(state) or can_tan(state) or (can_gold(state) and can_smelt_gold(state))
else:
return lambda state: can_tan(state) or (can_silver(state) and can_smelt_silver(state)) or \
(can_gold(state) and can_smelt_gold(state))
def get_cooking_skill_rule(level, player, options) -> CollectionRule:
if options.max_cooking_level < level:
return lambda state: False
if options.brutal_grinds or level < 15:
return lambda state: state.can_reach_region(RegionNames.Milk, player) or \
state.can_reach_region(RegionNames.Egg, player) or \
state.can_reach_region(RegionNames.Shrimp, player) or \
(state.can_reach_region(RegionNames.Wheat, player) and
state.can_reach_region(RegionNames.Windmill, player))
else:
can_catch_fly_fish = get_fishing_skill_rule(20, player, options)
return lambda state: (
(state.can_reach_region(RegionNames.Fly_Fish, player) and can_catch_fly_fish(state)) or
(state.can_reach_region(RegionNames.Port_Sarim, player))
) and (
state.can_reach_region(RegionNames.Milk, player) or
state.can_reach_region(RegionNames.Egg, player) or
state.can_reach_region(RegionNames.Shrimp, player) or
(state.can_reach_region(RegionNames.Wheat, player) and
state.can_reach_region(RegionNames.Windmill, player))
)
def get_runecraft_skill_rule(level, player, options) -> CollectionRule:
if options.max_runecraft_level < level:
return lambda state: False
if not options.brutal_grinds:
# Ensure access to the relevant altars
if level >= 5:
return lambda state: state.has(ItemNames.QP_Rune_Mysteries, player) and \
state.can_reach_region(RegionNames.Falador_Farm, player) and \
state.can_reach_region(RegionNames.Lumbridge_Swamp, player)
if level >= 9:
return lambda state: state.has(ItemNames.QP_Rune_Mysteries, player) and \
state.can_reach_region(RegionNames.Falador_Farm, player) and \
state.can_reach_region(RegionNames.Lumbridge_Swamp, player) and \
state.can_reach_region(RegionNames.East_Of_Varrock, player)
if level >= 14:
return lambda state: state.has(ItemNames.QP_Rune_Mysteries, player) and \
state.can_reach_region(RegionNames.Falador_Farm, player) and \
state.can_reach_region(RegionNames.Lumbridge_Swamp, player) and \
state.can_reach_region(RegionNames.East_Of_Varrock, player) and \
state.can_reach_region(RegionNames.Al_Kharid, player)
return lambda state: state.has(ItemNames.QP_Rune_Mysteries, player) and \
state.can_reach_region(RegionNames.Falador_Farm, player)
def get_magic_skill_rule(level, player, options) -> CollectionRule:
if options.max_magic_level < level:
return lambda state: False
return lambda state: state.can_reach_region(RegionNames.Mind_Runes, player)
def get_firemaking_skill_rule(level, player, options) -> CollectionRule:
if options.max_firemaking_level < level:
return lambda state: False
if not options.brutal_grinds:
if level >= 30:
can_chop_willows = get_woodcutting_skill_rule(30, player, options)
return lambda state: state.can_reach_region(RegionNames.Willow_Tree, player) and can_chop_willows(state)
if level >= 15:
can_chop_oaks = get_woodcutting_skill_rule(15, player, options)
return lambda state: state.can_reach_region(RegionNames.Oak_Tree, player) and can_chop_oaks(state)
# If brutal grinds are on, or if the level is less than 15, you can train it.
return lambda state: True
def get_skill_rule(skill, level, player, options) -> CollectionRule:
if skill.lower() == "fishing":
return get_fishing_skill_rule(level, player, options)
if skill.lower() == "mining":
return get_mining_skill_rule(level, player, options)
if skill.lower() == "woodcutting":
return get_woodcutting_skill_rule(level, player, options)
if skill.lower() == "smithing":
return get_smithing_skill_rule(level, player, options)
if skill.lower() == "crafting":
return get_crafting_skill_rule(level, player, options)
if skill.lower() == "cooking":
return get_cooking_skill_rule(level, player, options)
if skill.lower() == "runecraft":
return get_runecraft_skill_rule(level, player, options)
if skill.lower() == "magic":
return get_magic_skill_rule(level, player, options)
if skill.lower() == "firemaking":
return get_firemaking_skill_rule(level, player, options)
return lambda state: True
def generate_special_rules_for(entrance, region_row, outbound_region_name, player, options):
if outbound_region_name == RegionNames.Cooks_Guild:
add_rule(entrance, get_cooking_skill_rule(32, player, options))
elif outbound_region_name == RegionNames.Crafting_Guild:
add_rule(entrance, get_crafting_skill_rule(40, player, options))
elif outbound_region_name == RegionNames.Corsair_Cove:
# Need to be able to start Corsair Curse in addition to having the item
add_rule(entrance, lambda state: state.can_reach(RegionNames.Falador_Farm, "Region", player))
elif outbound_region_name == "Camdozaal*":
add_rule(entrance, lambda state: state.has(ItemNames.QP_Below_Ice_Mountain, player))
elif region_row.name == "Dwarven Mountain Pass" and outbound_region_name == "Anvil*":
add_rule(entrance, lambda state: state.has(ItemNames.QP_Dorics_Quest, player))
# Special logic for canoes
canoe_regions = [RegionNames.Lumbridge, RegionNames.South_Of_Varrock, RegionNames.Barbarian_Village,
RegionNames.Edgeville, RegionNames.Wilderness]
if region_row.name in canoe_regions:
# Skill rules for greater distances
woodcutting_rule_d1 = get_woodcutting_skill_rule(12, player, options)
woodcutting_rule_d2 = get_woodcutting_skill_rule(27, player, options)
woodcutting_rule_d3 = get_woodcutting_skill_rule(42, player, options)
woodcutting_rule_all = get_woodcutting_skill_rule(57, player, options)
if region_row.name == RegionNames.Lumbridge:
# Canoe Tree access for the Location
if outbound_region_name == RegionNames.Canoe_Tree:
add_rule(entrance,
lambda state: (state.can_reach_region(RegionNames.South_Of_Varrock, player)
and woodcutting_rule_d1(state)) or
(state.can_reach_region(RegionNames.Barbarian_Village, player)
and woodcutting_rule_d2(state)) or
(state.can_reach_region(RegionNames.Edgeville, player)
and woodcutting_rule_d3(state)) or
(state.can_reach_region(RegionNames.Wilderness, player)
and woodcutting_rule_all(state)))
# Access to other chunks based on woodcutting settings
elif outbound_region_name == RegionNames.South_Of_Varrock:
add_rule(entrance, woodcutting_rule_d1)
elif outbound_region_name == RegionNames.Barbarian_Village:
add_rule(entrance, woodcutting_rule_d2)
elif outbound_region_name == RegionNames.Edgeville:
add_rule(entrance, woodcutting_rule_d3)
elif outbound_region_name == RegionNames.Wilderness:
add_rule(entrance, woodcutting_rule_all)
elif region_row.name == RegionNames.South_Of_Varrock:
if outbound_region_name == RegionNames.Canoe_Tree:
add_rule(entrance,
lambda state: (state.can_reach_region(RegionNames.Lumbridge, player)
and woodcutting_rule_d1(state)) or
(state.can_reach_region(RegionNames.Barbarian_Village, player)
and woodcutting_rule_d1(state)) or
(state.can_reach_region(RegionNames.Edgeville, player)
and woodcutting_rule_d2(state)) or
(state.can_reach_region(RegionNames.Wilderness, player)
and woodcutting_rule_d3(state)))
# Access to other chunks based on woodcutting settings
elif outbound_region_name == RegionNames.Lumbridge:
add_rule(entrance, woodcutting_rule_d1)
elif outbound_region_name == RegionNames.Barbarian_Village:
add_rule(entrance, woodcutting_rule_d1)
elif outbound_region_name == RegionNames.Edgeville:
add_rule(entrance, woodcutting_rule_d3)
elif outbound_region_name == RegionNames.Wilderness:
add_rule(entrance, woodcutting_rule_all)
elif region_row.name == RegionNames.Barbarian_Village:
if outbound_region_name == RegionNames.Canoe_Tree:
add_rule(entrance,
lambda state: (state.can_reach_region(RegionNames.Lumbridge, player)
and woodcutting_rule_d2(state)) or (state.can_reach_region(RegionNames.South_Of_Varrock, player)
and woodcutting_rule_d1(state)) or (state.can_reach_region(RegionNames.Edgeville, player)
and woodcutting_rule_d1(state)) or (state.can_reach_region(RegionNames.Wilderness, player)
and woodcutting_rule_d2(state)))
# Access to other chunks based on woodcutting settings
elif outbound_region_name == RegionNames.Lumbridge:
add_rule(entrance, woodcutting_rule_d2)
elif outbound_region_name == RegionNames.South_Of_Varrock:
add_rule(entrance, woodcutting_rule_d1)
# Edgeville does not need to be checked, because it's already adjacent
elif outbound_region_name == RegionNames.Wilderness:
add_rule(entrance, woodcutting_rule_d3)
elif region_row.name == RegionNames.Edgeville:
if outbound_region_name == RegionNames.Canoe_Tree:
add_rule(entrance,
lambda state: (state.can_reach_region(RegionNames.Lumbridge, player)
and woodcutting_rule_d3(state)) or
(state.can_reach_region(RegionNames.South_Of_Varrock, player)
and woodcutting_rule_d2(state)) or
(state.can_reach_region(RegionNames.Barbarian_Village, player)
and woodcutting_rule_d1(state)) or
(state.can_reach_region(RegionNames.Wilderness, player)
and woodcutting_rule_d1(state)))
# Access to other chunks based on woodcutting settings
elif outbound_region_name == RegionNames.Lumbridge:
add_rule(entrance, woodcutting_rule_d3)
elif outbound_region_name == RegionNames.South_Of_Varrock:
add_rule(entrance, woodcutting_rule_d2)
# Barbarian Village does not need to be checked, because it's already adjacent
# Wilderness does not need to be checked, because it's already adjacent
elif region_row.name == RegionNames.Wilderness:
if outbound_region_name == RegionNames.Canoe_Tree:
add_rule(entrance,
lambda state: (state.can_reach_region(RegionNames.Lumbridge, player)
and woodcutting_rule_all(state)) or
(state.can_reach_region(RegionNames.South_Of_Varrock, player)
and woodcutting_rule_d3(state)) or
(state.can_reach_region(RegionNames.Barbarian_Village, player)
and woodcutting_rule_d2(state)) or
(state.can_reach_region(RegionNames.Edgeville, player)
and woodcutting_rule_d1(state)))
# Access to other chunks based on woodcutting settings
elif outbound_region_name == RegionNames.Lumbridge:
add_rule(entrance, woodcutting_rule_all)
elif outbound_region_name == RegionNames.South_Of_Varrock:
add_rule(entrance, woodcutting_rule_d3)
elif outbound_region_name == RegionNames.Barbarian_Village:
add_rule(entrance, woodcutting_rule_d2)
# Edgeville does not need to be checked, because it's already adjacent

View File

@@ -1,12 +1,12 @@
import typing
from BaseClasses import Item, Tutorial, ItemClassification, Region, MultiWorld
from BaseClasses import Item, Tutorial, ItemClassification, Region, MultiWorld, CollectionState
from Fill import fill_restrictive, FillError
from worlds.AutoWorld import WebWorld, World
from worlds.generic.Rules import add_rule, CollectionRule
from .Items import OSRSItem, starting_area_dict, chunksanity_starting_chunks, QP_Items, ItemRow, \
chunksanity_special_region_names
from .Locations import OSRSLocation, LocationRow
from .Rules import *
from .Options import OSRSOptions, StartingArea
from .Names import LocationNames, ItemNames, RegionNames
@@ -46,6 +46,7 @@ class OSRSWorld(World):
web = OSRSWeb()
base_id = 0x070000
data_version = 1
explicit_indirect_conditions = False
item_name_to_id = {item_rows[i].name: 0x070000 + i for i in range(len(item_rows))}
location_name_to_id = {location_rows[i].name: 0x070000 + i for i in range(len(location_rows))}
@@ -61,6 +62,7 @@ class OSRSWorld(World):
starting_area_item: str
locations_by_category: typing.Dict[str, typing.List[LocationRow]]
available_QP_locations: typing.List[str]
def __init__(self, multiworld: MultiWorld, player: int):
super().__init__(multiworld, player)
@@ -75,6 +77,7 @@ class OSRSWorld(World):
self.starting_area_item = ""
self.locations_by_category = {}
self.available_QP_locations = []
def generate_early(self) -> None:
location_categories = [location_row.category for location_row in location_rows]
@@ -90,9 +93,9 @@ class OSRSWorld(World):
rnd = self.random
starting_area = self.options.starting_area
#UT specific override, if we are in normal gen, resolve starting area, we will get it from slot_data in UT
if not hasattr(self.multiworld, "generation_is_fake"):
if not hasattr(self.multiworld, "generation_is_fake"):
if starting_area.value == StartingArea.option_any_bank:
self.starting_area_item = rnd.choice(starting_area_dict)
elif starting_area.value < StartingArea.option_chunksanity:
@@ -127,7 +130,6 @@ class OSRSWorld(World):
starting_entrance.access_rule = lambda state: state.has(self.starting_area_item, self.player)
starting_entrance.connect(self.region_name_to_data[starting_area_region])
def create_regions(self) -> None:
"""
called to place player's regions into the MultiWorld's regions list. If it's hard to separate, this can be done
@@ -145,7 +147,8 @@ class OSRSWorld(World):
# Removes the word "Area: " from the item name to get the region it applies to.
# I figured tacking "Area: " at the beginning would make it _easier_ to tell apart. Turns out it made it worse
if self.starting_area_item != "": #if area hasn't been set, then we shouldn't connect it
# if area hasn't been set, then we shouldn't connect it
if self.starting_area_item != "":
if self.starting_area_item in chunksanity_special_region_names:
starting_area_region = chunksanity_special_region_names[self.starting_area_item]
else:
@@ -164,11 +167,8 @@ class OSRSWorld(World):
entrance.connect(self.region_name_to_data[parsed_outbound])
item_name = self.region_rows_by_name[parsed_outbound].itemReq
if "*" not in outbound_region_name and "*" not in item_name:
entrance.access_rule = lambda state, item_name=item_name: state.has(item_name, self.player)
continue
self.generate_special_rules_for(entrance, region_row, outbound_region_name)
entrance.access_rule = lambda state, item_name=item_name.replace("*",""): state.has(item_name, self.player)
generate_special_rules_for(entrance, region_row, outbound_region_name, self.player, self.options)
for resource_region in region_row.resources:
if not resource_region:
@@ -178,321 +178,34 @@ class OSRSWorld(World):
if "*" not in resource_region:
entrance.connect(self.region_name_to_data[resource_region])
else:
self.generate_special_rules_for(entrance, region_row, resource_region)
entrance.connect(self.region_name_to_data[resource_region.replace('*', '')])
generate_special_rules_for(entrance, region_row, resource_region, self.player, self.options)
self.roll_locations()
def generate_special_rules_for(self, entrance, region_row, outbound_region_name):
# print(f"Special rules required to access region {outbound_region_name} from {region_row.name}")
if outbound_region_name == RegionNames.Cooks_Guild:
item_name = self.region_rows_by_name[outbound_region_name].itemReq.replace('*', '')
cooking_level_rule = self.get_skill_rule("cooking", 32)
entrance.access_rule = lambda state: state.has(item_name, self.player) and \
cooking_level_rule(state)
if self.options.brutal_grinds:
cooking_level_32_regions = {
RegionNames.Milk,
RegionNames.Egg,
RegionNames.Shrimp,
RegionNames.Wheat,
RegionNames.Windmill,
}
else:
# Level 15 cooking and higher requires level 20 fishing.
fishing_level_20_regions = {
RegionNames.Shrimp,
RegionNames.Port_Sarim,
}
cooking_level_32_regions = {
RegionNames.Milk,
RegionNames.Egg,
RegionNames.Shrimp,
RegionNames.Wheat,
RegionNames.Windmill,
RegionNames.Fly_Fish,
*fishing_level_20_regions,
}
for region_name in cooking_level_32_regions:
self.multiworld.register_indirect_condition(self.get_region(region_name), entrance)
return
if outbound_region_name == RegionNames.Crafting_Guild:
item_name = self.region_rows_by_name[outbound_region_name].itemReq.replace('*', '')
crafting_level_rule = self.get_skill_rule("crafting", 40)
entrance.access_rule = lambda state: state.has(item_name, self.player) and \
crafting_level_rule(state)
if self.options.brutal_grinds:
crafting_level_40_regions = {
# can_spin
RegionNames.Sheep,
RegionNames.Spinning_Wheel,
# can_pot
RegionNames.Clay_Rock,
RegionNames.Barbarian_Village,
# can_tan
RegionNames.Milk,
RegionNames.Al_Kharid,
}
else:
mould_access_regions = {
RegionNames.Al_Kharid,
RegionNames.Rimmington,
}
smithing_level_20_regions = {
RegionNames.Bronze_Ores,
RegionNames.Iron_Rock,
RegionNames.Furnace,
RegionNames.Anvil,
}
smithing_level_40_regions = {
*smithing_level_20_regions,
RegionNames.Coal_Rock,
}
crafting_level_40_regions = {
# can_tan
RegionNames.Milk,
RegionNames.Al_Kharid,
# can_silver
RegionNames.Silver_Rock,
RegionNames.Furnace,
*mould_access_regions,
# can_smelt_silver
*smithing_level_20_regions,
# can_gold
RegionNames.Gold_Rock,
RegionNames.Furnace,
*mould_access_regions,
# can_smelt_gold
*smithing_level_40_regions,
}
for region_name in crafting_level_40_regions:
self.multiworld.register_indirect_condition(self.get_region(region_name), entrance)
return
if outbound_region_name == RegionNames.Corsair_Cove:
item_name = self.region_rows_by_name[outbound_region_name].itemReq.replace('*', '')
# Need to be able to start Corsair Curse in addition to having the item
entrance.access_rule = lambda state: state.has(item_name, self.player) and \
state.can_reach(RegionNames.Falador_Farm, "Region", self.player)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Falador_Farm, self.player), entrance)
return
if outbound_region_name == "Camdozaal*":
item_name = self.region_rows_by_name[outbound_region_name.replace('*', '')].itemReq
entrance.access_rule = lambda state: state.has(item_name, self.player) and \
state.has(ItemNames.QP_Below_Ice_Mountain, self.player)
return
if region_row.name == "Dwarven Mountain Pass" and outbound_region_name == "Anvil*":
entrance.access_rule = lambda state: state.has(ItemNames.QP_Dorics_Quest, self.player)
return
# Special logic for canoes
canoe_regions = [RegionNames.Lumbridge, RegionNames.South_Of_Varrock, RegionNames.Barbarian_Village,
RegionNames.Edgeville, RegionNames.Wilderness]
if region_row.name in canoe_regions:
# Skill rules for greater distances
woodcutting_rule_d1 = self.get_skill_rule("woodcutting", 12)
woodcutting_rule_d2 = self.get_skill_rule("woodcutting", 27)
woodcutting_rule_d3 = self.get_skill_rule("woodcutting", 42)
woodcutting_rule_all = self.get_skill_rule("woodcutting", 57)
def add_indirect_conditions_for_woodcutting_levels(entrance, *levels: int):
if self.options.brutal_grinds:
# No access to specific regions required.
return
# Currently, each level requirement requires everything from the previous level requirements, so the
# maximum level requirement can be taken.
max_level = max(levels, default=0)
max_level = min(max_level, self.options.max_woodcutting_level.value)
if 15 <= max_level < 30:
self.multiworld.register_indirect_condition(self.get_region(RegionNames.Oak_Tree), entrance)
elif 30 <= max_level:
self.multiworld.register_indirect_condition(self.get_region(RegionNames.Oak_Tree), entrance)
self.multiworld.register_indirect_condition(self.get_region(RegionNames.Willow_Tree), entrance)
if region_row.name == RegionNames.Lumbridge:
# Canoe Tree access for the Location
if outbound_region_name == RegionNames.Canoe_Tree:
entrance.access_rule = \
lambda state: (state.can_reach_region(RegionNames.South_Of_Varrock, self.player)
and woodcutting_rule_d1(state) and self.options.max_woodcutting_level >= 12) or \
(state.can_reach_region(RegionNames.Barbarian_Village)
and woodcutting_rule_d2(state) and self.options.max_woodcutting_level >= 27) or \
(state.can_reach_region(RegionNames.Edgeville)
and woodcutting_rule_d3(state) and self.options.max_woodcutting_level >= 42) or \
(state.can_reach_region(RegionNames.Wilderness)
and woodcutting_rule_all(state) and self.options.max_woodcutting_level >= 57)
add_indirect_conditions_for_woodcutting_levels(entrance, 12, 27, 42, 57)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.South_Of_Varrock, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Barbarian_Village, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Edgeville, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Wilderness, self.player), entrance)
# Access to other chunks based on woodcutting settings
# South of Varrock does not need to be checked, because it's already adjacent
if outbound_region_name == RegionNames.Barbarian_Village:
entrance.access_rule = lambda state: woodcutting_rule_d2(state) \
and self.options.max_woodcutting_level >= 27
add_indirect_conditions_for_woodcutting_levels(entrance, 27)
if outbound_region_name == RegionNames.Edgeville:
entrance.access_rule = lambda state: woodcutting_rule_d3(state) \
and self.options.max_woodcutting_level >= 42
add_indirect_conditions_for_woodcutting_levels(entrance, 42)
if outbound_region_name == RegionNames.Wilderness:
entrance.access_rule = lambda state: woodcutting_rule_all(state) \
and self.options.max_woodcutting_level >= 57
add_indirect_conditions_for_woodcutting_levels(entrance, 57)
if region_row.name == RegionNames.South_Of_Varrock:
if outbound_region_name == RegionNames.Canoe_Tree:
entrance.access_rule = \
lambda state: (state.can_reach_region(RegionNames.Lumbridge, self.player)
and woodcutting_rule_d1(state) and self.options.max_woodcutting_level >= 12) or \
(state.can_reach_region(RegionNames.Barbarian_Village)
and woodcutting_rule_d1(state) and self.options.max_woodcutting_level >= 12) or \
(state.can_reach_region(RegionNames.Edgeville)
and woodcutting_rule_d2(state) and self.options.max_woodcutting_level >= 27) or \
(state.can_reach_region(RegionNames.Wilderness)
and woodcutting_rule_d3(state) and self.options.max_woodcutting_level >= 42)
add_indirect_conditions_for_woodcutting_levels(entrance, 12, 27, 42)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Lumbridge, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Barbarian_Village, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Edgeville, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Wilderness, self.player), entrance)
# Access to other chunks based on woodcutting settings
# Lumbridge does not need to be checked, because it's already adjacent
if outbound_region_name == RegionNames.Barbarian_Village:
entrance.access_rule = lambda state: woodcutting_rule_d1(state) \
and self.options.max_woodcutting_level >= 12
add_indirect_conditions_for_woodcutting_levels(entrance, 12)
if outbound_region_name == RegionNames.Edgeville:
entrance.access_rule = lambda state: woodcutting_rule_d3(state) \
and self.options.max_woodcutting_level >= 27
add_indirect_conditions_for_woodcutting_levels(entrance, 27)
if outbound_region_name == RegionNames.Wilderness:
entrance.access_rule = lambda state: woodcutting_rule_all(state) \
and self.options.max_woodcutting_level >= 42
add_indirect_conditions_for_woodcutting_levels(entrance, 42)
if region_row.name == RegionNames.Barbarian_Village:
if outbound_region_name == RegionNames.Canoe_Tree:
entrance.access_rule = \
lambda state: (state.can_reach_region(RegionNames.Lumbridge, self.player)
and woodcutting_rule_d2(state) and self.options.max_woodcutting_level >= 27) or \
(state.can_reach_region(RegionNames.South_Of_Varrock)
and woodcutting_rule_d1(state) and self.options.max_woodcutting_level >= 12) or \
(state.can_reach_region(RegionNames.Edgeville)
and woodcutting_rule_d1(state) and self.options.max_woodcutting_level >= 12) or \
(state.can_reach_region(RegionNames.Wilderness)
and woodcutting_rule_d2(state) and self.options.max_woodcutting_level >= 27)
add_indirect_conditions_for_woodcutting_levels(entrance, 12, 27)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Lumbridge, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.South_Of_Varrock, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Edgeville, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Wilderness, self.player), entrance)
# Access to other chunks based on woodcutting settings
if outbound_region_name == RegionNames.Lumbridge:
entrance.access_rule = lambda state: woodcutting_rule_d2(state) \
and self.options.max_woodcutting_level >= 27
add_indirect_conditions_for_woodcutting_levels(entrance, 27)
if outbound_region_name == RegionNames.South_Of_Varrock:
entrance.access_rule = lambda state: woodcutting_rule_d1(state) \
and self.options.max_woodcutting_level >= 12
add_indirect_conditions_for_woodcutting_levels(entrance, 12)
# Edgeville does not need to be checked, because it's already adjacent
if outbound_region_name == RegionNames.Wilderness:
entrance.access_rule = lambda state: woodcutting_rule_d3(state) \
and self.options.max_woodcutting_level >= 42
add_indirect_conditions_for_woodcutting_levels(entrance, 42)
if region_row.name == RegionNames.Edgeville:
if outbound_region_name == RegionNames.Canoe_Tree:
entrance.access_rule = \
lambda state: (state.can_reach_region(RegionNames.Lumbridge, self.player)
and woodcutting_rule_d3(state) and self.options.max_woodcutting_level >= 42) or \
(state.can_reach_region(RegionNames.South_Of_Varrock)
and woodcutting_rule_d2(state) and self.options.max_woodcutting_level >= 27) or \
(state.can_reach_region(RegionNames.Barbarian_Village)
and woodcutting_rule_d1(state) and self.options.max_woodcutting_level >= 12) or \
(state.can_reach_region(RegionNames.Wilderness)
and woodcutting_rule_d1(state) and self.options.max_woodcutting_level >= 12)
add_indirect_conditions_for_woodcutting_levels(entrance, 12, 27, 42)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Lumbridge, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.South_Of_Varrock, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Barbarian_Village, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Wilderness, self.player), entrance)
# Access to other chunks based on woodcutting settings
if outbound_region_name == RegionNames.Lumbridge:
entrance.access_rule = lambda state: woodcutting_rule_d3(state) \
and self.options.max_woodcutting_level >= 42
add_indirect_conditions_for_woodcutting_levels(entrance, 42)
if outbound_region_name == RegionNames.South_Of_Varrock:
entrance.access_rule = lambda state: woodcutting_rule_d2(state) \
and self.options.max_woodcutting_level >= 27
add_indirect_conditions_for_woodcutting_levels(entrance, 27)
# Barbarian Village does not need to be checked, because it's already adjacent
# Wilderness does not need to be checked, because it's already adjacent
if region_row.name == RegionNames.Wilderness:
if outbound_region_name == RegionNames.Canoe_Tree:
entrance.access_rule = \
lambda state: (state.can_reach_region(RegionNames.Lumbridge, self.player)
and woodcutting_rule_all(state) and self.options.max_woodcutting_level >= 57) or \
(state.can_reach_region(RegionNames.South_Of_Varrock)
and woodcutting_rule_d3(state) and self.options.max_woodcutting_level >= 42) or \
(state.can_reach_region(RegionNames.Barbarian_Village)
and woodcutting_rule_d2(state) and self.options.max_woodcutting_level >= 27) or \
(state.can_reach_region(RegionNames.Edgeville)
and woodcutting_rule_d1(state) and self.options.max_woodcutting_level >= 12)
add_indirect_conditions_for_woodcutting_levels(entrance, 12, 27, 42, 57)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Lumbridge, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.South_Of_Varrock, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Barbarian_Village, self.player), entrance)
self.multiworld.register_indirect_condition(
self.multiworld.get_region(RegionNames.Edgeville, self.player), entrance)
# Access to other chunks based on woodcutting settings
if outbound_region_name == RegionNames.Lumbridge:
entrance.access_rule = lambda state: woodcutting_rule_all(state) \
and self.options.max_woodcutting_level >= 57
add_indirect_conditions_for_woodcutting_levels(entrance, 57)
if outbound_region_name == RegionNames.South_Of_Varrock:
entrance.access_rule = lambda state: woodcutting_rule_d3(state) \
and self.options.max_woodcutting_level >= 42
add_indirect_conditions_for_woodcutting_levels(entrance, 42)
if outbound_region_name == RegionNames.Barbarian_Village:
entrance.access_rule = lambda state: woodcutting_rule_d2(state) \
and self.options.max_woodcutting_level >= 27
add_indirect_conditions_for_woodcutting_levels(entrance, 27)
# Edgeville does not need to be checked, because it's already adjacent
def task_within_skill_levels(self, skills_required):
# Loop through each required skill. If any of its requirements are out of the defined limit, return false
for skill in skills_required:
max_level_for_skill = getattr(self.options, f"max_{skill.skill.lower()}_level")
if skill.level > max_level_for_skill:
return False
return True
def roll_locations(self):
locations_required = 0
generation_is_fake = hasattr(self.multiworld, "generation_is_fake") # UT specific override
locations_required = 0
for item_row in item_rows:
locations_required += item_row.amount
locations_added = 1 # At this point we've already added the starting area, so we start at 1 instead of 0
# Quests are always added
# Quests are always added first, before anything else is rolled
for i, location_row in enumerate(location_rows):
if location_row.category in {"quest", "points", "goal"}:
self.create_and_add_location(i)
if location_row.category == "quest":
locations_added += 1
if self.task_within_skill_levels(location_row.skills):
self.create_and_add_location(i)
if location_row.category == "quest":
locations_added += 1
# Build up the weighted Task Pool
rnd = self.random
@@ -516,10 +229,9 @@ class OSRSWorld(World):
task_types = ["prayer", "magic", "runecraft", "mining", "crafting",
"smithing", "fishing", "cooking", "firemaking", "woodcutting", "combat"]
for task_type in task_types:
max_level_for_task_type = getattr(self.options, f"max_{task_type}_level")
max_amount_for_task_type = getattr(self.options, f"max_{task_type}_tasks")
tasks_for_this_type = [task for task in self.locations_by_category[task_type]
if task.skills[0].level <= max_level_for_task_type]
if self.task_within_skill_levels(task.skills)]
if not self.options.progressive_tasks:
rnd.shuffle(tasks_for_this_type)
else:
@@ -568,6 +280,7 @@ class OSRSWorld(World):
self.add_location(task)
locations_added += 1
def add_location(self, location):
index = [i for i in range(len(location_rows)) if location_rows[i].name == location.name][0]
self.create_and_add_location(index)
@@ -586,11 +299,15 @@ class OSRSWorld(World):
def create_and_add_location(self, row_index) -> None:
location_row = location_rows[row_index]
# print(f"Adding task {location_row.name}")
# Quest Points are handled differently now, but in case this gets fed an older version of the data sheet,
# the points might still be listed in a different row
if location_row.category == "points":
return
# Create Location
location_id = self.base_id + row_index
if location_row.category == "points" or location_row.category == "goal":
if location_row.category == "goal":
location_id = None
location = OSRSLocation(self.player, location_row.name, location_id)
self.location_name_to_data[location_row.name] = location
@@ -602,6 +319,14 @@ class OSRSWorld(World):
location.parent_region = region
region.locations.append(location)
# If it's a quest, generate a "Points" location we'll add an event to
if location_row.category == "quest":
points_name = location_row.name.replace("Quest:", "Points:")
points_location = OSRSLocation(self.player, points_name)
self.location_name_to_data[points_name] = points_location
points_location.parent_region = region
region.locations.append(points_location)
def set_rules(self) -> None:
"""
called to set access and item rules on locations and entrances.
@@ -612,18 +337,26 @@ class OSRSWorld(World):
"Witchs_Potion", "Knights_Sword", "Goblin_Diplomacy", "Pirates_Treasure",
"Rune_Mysteries", "Misthalin_Mystery", "Corsair_Curse", "X_Marks_the_Spot",
"Below_Ice_Mountain"]
for qp_attr_name in quest_attr_names:
loc_name = getattr(LocationNames, f"QP_{qp_attr_name}")
item_name = getattr(ItemNames, f"QP_{qp_attr_name}")
self.multiworld.get_location(loc_name, self.player) \
.place_locked_item(self.create_event(item_name))
for quest_attr_name in quest_attr_names:
qp_loc_name = getattr(LocationNames, f"QP_{quest_attr_name}")
qp_loc = self.location_name_to_data.get(qp_loc_name)
q_loc_name = getattr(LocationNames, f"Q_{quest_attr_name}")
add_rule(self.multiworld.get_location(qp_loc_name, self.player), lambda state, q_loc_name=q_loc_name: (
self.multiworld.get_location(q_loc_name, self.player).can_reach(state)
))
q_loc = self.location_name_to_data.get(q_loc_name)
# Checks to make sure the task is actually in the list before trying to create its rules
if qp_loc and q_loc:
# Create the QP Event Item
item_name = getattr(ItemNames, f"QP_{quest_attr_name}")
qp_loc.place_locked_item(self.create_event(item_name))
# If a quest is excluded, don't actually consider it for quest point progression
if q_loc_name not in self.options.exclude_locations:
self.available_QP_locations.append(item_name)
# Set the access rule for the QP Location
add_rule(qp_loc, lambda state, loc=q_loc: (loc.can_reach(state)))
# place "Victory" at "Dragon Slayer" and set collection as win condition
self.multiworld.get_location(LocationNames.Q_Dragon_Slayer, self.player) \
@@ -639,7 +372,7 @@ class OSRSWorld(World):
lambda state, region_required=region_required: state.can_reach(region_required, "Region",
self.player))
for skill_req in location_row.skills:
add_rule(location, self.get_skill_rule(skill_req.skill, skill_req.level))
add_rule(location, get_skill_rule(skill_req.skill, skill_req.level, self.player, self.options))
for item_req in location_row.items:
add_rule(location, lambda state, item_req=item_req: state.has(item_req, self.player))
if location_row.qp:
@@ -664,124 +397,8 @@ class OSRSWorld(World):
def quest_points(self, state):
qp = 0
for qp_event in QP_Items:
for qp_event in self.available_QP_locations:
if state.has(qp_event, self.player):
qp += int(qp_event[0])
return qp
"""
Ensures a target level can be reached with available resources
"""
def get_skill_rule(self, skill, level) -> CollectionRule:
if skill.lower() == "fishing":
if self.options.brutal_grinds or level < 5:
return lambda state: state.can_reach(RegionNames.Shrimp, "Region", self.player)
if level < 20:
return lambda state: state.can_reach(RegionNames.Shrimp, "Region", self.player) and \
state.can_reach(RegionNames.Port_Sarim, "Region", self.player)
else:
return lambda state: state.can_reach(RegionNames.Shrimp, "Region", self.player) and \
state.can_reach(RegionNames.Port_Sarim, "Region", self.player) and \
state.can_reach(RegionNames.Fly_Fish, "Region", self.player)
if skill.lower() == "mining":
if self.options.brutal_grinds or level < 15:
return lambda state: state.can_reach(RegionNames.Bronze_Ores, "Region", self.player) or \
state.can_reach(RegionNames.Clay_Rock, "Region", self.player)
else:
# Iron is the best way to train all the way to 99, so having access to iron is all you need to check for
return lambda state: (state.can_reach(RegionNames.Bronze_Ores, "Region", self.player) or
state.can_reach(RegionNames.Clay_Rock, "Region", self.player)) and \
state.can_reach(RegionNames.Iron_Rock, "Region", self.player)
if skill.lower() == "woodcutting":
if self.options.brutal_grinds or level < 15:
# I've checked. There is not a single chunk in the f2p that does not have at least one normal tree.
# Even the desert.
return lambda state: True
if level < 30:
return lambda state: state.can_reach(RegionNames.Oak_Tree, "Region", self.player)
else:
return lambda state: state.can_reach(RegionNames.Oak_Tree, "Region", self.player) and \
state.can_reach(RegionNames.Willow_Tree, "Region", self.player)
if skill.lower() == "smithing":
if self.options.brutal_grinds:
return lambda state: state.can_reach(RegionNames.Bronze_Ores, "Region", self.player) and \
state.can_reach(RegionNames.Furnace, "Region", self.player)
if level < 15:
# Lumbridge has a special bronze-only anvil. This is the only anvil of its type so it's not included
# in the "Anvil" resource region. We still need to check for it though.
return lambda state: state.can_reach(RegionNames.Bronze_Ores, "Region", self.player) and \
state.can_reach(RegionNames.Furnace, "Region", self.player) and \
(state.can_reach(RegionNames.Anvil, "Region", self.player) or
state.can_reach(RegionNames.Lumbridge, "Region", self.player))
if level < 30:
# For levels between 15 and 30, the lumbridge anvil won't cut it. Only a real one will do
return lambda state: state.can_reach(RegionNames.Bronze_Ores, "Region", self.player) and \
state.can_reach(RegionNames.Iron_Rock, "Region", self.player) and \
state.can_reach(RegionNames.Furnace, "Region", self.player) and \
state.can_reach(RegionNames.Anvil, "Region", self.player)
else:
return lambda state: state.can_reach(RegionNames.Bronze_Ores, "Region", self.player) and \
state.can_reach(RegionNames.Iron_Rock, "Region", self.player) and \
state.can_reach(RegionNames.Coal_Rock, "Region", self.player) and \
state.can_reach(RegionNames.Furnace, "Region", self.player) and \
state.can_reach(RegionNames.Anvil, "Region", self.player)
if skill.lower() == "crafting":
# Crafting is really complex. Need a lot of sub-rules to make this even remotely readable
def can_spin(state):
return state.can_reach(RegionNames.Sheep, "Region", self.player) and \
state.can_reach(RegionNames.Spinning_Wheel, "Region", self.player)
def can_pot(state):
return state.can_reach(RegionNames.Clay_Rock, "Region", self.player) and \
state.can_reach(RegionNames.Barbarian_Village, "Region", self.player)
def can_tan(state):
return state.can_reach(RegionNames.Milk, "Region", self.player) and \
state.can_reach(RegionNames.Al_Kharid, "Region", self.player)
def mould_access(state):
return state.can_reach(RegionNames.Al_Kharid, "Region", self.player) or \
state.can_reach(RegionNames.Rimmington, "Region", self.player)
def can_silver(state):
return state.can_reach(RegionNames.Silver_Rock, "Region", self.player) and \
state.can_reach(RegionNames.Furnace, "Region", self.player) and mould_access(state)
def can_gold(state):
return state.can_reach(RegionNames.Gold_Rock, "Region", self.player) and \
state.can_reach(RegionNames.Furnace, "Region", self.player) and mould_access(state)
if self.options.brutal_grinds or level < 5:
return lambda state: can_spin(state) or can_pot(state) or can_tan(state)
can_smelt_gold = self.get_skill_rule("smithing", 40)
can_smelt_silver = self.get_skill_rule("smithing", 20)
if level < 16:
return lambda state: can_pot(state) or can_tan(state) or (can_gold(state) and can_smelt_gold(state))
else:
return lambda state: can_tan(state) or (can_silver(state) and can_smelt_silver(state)) or \
(can_gold(state) and can_smelt_gold(state))
if skill.lower() == "cooking":
if self.options.brutal_grinds or level < 15:
return lambda state: state.can_reach(RegionNames.Milk, "Region", self.player) or \
state.can_reach(RegionNames.Egg, "Region", self.player) or \
state.can_reach(RegionNames.Shrimp, "Region", self.player) or \
(state.can_reach(RegionNames.Wheat, "Region", self.player) and
state.can_reach(RegionNames.Windmill, "Region", self.player))
else:
can_catch_fly_fish = self.get_skill_rule("fishing", 20)
return lambda state: state.can_reach(RegionNames.Fly_Fish, "Region", self.player) and \
can_catch_fly_fish(state) and \
(state.can_reach(RegionNames.Milk, "Region", self.player) or
state.can_reach(RegionNames.Egg, "Region", self.player) or
state.can_reach(RegionNames.Shrimp, "Region", self.player) or
(state.can_reach(RegionNames.Wheat, "Region", self.player) and
state.can_reach(RegionNames.Windmill, "Region", self.player)))
if skill.lower() == "runecraft":
return lambda state: state.has(ItemNames.QP_Rune_Mysteries, self.player)
if skill.lower() == "magic":
return lambda state: state.can_reach(RegionNames.Mind_Runes, "Region", self.player)
return lambda state: True

View File

@@ -2,6 +2,7 @@
### Features
- Added many new item and location groups.
- Added a Swedish translation of the setup guide.
- The client communicates map transitions to any trackers connected to the slot.
- Added the player's Normalize Encounter Rates option to slot data for trackers.

View File

@@ -15,11 +15,11 @@ import settings
from worlds.AutoWorld import WebWorld, World
from .client import PokemonEmeraldClient # Unused, but required to register with BizHawkClient
from .data import LEGENDARY_POKEMON, MapData, SpeciesData, TrainerData, data as emerald_data
from .items import (ITEM_GROUPS, PokemonEmeraldItem, create_item_label_to_code_map, get_item_classification,
offset_item_value)
from .locations import (LOCATION_GROUPS, PokemonEmeraldLocation, create_location_label_to_id_map,
create_locations_with_tags, set_free_fly, set_legendary_cave_entrances)
from .data import LEGENDARY_POKEMON, MapData, SpeciesData, TrainerData, LocationCategory, data as emerald_data
from .groups import ITEM_GROUPS, LOCATION_GROUPS
from .items import PokemonEmeraldItem, create_item_label_to_code_map, get_item_classification, offset_item_value
from .locations import (PokemonEmeraldLocation, create_location_label_to_id_map, create_locations_by_category,
set_free_fly, set_legendary_cave_entrances)
from .opponents import randomize_opponent_parties
from .options import (Goal, DarkCavesRequireFlash, HmRequirements, ItemPoolType, PokemonEmeraldOptions,
RandomizeWildPokemon, RandomizeBadges, RandomizeHms, NormanRequirement)
@@ -133,9 +133,10 @@ class PokemonEmeraldWorld(World):
@classmethod
def stage_assert_generate(cls, multiworld: MultiWorld) -> None:
from .sanity_check import validate_regions
from .sanity_check import validate_regions, validate_group_maps
assert validate_regions()
assert validate_group_maps()
def get_filler_item_name(self) -> str:
return "Great Ball"
@@ -237,24 +238,32 @@ class PokemonEmeraldWorld(World):
def create_regions(self) -> None:
from .regions import create_regions
regions = create_regions(self)
all_regions = create_regions(self)
tags = {"Badge", "HM", "KeyItem", "Rod", "Bike", "EventTicket"} # Tags with progression items always included
# Categories with progression items always included
categories = {
LocationCategory.BADGE,
LocationCategory.HM,
LocationCategory.KEY,
LocationCategory.ROD,
LocationCategory.BIKE,
LocationCategory.TICKET
}
if self.options.overworld_items:
tags.add("OverworldItem")
categories.add(LocationCategory.OVERWORLD_ITEM)
if self.options.hidden_items:
tags.add("HiddenItem")
categories.add(LocationCategory.HIDDEN_ITEM)
if self.options.npc_gifts:
tags.add("NpcGift")
categories.add(LocationCategory.GIFT)
if self.options.berry_trees:
tags.add("BerryTree")
categories.add(LocationCategory.BERRY_TREE)
if self.options.dexsanity:
tags.add("Pokedex")
categories.add(LocationCategory.POKEDEX)
if self.options.trainersanity:
tags.add("Trainer")
create_locations_with_tags(self, regions, tags)
categories.add(LocationCategory.TRAINER)
create_locations_by_category(self, all_regions, categories)
self.multiworld.regions.extend(regions.values())
self.multiworld.regions.extend(all_regions.values())
# Exclude locations which are always locked behind the player's goal
def exclude_locations(location_names: List[str]):
@@ -325,21 +334,21 @@ class PokemonEmeraldWorld(World):
# Filter progression items which shouldn't be shuffled into the itempool.
# Their locations will still exist, but event items will be placed and
# locked at their vanilla locations instead.
filter_tags = set()
filter_categories = set()
if not self.options.key_items:
filter_tags.add("KeyItem")
filter_categories.add(LocationCategory.KEY)
if not self.options.rods:
filter_tags.add("Rod")
filter_categories.add(LocationCategory.ROD)
if not self.options.bikes:
filter_tags.add("Bike")
filter_categories.add(LocationCategory.BIKE)
if not self.options.event_tickets:
filter_tags.add("EventTicket")
filter_categories.add(LocationCategory.TICKET)
if self.options.badges in {RandomizeBadges.option_vanilla, RandomizeBadges.option_shuffle}:
filter_tags.add("Badge")
filter_categories.add(LocationCategory.BADGE)
if self.options.hms in {RandomizeHms.option_vanilla, RandomizeHms.option_shuffle}:
filter_tags.add("HM")
filter_categories.add(LocationCategory.HM)
# If Badges and HMs are set to the `shuffle` option, don't add them to
# the normal item pool, but do create their items and save them and
@@ -347,17 +356,17 @@ class PokemonEmeraldWorld(World):
if self.options.badges == RandomizeBadges.option_shuffle:
self.badge_shuffle_info = [
(location, self.create_item_by_code(location.default_item_code))
for location in [l for l in item_locations if "Badge" in l.tags]
for location in [l for l in item_locations if emerald_data.locations[l.key].category == LocationCategory.BADGE]
]
if self.options.hms == RandomizeHms.option_shuffle:
self.hm_shuffle_info = [
(location, self.create_item_by_code(location.default_item_code))
for location in [l for l in item_locations if "HM" in l.tags]
for location in [l for l in item_locations if emerald_data.locations[l.key].category == LocationCategory.HM]
]
# Filter down locations to actual items that will be filled and create
# the itempool.
item_locations = [location for location in item_locations if len(filter_tags & location.tags) == 0]
item_locations = [location for location in item_locations if emerald_data.locations[location.key].category not in filter_categories]
default_itempool = [self.create_item_by_code(location.default_item_code) for location in item_locations]
# Take the itempool as-is
@@ -366,7 +375,8 @@ class PokemonEmeraldWorld(World):
# Recreate the itempool from random items
elif self.options.item_pool_type in (ItemPoolType.option_diverse, ItemPoolType.option_diverse_balanced):
item_categories = ["Ball", "Heal", "Candy", "Vitamin", "EvoStone", "Money", "TM", "Held", "Misc", "Berry"]
item_categories = ["Ball", "Healing", "Rare Candy", "Vitamin", "Evolution Stone",
"Money", "TM", "Held", "Misc", "Berry"]
# Count occurrences of types of vanilla items in pool
item_category_counter = Counter()
@@ -436,25 +446,26 @@ class PokemonEmeraldWorld(World):
# Key items which are considered in access rules but not randomized are converted to events and placed
# in their vanilla locations so that the player can have them in their inventory for logic.
def convert_unrandomized_items_to_events(tag: str) -> None:
def convert_unrandomized_items_to_events(category: LocationCategory) -> None:
for location in self.multiworld.get_locations(self.player):
if location.tags is not None and tag in location.tags:
assert isinstance(location, PokemonEmeraldLocation)
if location.key is not None and emerald_data.locations[location.key].category == category:
location.place_locked_item(self.create_event(self.item_id_to_name[location.default_item_code]))
location.progress_type = LocationProgressType.DEFAULT
location.address = None
if self.options.badges == RandomizeBadges.option_vanilla:
convert_unrandomized_items_to_events("Badge")
convert_unrandomized_items_to_events(LocationCategory.BADGE)
if self.options.hms == RandomizeHms.option_vanilla:
convert_unrandomized_items_to_events("HM")
convert_unrandomized_items_to_events(LocationCategory.HM)
if not self.options.rods:
convert_unrandomized_items_to_events("Rod")
convert_unrandomized_items_to_events(LocationCategory.ROD)
if not self.options.bikes:
convert_unrandomized_items_to_events("Bike")
convert_unrandomized_items_to_events(LocationCategory.BIKE)
if not self.options.event_tickets:
convert_unrandomized_items_to_events("EventTicket")
convert_unrandomized_items_to_events(LocationCategory.TICKET)
if not self.options.key_items:
convert_unrandomized_items_to_events("KeyItem")
convert_unrandomized_items_to_events(LocationCategory.KEY)
def pre_fill(self) -> None:
# Badges and HMs that are set to shuffle need to be placed at

View File

@@ -117,6 +117,21 @@ class ItemData(NamedTuple):
tags: FrozenSet[str]
class LocationCategory(IntEnum):
BADGE = 0
HM = 1
KEY = 2
ROD = 3
BIKE = 4
TICKET = 5
OVERWORLD_ITEM = 6
HIDDEN_ITEM = 7
GIFT = 8
BERRY_TREE = 9
TRAINER = 10
POKEDEX = 11
class LocationData(NamedTuple):
name: str
label: str
@@ -124,6 +139,7 @@ class LocationData(NamedTuple):
default_item: int
address: Union[int, List[int]]
flag: int
category: LocationCategory
tags: FrozenSet[str]
@@ -431,6 +447,7 @@ def _init() -> None:
location_json["default_item"],
[location_json["address"]] + [j["address"] for j in alternate_rival_jsons],
location_json["flag"],
LocationCategory[location_attributes_json[location_name]["category"]],
frozenset(location_attributes_json[location_name]["tags"])
)
else:
@@ -441,6 +458,7 @@ def _init() -> None:
location_json["default_item"],
location_json["address"],
location_json["flag"],
LocationCategory[location_attributes_json[location_name]["category"]],
frozenset(location_attributes_json[location_name]["tags"])
)
new_region.locations.append(location_name)
@@ -948,6 +966,7 @@ def _init() -> None:
evo_stage_to_ball_map[evo_stage],
data.locations[dex_location_name].address,
data.locations[dex_location_name].flag,
data.locations[dex_location_name].category,
data.locations[dex_location_name].tags
)

View File

@@ -52,49 +52,49 @@
"ITEM_HM_CUT": {
"label": "HM01 Cut",
"classification": "PROGRESSION",
"tags": ["HM", "Unique"],
"tags": ["HM", "HM01", "Unique"],
"modern_id": 420
},
"ITEM_HM_FLY": {
"label": "HM02 Fly",
"classification": "PROGRESSION",
"tags": ["HM", "Unique"],
"tags": ["HM", "HM02", "Unique"],
"modern_id": 421
},
"ITEM_HM_SURF": {
"label": "HM03 Surf",
"classification": "PROGRESSION",
"tags": ["HM", "Unique"],
"tags": ["HM", "HM03", "Unique"],
"modern_id": 422
},
"ITEM_HM_STRENGTH": {
"label": "HM04 Strength",
"classification": "PROGRESSION",
"tags": ["HM", "Unique"],
"tags": ["HM", "HM04", "Unique"],
"modern_id": 423
},
"ITEM_HM_FLASH": {
"label": "HM05 Flash",
"classification": "PROGRESSION",
"tags": ["HM", "Unique"],
"tags": ["HM", "HM05", "Unique"],
"modern_id": 424
},
"ITEM_HM_ROCK_SMASH": {
"label": "HM06 Rock Smash",
"classification": "PROGRESSION",
"tags": ["HM", "Unique"],
"tags": ["HM", "HM06", "Unique"],
"modern_id": 425
},
"ITEM_HM_WATERFALL": {
"label": "HM07 Waterfall",
"classification": "PROGRESSION",
"tags": ["HM", "Unique"],
"tags": ["HM", "HM07", "Unique"],
"modern_id": 737
},
"ITEM_HM_DIVE": {
"label": "HM08 Dive",
"classification": "PROGRESSION",
"tags": ["HM", "Unique"],
"tags": ["HM", "HM08", "Unique"],
"modern_id": null
},
@@ -375,169 +375,169 @@
"ITEM_POTION": {
"label": "Potion",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 17
},
"ITEM_ANTIDOTE": {
"label": "Antidote",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 18
},
"ITEM_BURN_HEAL": {
"label": "Burn Heal",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 19
},
"ITEM_ICE_HEAL": {
"label": "Ice Heal",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 20
},
"ITEM_AWAKENING": {
"label": "Awakening",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 21
},
"ITEM_PARALYZE_HEAL": {
"label": "Paralyze Heal",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 22
},
"ITEM_FULL_RESTORE": {
"label": "Full Restore",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 23
},
"ITEM_MAX_POTION": {
"label": "Max Potion",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 24
},
"ITEM_HYPER_POTION": {
"label": "Hyper Potion",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 25
},
"ITEM_SUPER_POTION": {
"label": "Super Potion",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 26
},
"ITEM_FULL_HEAL": {
"label": "Full Heal",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 27
},
"ITEM_REVIVE": {
"label": "Revive",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 28
},
"ITEM_MAX_REVIVE": {
"label": "Max Revive",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 29
},
"ITEM_FRESH_WATER": {
"label": "Fresh Water",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 30
},
"ITEM_SODA_POP": {
"label": "Soda Pop",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 31
},
"ITEM_LEMONADE": {
"label": "Lemonade",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 32
},
"ITEM_MOOMOO_MILK": {
"label": "Moomoo Milk",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 33
},
"ITEM_ENERGY_POWDER": {
"label": "Energy Powder",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 34
},
"ITEM_ENERGY_ROOT": {
"label": "Energy Root",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 35
},
"ITEM_HEAL_POWDER": {
"label": "Heal Powder",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 36
},
"ITEM_REVIVAL_HERB": {
"label": "Revival Herb",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 37
},
"ITEM_ETHER": {
"label": "Ether",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 38
},
"ITEM_MAX_ETHER": {
"label": "Max Ether",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 39
},
"ITEM_ELIXIR": {
"label": "Elixir",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 40
},
"ITEM_MAX_ELIXIR": {
"label": "Max Elixir",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 41
},
"ITEM_LAVA_COOKIE": {
"label": "Lava Cookie",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 42
},
"ITEM_BERRY_JUICE": {
"label": "Berry Juice",
"classification": "FILLER",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 43
},
"ITEM_SACRED_ASH": {
"label": "Sacred Ash",
"classification": "USEFUL",
"tags": ["Heal"],
"tags": ["Healing"],
"modern_id": 44
},
@@ -736,19 +736,19 @@
},
"ITEM_BLACK_FLUTE": {
"label": "Black Flute",
"classification": "FILLER",
"classification": "USEFUL",
"tags": ["Misc"],
"modern_id": 68
},
"ITEM_WHITE_FLUTE": {
"label": "White Flute",
"classification": "FILLER",
"classification": "USEFUL",
"tags": ["Misc"],
"modern_id": 69
},
"ITEM_HEART_SCALE": {
"label": "Heart Scale",
"classification": "FILLER",
"classification": "USEFUL",
"tags": ["Misc"],
"modern_id": 93
},
@@ -757,37 +757,37 @@
"ITEM_SUN_STONE": {
"label": "Sun Stone",
"classification": "USEFUL",
"tags": ["EvoStone"],
"tags": ["Evolution Stone"],
"modern_id": 80
},
"ITEM_MOON_STONE": {
"label": "Moon Stone",
"classification": "USEFUL",
"tags": ["EvoStone"],
"tags": ["Evolution Stone"],
"modern_id": 81
},
"ITEM_FIRE_STONE": {
"label": "Fire Stone",
"classification": "USEFUL",
"tags": ["EvoStone"],
"tags": ["Evolution Stone"],
"modern_id": 82
},
"ITEM_THUNDER_STONE": {
"label": "Thunder Stone",
"classification": "USEFUL",
"tags": ["EvoStone"],
"tags": ["Evolution Stone"],
"modern_id": 83
},
"ITEM_WATER_STONE": {
"label": "Water Stone",
"classification": "USEFUL",
"tags": ["EvoStone"],
"tags": ["Evolution Stone"],
"modern_id": 84
},
"ITEM_LEAF_STONE": {
"label": "Leaf Stone",
"classification": "USEFUL",
"tags": ["EvoStone"],
"tags": ["Evolution Stone"],
"modern_id": 85
},
@@ -1215,7 +1215,7 @@
"ITEM_KINGS_ROCK": {
"label": "King's Rock",
"classification": "USEFUL",
"tags": ["Held"],
"tags": ["Held", "Evolution Stone"],
"modern_id": 221
},
"ITEM_SILVER_POWDER": {
@@ -1245,13 +1245,13 @@
"ITEM_DEEP_SEA_TOOTH": {
"label": "Deep Sea Tooth",
"classification": "USEFUL",
"tags": ["Held"],
"tags": ["Held", "Evolution Stone"],
"modern_id": 226
},
"ITEM_DEEP_SEA_SCALE": {
"label": "Deep Sea Scale",
"classification": "USEFUL",
"tags": ["Held"],
"tags": ["Held", "Evolution Stone"],
"modern_id": 227
},
"ITEM_SMOKE_BALL": {
@@ -1287,7 +1287,7 @@
"ITEM_METAL_COAT": {
"label": "Metal Coat",
"classification": "USEFUL",
"tags": ["Held"],
"tags": ["Held", "Evolution Stone"],
"modern_id": 233
},
"ITEM_LEFTOVERS": {
@@ -1299,7 +1299,7 @@
"ITEM_DRAGON_SCALE": {
"label": "Dragon Scale",
"classification": "USEFUL",
"tags": ["Held"],
"tags": ["Held", "Evolution Stone"],
"modern_id": 235
},
"ITEM_LIGHT_BALL": {
@@ -1401,7 +1401,7 @@
"ITEM_UP_GRADE": {
"label": "Up-Grade",
"classification": "USEFUL",
"tags": ["Held"],
"tags": ["Held", "Evolution Stone"],
"modern_id": 252
},
"ITEM_SHELL_BELL": {

File diff suppressed because it is too large Load Diff

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