Compare commits

...

274 Commits

Author SHA1 Message Date
Fabian Dill
bede173277 Merge branch 'main' into multiserver_discord_webhook 2025-05-14 20:00:32 +02:00
Scipio Wright
2a0d0b4224 Noita: Modernization Refactor (#4980) 2025-05-14 07:55:45 -04:00
Nicholas Saylor
02fd75c018 Core: Update Some Outdated Typing (#4986) 2025-05-14 07:40:38 -04:00
agilbert1412
a87fec0cbd SDV: Add Missing Marriage Requirement for Spouse Stardrop (#4988) 2025-05-14 07:27:15 -04:00
Natalie Weizenbaum
11842d396a DS3: Fix the Name of "Red and White Round Shield" (#4994)
This item name is unusual in that it loses the word "round" when it's
infused, *and* the only guaranteed drop in the base game is the infused
"Blessed Red and White Round Shield +1". But since we're just listing
the uninfused version, we should use the uninfused name.
2025-05-14 07:23:12 -04:00
Ixrec
72854cde44 Docs: Add a "Missable Locations" Question to apworld FAQ (#4965)
* Docs: add a "missable locations" question to apworld_dev_faq.md

Basically turning the conversation at https://discord.com/channels/731205301247803413/1214608557077700720/1368996789260128388 into a FAQ entry.

* feedback

* qwint feedback

* Update docs/apworld_dev_faq.md

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

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-14 07:21:40 -04:00
Duck
b71c8005e7 AHiT: Fix Client Argument Handling (#4992) 2025-05-14 07:18:36 -04:00
Ixrec
0994afa25b Tests: actually run tests in __init__.py files (#4969)
* demonstrate our pytest/CI configuration missing a __init__ test failure

* tell pytest/CI to run tests in __init__.py files

* revert the demonstration test failure

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-05-13 09:59:41 +02:00
Jérémie Bolduc
7d5693e0fb Stardew Valley: Move BaseTest out of __init__.py to comply with future conventions (#4991)
* move everything out of init; fix from imports and some typing errors

* why is there a change in multiserver

* fix some relative shits
2025-05-13 09:58:03 +02:00
black-sliver
feaed7ea00 Docs: tests: add naming / file naming conventions (#4982)
* Docs: tests: add naming / file naming conventions

Deprecates putting stuff into `__init__.py`.
This may be relevant for test discovery in the future.

* Docs: tests: fix class naming

* Docs: tests: update examples

* Punctuation is hard

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

* Revert part of one suggestion

The first set of () make the sentence make less sense.

* Docs: tests: clarify that __init__.py may be empty

* Make sentence nicer to read

I simply kept the original wording, but I agree that it reads somewhat odd

Co-authored-by: Ixrec <ericrhitchcock@gmail.com>

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: Ixrec <ericrhitchcock@gmail.com>
2025-05-13 09:49:43 +02:00
Justus Lind
8340371f9c Muse Dash: Update to Otaku Pack Vol 20 (#4924)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-12 18:47:19 -04:00
Emerassi
824caaffd0 Docs: clarify that ModuleUpdate.py is a prerequisite for running tests (#4970)
* Update tests.md

Spelled out that tests will not run without running UpdateModule.py first and including a link to the instructions on how to do that.

* Applied black-silver's feedback and also I ran into tests that don't run correctly unless you also have run Webhost.py once.  I have included that in the documentation as well.

* More black-silver feedback.
2025-05-11 12:41:35 +02:00
lordlou
c0b3fa9ff7 SMZ3: replace copyright credits music (#4978) 2025-05-11 08:10:51 +02:00
Aaron Wagener
e809b9328b The Messenger: do all empty state validation during portal shuffle (#4971) 2025-05-11 00:57:16 +02:00
qwint
53defd3108 MultiServer: More Guardrails for Nolocation Clients (#4470) 2025-05-10 18:51:44 -04:00
Silvris
a166dc77bc Core: Plando Items "Rewrite" (#3046) 2025-05-10 18:49:49 -04:00
Szabó Benedek Zoltán
68ed208613 DS3: "US: Homeward Bone - foot, drop overlook" (#4875) 2025-05-10 18:31:05 -04:00
agilbert1412
8f71dac417 Stardew valley: Add Trap Distribution setting (#4601)
Co-authored-by: Jouramie <16137441+Jouramie@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-10 17:57:24 -04:00
Katelyn Gigante
5f24da7e18 Core: Use the location of Utils.py rather than __main__ to determine the AP Folder (#4009) 2025-05-10 15:20:43 +02:00
NewSoupVi
4e61f1f23c Core: Institute limit of 10000 items on StartInventory (#4972)
* Institute limit on StartInventory

* Update Options.py

* Update Options.py

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

* Update Options.py

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-10 04:11:39 +02:00
Fabian Dill
cbfcaeba8b Subnautica: use less multiworld API (#4977) 2025-05-10 00:05:18 +02:00
palex00
9a8abeac28 Add blurb about patch files to the host page (#4974) 2025-05-09 14:27:43 +00:00
digiholic
b0f42466f0 MMBN3: Adds Beach Access to Help With Rehab Job Bonus Reward Check (#4963) 2025-05-08 13:31:00 -04:00
kbranch
bcd7d62d0b LADX: Improve Fake Tracker Items (#4897) 2025-05-07 14:53:58 -04:00
digiholic
703f5a22fd OSRS: New Tasks, New Options, Compatibility with new Plugin Features (#4688) 2025-05-07 13:43:03 -04:00
Benjamin S Wolf
1ee8e339af Launcher: Warn if there is no File Browser (#4275) 2025-05-07 12:51:26 -04:00
Ixrec
dffde64079 Docs: add a "soft logic" question to apworld_dev_faq.md (#4953)
* add a "soft logic" question to apworld_dev_faq.md

* Update apworld_dev_faq.md

* Update docs/apworld_dev_faq.md

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

* Update docs/apworld_dev_faq.md

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

* add a reminder about progression and how it influences soft logic implementations

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-07 12:20:21 -04:00
Scipio Wright
17bc184e28 TUNIC: Add Hidden all_random Option (#4635) 2025-05-07 10:59:16 -04:00
qwint
0ba9ee0695 Docs: update line length in apworld faq doc (#4960) 2025-05-07 10:47:14 -04:00
Scipio Wright
c40214e20f Docs: Minor Changes to apworld_dev_faq.md (#4947)
Co-authored-by: qwint <qwint.42@gmail.com>
2025-05-07 10:41:37 -04:00
Scipio Wright
a3aac3d737 TUNIC: Entrance rando Direction Pairs + Decoupled (#3761)
* Fix merge conflict

* Fix formatting, fix rule for heir access after merge

* Writing combat logic helpers

* More helpers!

* More logic!

* Rename has_stick to has_melee, some fixes per Medic's review

* Clamp max power from sword upgrades

* Wrote the rest of the helpers

* Remove unused import

* Apply item classifications

* Create the combat logic option

* Item classification varies based on option

* Add the shop sword logic stuff in

* Add the rules for the boss-only option

* Fix tiny issues

* Some early Overworld combat logic

* Fill out swamp combat logic

* Add note

* Bump up Boss Scav and Heir

* More revisions to combat logic

* Some changes, currently broken

* New system for power, kinda jank probably

* Revisions to new system, needs more balancing

* Cap attack upgrades

* Uncap mp power since it's directly related to damage output

* Voidlings

* Put together a table showing the vanilla-expected stats for each area

* Added some info on potion counts

* Made new helper functions

* Make has_required_stats

* Make has_combat_reqs

* Update er_rules for new combat reqs

* Fix all the broken things ever

* Remove outdated todo

* Make temp option for testing logic

* More flexible choices for combat items

* Hard require sword for bosses

* Temporarily default combat logic to on

* Finish writing overworld combat logic

* East Forest combat logic done

* Remove a few easy ones

* Finish beneath the well

* Dark Tomb combat logic

* West Garden combat logic

* make unit tests checkmark again

* Weird west garden dagger house edge case

* Try block for that weird west garden edge case

* Add quarry combat logic

* Update to filter out unreachable regions outside of ER

* Fortress Grave Path logic, and a couple fixes to the west garden logic

* Fortress east shortcut logic, and rewriting the try except blocks to use finally

* Refactor to use a new function cause wow there was a lot of repeated code

* Add combat logic to the other two sets of fortress fuses

* Add combat rules to beneath the vault

* Fix missing cathedral -> elevator connection

* Combat logic for cathedral to elevator

* Add cathedral main region, rename cathedral -> cathedral entry

* Setup cathedral combat logic

* Adjust locations' regions for ER

* Add laurels zip logic to the chest in the spike room in cathedral

* Add combat logic to frog's domain

* Move frog's domain locations to regions for combat logic

* Add new frog's domain regions for combat logic

* Update region name for frog's domain

* Fix typo

* Add more regions for lower zig

* Move around lower zig regions for combat logic

* Lower Zig combat logic

* Upper zig combat logic

* Fix typo

* Fix typos

* Fix missing world.

* Update combat logic description

* Add todo

* Add todo

* Don't make zig skip if er or fixed shop is off

* Make it so zig skip is only made with fewer shops and er

* Temporarily default combat logic on

* Update test to explicitly disable combat logic

* Update test_access.py

* Slight wording changes

* Fix bugs, refactor quarry regions so you can access chests in lower quarry with ice grapples

* Run through checks you can do with magic dagger

* Run through checks you can do with magic dagger

* Add rule for entering town portal of having equipment to deal with enemies

* Add rule for atoll near the 6 crabs surrounding a poor defenseless baby slorm

* Update the rule for the chest near the 6 crabs surrounding a slorm to also possibly require laurels

* Revamp combat logic function to work properly without melee

* Add laurels rules to combat logic chests

* Modify beneath the vault bridge rule to need a lantern if combat logic is on

* Put in money logic

* Dagger or combat for swamp big skeleton chest

* Remove the 100 moneys from logic

* Modify lower zig ls drop region destinations

* Remove completed todo

* Reword combat logic option description, remove test option

* Add combat logic to slot data

* Merge Silent's missing slot data bugfix PR #3628

* Remove test combat option

* Update combat logic description

* Fix secret gathering place issue

* Fix secret gathering place issue

* Fix lower zig ls rule

* Fix accidentally removed librarian rule

* Remove redundant rule

* Update gauntlet rule to hard-require a sword

* Add test for a problematic connection

* Adjust combat logic to deal with weird edge cases so it doesn't take stuff out of logic that was previously in logic

* Fix create_item classification

* Update some comments

* Update per exempt's suggestion

* Add combat logic to the well boss fight, reorder the combat logic stuff a little to better section them off

* Add EntranceLayout option

* Add back LogicRules as an invisible option, to not break old yamls

* Fix a bug with seed group, continue changing fixed shop to entrance layout

* Fix missed fixed shop -> entrance layout spot

* Fix bug in seed groups with fixed shop on and off

* Add entrance layout to the UT regen stuff

* Put direction. in, will add them later

* Remove unused elevation from portal class

* Got like half of them in

* Finish adding all of the directions

* Add combat rule for zig front to back

* Update per Medic's suggestion

* Update ladder storage without items option description

* Mess with state with collect and remove to save like 2 seconds (never again)

* Save even more time, still never going to do this again on anything else

* Add option check for collect and remove

* Add directions to shop portals

* Update direction in Portal with default

* Move Direction above Portal

* Add decoupled option, mess with plando connection stuff

* Merge, implement verify plando directions

* Condense the stuff in change and remove to less lines (thanks medic)

* Remove unused thing

* Swap to using logicmixin instead of prog_items (thanks Vi)

* Fix consistency in stat counters

* Add back something that was needed

* Fix mistake when adding back

* Making the fix better (thanks medic)

* Make it actually return false if it gets to the backup lists and fails them

* Fix stuff after merge

* Add outlet regions, create new regions as needed for them

* Put together part of decoupled and direction pairs

* make direction pairs work

* Make decoupled work

* Make fixed shop work again

* Fix a few minor bugs

* Fix a few minor bugs

* Fix plando

* god i love programming

* Reorder portal list

* Update portal sorter for variable shops

* Add missing parameter

* Some cleanup of prints and functions

* Fix typo

* it's aliiiiiive

* Make seed groups not sync decoupled

* Add test with full-shop plando

* Fix bug with vanilla portals

* Handle plando connections and direction pair errors

* Update plando checking for decoupled

* Fix typo

* Fix exception text to be shorter

* Add some more comments

* Add todo note

* Remove unused safety thing

* Remove extra plando connections definition in options

* Make seed groups in decoupled with overlapping but not fully overlapped plando connections interact nicely without messing with what the entrances look like in the spoiler log

* Fix weird edge case that is technically user error

* Add note to fixed shop

* Fix parsing shop names in UT

* Remove debug print

* Actually make UT work

* multiworld. to world.

* Fix typo from merge

* Make it so the shops show up in the entrance hints

* Fix bug in ladder storage rules

* Remove blank line

* # Conflicts:
#	worlds/tunic/__init__.py
#	worlds/tunic/er_data.py
#	worlds/tunic/er_rules.py
#	worlds/tunic/er_scripts.py
#	worlds/tunic/rules.py
#	worlds/tunic/test/test_access.py

* Fix issues after merge

* Update plando connections stuff in docs

* Fix library mistake

* has_stick -> has_melee

* has_stick -> has_melee

* Add a failsafe for direction pairing

* Fix playthrough crash bug

* Remove init from logicmixin

* Updates per code review (thanks hesto)

* has_stick to has_melee in newer update

* has_stick to has_melee in newer update

* # Conflicts:
#	worlds/tunic/__init__.py
#	worlds/tunic/combat_logic.py
#	worlds/tunic/er_data.py
#	worlds/tunic/er_rules.py
#	worlds/tunic/er_scripts.py

* Cleanup more stuff after merge

* Revert "Cleanup more stuff after merge"

This reverts commit a6ee9a93da.

* Revert "# Conflicts:"

This reverts commit c74ccd74a4.

* Cleanup more stuff after merge

* Swap to .get for decoupled so it works with older games probably maybe

* Fix after merge

* Fix typo

* Fix UT support with fixed shop option

* Backport plando connections fix

* Fix issue with fixed shop + decoupled

* Make the error not duplicate the while loop condition

* Fix rule for quarry back to monastery

* Fix more stuff after merge

* Make it not output anything if you set plando connections but not ER

* Add obvious note to plando connections description

* Fix after merge

* add comment to commented out connection

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-06 12:33:21 -04:00
Seldom
7bbe62019a Terraria: Fix inaccessible Leading Landlord achievement when getfixedboi is enabled #4958 2025-05-06 18:32:55 +02:00
Aaron Wagener
b898b9d9e6 The Messenger: fix indentation in setup guide (#4959)
* The Messenger: fix indentation in setup guide

* just delete the save backup section tbh
2025-05-06 18:32:30 +02:00
Exempt-Medic
b217372fea Core: Make Perfect Fuzzy Match Prioritize Casing (#4956) 2025-05-05 19:18:20 -04:00
Jérémie Bolduc
b2d2c8e596 Stardew Valley: Add void mayo requirement for Goblin Problem quest (#4933)
This adds the requirement of a void mayo for the Goblin Problem quest. There are also some small adjustments to related rules
- Fishing a void mayo is only considered an option during the Goblin Problem quest, as the odds of finding one after the quest drops drastically.
- Entrance to the witch hut now requires the goblin problem quest, not just a void mayo.
- Fishing rules are all moved to `fishing_logic.py`.
- `can_fish_at` no longer check that you have any of the fishing regions and the region you actually want to fish in.
- created `can_fish_anywhere` and `can_crab_pot_anywhere` to better illustrate when any fish satisfies the rule.
2025-05-04 16:28:38 +02:00
Fabian Dill
68e37b8f9a Factorio: client cleanup and prevent process bomb (#4882)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-05-04 16:22:48 +02:00
Fabian Dill
fa2d7797f4 Core: update certifi (#4954) 2025-05-04 15:59:41 +02:00
Jonathan Tan
1885dab066 TWW: Documentation Cleanup (#4942) 2025-05-03 20:06:16 -04:00
Tim Mahan
9425f5b772 Docs: Direct Mac users to Launcher.py (#4767) 2025-05-03 08:42:52 -04:00
Fabian Dill
83ed3c8b50 Core: always embed Archipelago (#4880) 2025-05-03 11:53:52 +02:00
qwint
f4690e296d CommonClient: remove Datapackage Version handling (#4487)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-05-03 01:31:40 +02:00
Fabian Dill
68c350b4c0 CommonClient: rip out old global name lookup (#4941) 2025-05-02 23:39:52 +02:00
Fabian Dill
da0207f5cb Factorio: implement custom filler items (#4945) 2025-05-02 23:39:14 +02:00
Aaron Wagener
2455f1158f Options: Cleanup CommonOptions.as_dict (#4921)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-02 12:39:58 -04:00
Fabian Dill
1031fc4923 Factorio: remove FactorioClient executable (#4928) 2025-05-02 15:59:27 +02:00
qwint
6beaacb905 Generate: Better yaml parsing error messaging (#4927)
Co-authored-by: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com>
2025-05-02 09:46:34 -04:00
Scipio Wright
c46ee7c420 TUNIC: Lock pre-placed filler to make the game play nicer with prog balancing (#4917) 2025-04-30 21:57:46 +02:00
Bryce Wilson
227f0bce3d Pokemon Red/Blue: Convert to Procedure Patch (#4801) 2025-04-30 16:31:33 +02:00
PoryGone
611e1c2b19 SMW: v2.1 Feature Update (#4652)
### Features:
- Trap Link
  - When you receive a trap, you send a copy of it to every other player with Trap Link enabled
- Ring Link
    - Any coin amounts gained and lost by a linked player will be instantly shared with all other active linked players

Co-authored-by: TheLX5 <luisyuregi@gmail.com>
2025-04-30 16:24:10 +02:00
Mysteryem
5f974b7457 SM: Fix FakeROM instances sharing the same data dictionary (#4912)
FakeROM instances were being created with default arguments, which
included a mutable default argument data dictionary, so all FakeROM
instances would be writing to and reading the same dictionary, resulting
in broken patch data in multiworlds with more than one Super Metroid
world.
2025-04-30 04:57:35 +02:00
threeandthreee
3ef35105c8 LADX: Remove copyrighted assets (#4935) 2025-04-30 04:27:54 +02:00
Alchav
ec768a2e89 ALTTP: Swamp Palace West logic fix (#4936) 2025-04-29 16:53:31 +02:00
black-sliver
b580d3c25a CI: add optional windows release build and build attestation (#4940)
* CI: github attestation for manually started builds

* CI: include appimage zsync in build attestation

* CI: github attestation for Linux release builds

* CI: reorder steps in build.yml

* CI: add windows builds to release.yml

* CI: order jobs in release.yml

* CI: add missing permission to release.yml

* CI: enable windows build in release.yml

* CI: false is skip
2025-04-29 08:32:36 +02:00
Jérémie Bolduc
ce14f190fb Stardew Valley: Replace event creation stardew code with add_event (#4922)
* replace event creation stardew code with add_event

* delete unnecessary default args
2025-04-29 00:12:52 +02:00
Jonathan Tan
4e3da005d4 TWW: Fix generation failure with output file (#4932) 2025-04-27 09:43:24 +02:00
Exempt-Medic
0d9967e8d8 OC2: Account for Multiclass Items in Progression Balancing (#4929) 2025-04-26 13:28:07 -04:00
KonoTyran
2624a0a7ea Remove Slay the Spire (#4673)
* Remove Slay the Spire

* remove slay the spire
2025-04-25 20:54:53 +02:00
Nicholas Brochu
8755d5cbc0 Remove Game: Zork Grand Inquisitor (#4884)
* remove zork grand inquisitor

* add apworld to inno setup installdelete
2025-04-25 01:42:42 +02:00
Jérémie Bolduc
abb6d7fbdb Stardew Valley: Replace all add_rule by set_rule #4909 2025-04-24 23:36:25 +02:00
Star Rauchenberger
fc04192c99 Lingo: Use OptionCounter for trap_weights (#4920) 2025-04-24 23:14:42 +02:00
Fabian Dill
d4110d3b2a LttP: make progression health optional (#4918) 2025-04-24 23:10:58 +02:00
NewSoupVi
05c1751d29 Core: Add "OptionCounter", use it for generic "StartInventory" and Witness "TrapWeights" (#3756)
* CounterOption

* bring back the negative exception for ItemDict

* Backwards compatibility

* ruff on witness

* fix in calls

* move the contains

* comment

* comment

* Add option min and max values for CounterOption

* Use min 0 for TrapWeights

* This is safe now

* ruff

* This fits on one line again now

* OptionCounter

* Update Options.py

* Couple more typing things

* Update Options.py

* Make StartInventory work again, also make LocationCounter theoretically work

* Docs

* more forceful wording

* forced line break

* Fix unit test (that wasn't breaking?)

* Add trapweights to witness option presets to 'prove' that the unit test passes

* Make it so you can order stuff

* Update macros.html
2025-04-24 22:06:41 +02:00
NewSoupVi
6ad042b349 Core: Add Region.add_event (#2965)
* region.add_event function

* Make it return the location bc why not

* Actually item bc that seems more useful

* Update BaseClasses.py

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

* Update BaseClasses.py

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

* add all the requested features from code review

* oop

* roughly sort args in order of importance (imo)

* Fix typing

---------

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
2025-04-24 21:56:52 +02:00
NewSoupVi
e52d8b4dbd The Witness: Remove first-stage requirements of progressive items from the logic files (#4257)
* Remove extraneous symbol requirements

* Some missed Full Dots cases

* Bruh

* merge error

* merge error 2
2025-04-24 21:56:05 +02:00
NewSoupVi
f288e3469c Core: Add a function docstring to roll_settings to hopefully prevent the weights fiasco from being repeated (#3388)
* Add an option docstring to roll_settings to hopefully prevent the weights fiasco from being repeated

* Update Generate.py

* Update Generate.py
2025-04-24 21:55:48 +02:00
Jarno
5bb87c6da5 Tests: Make overlapping test actually print out the overlaps (#4431) 2025-04-24 15:33:30 -04:00
Aaron Wagener
03768a5f90 Tests: Test that a world can generate with item links (#2081)
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-04-24 15:23:51 -04:00
Scipio Wright
a84366368f Docs: Update comment for create_item (#4919) 2025-04-24 09:38:30 -04:00
Fabian Dill
29e6a10e42 Setup: offer the default-on option to clean /lib folder on update (#4890)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-04-24 08:50:34 +02:00
Fabian Dill
febd280fba Setup: use sha256 for timestamp server (#4892) 2025-04-23 20:30:15 +02:00
black-sliver
73964b374c MultiServer: import get_settings from the correct module (#4914)
* MultiServer: import get_settings from the correct module

* MultiServer: settings: use attr inbstead of dict access
2025-04-23 15:40:36 +00:00
Jérémie Bolduc
bad6a4b211 Stardew Valley: remove BaseLogic generic so importing mixins is no longer needed (#4916)
* remove BaseLogic generic so importing mixins is no longer needed

* self review
2025-04-23 17:31:08 +02:00
Scipio Wright
57d3c52df9 TUNIC: More varied reserved locations for local_fill option (#4653)
* Make reserved locations more varied

* Use CollectionState(self.multiworld) instead of whatever it used to be
2025-04-21 23:41:20 +02:00
Star Rauchenberger
d309de2557 Lingo: Rework Early Good Items (#4910) 2025-04-21 16:06:24 -04:00
Scipio Wright
d5d56ede8b TUNIC: Remove Outdated Plando Code (#4908) 2025-04-21 15:20:22 -04:00
Fabian Dill
6613c29652 Core: print both world source paths in case of conflict (#4751) 2025-04-21 00:53:40 +02:00
NewSoupVi
1a6de25ab6 Core, all worlds: Hard-deprecate old options API (by August 10th 2024) (#3284)
* Core: deprecate old options API

* also deprecate assigning options via option_definitions

---------

Co-authored-by: alwaysintreble <mmmcheese158@gmail.com>
2025-04-21 00:43:31 +02:00
NewSoupVi
b62c1364a9 MultiServer.py: Another Hint Priority + Item Links bug oh boy (#4874)
Basically, hints for itemlink worlds' locations get stored in ctx.hints under
1. the location's player
2. **every individual player** that is participating in the itemlink.

Right now, the updatehint code tries to replace and resend the hint under the itemlinked player, which doesn't work.
2025-04-21 00:43:05 +02:00
Fabian Dill
b59162737d LttP: increase gen rate of pedestal goal with limited rupee pool (#4905)
* LttP: increase gen rate of pedestal goal with limited rupee pool

* improve chance further if retro bow is involved
2025-04-20 23:04:40 +02:00
Jérémie Bolduc
543dcb27d8 Stardew Valley: Exclude maximum one resource packs from pool when in start inventory (#4839)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-04-20 10:51:03 -04:00
Jérémie Bolduc
22941168cd Stardew Valley: Refactor Animals to use Content Packs (#4320) 2025-04-20 10:17:22 -04:00
Scipio Wright
33dc845de8 TUNIC: Fix UT Issue with Fewer Shops Option (#4873) 2025-04-20 09:48:09 -04:00
LiquidCat64
be0f23beb3 CV64: Some DeathLink Adjustments (#4727) 2025-04-20 09:46:57 -04:00
Silvris
b76f2163a4 MM2: Fix invalid weakness failsafe and refactor weakness tests (#4899) 2025-04-20 09:08:30 -04:00
Omnises Nihilis
04aa471526 KH2: Update Docs (#4871) 2025-04-20 08:43:52 -04:00
Trevor L
b756a67c2a BRC: Update Setup Guide (#4861)
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-04-20 08:31:58 -04:00
Jérémie Bolduc
a76ee010eb Stardew Valley: Make Bus and Boat Require Money (#4833) 2025-04-20 08:21:02 -04:00
shananas
eb1fef1f92 KH2: Update Docs (#4869) 2025-04-20 08:20:23 -04:00
Doug Hoskisson
e498cc7d48 Tests: Don't use type as Callable (#4866) 2025-04-20 07:21:40 -04:00
Doug Hoskisson
a26abe079e Zillion: Some Code Cleaning (#4780) 2025-04-20 07:07:17 -04:00
qwint
199b6bdabb Launcher: Update header docstring (#4777) 2025-04-20 07:04:56 -04:00
SunCat
e4bc7bd1cd Checksfinder: Fix the last remnant of outdated game description (#4893)
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-04-20 00:16:46 -04:00
Silvris
20651df307 kvui: fix kwargs on ResizableTextField and ImageButton (#4903) 2025-04-20 01:21:11 +02:00
massimilianodelliubaldini
f857933748 Launcher: Add search box (#4863)
* Add fuzzy search box to Launcher.

* move func bind to the kv and prefer substring matching (#79)

* move the func bind to the kv

* prefer substr matching

* Remove fuzzy results, rely on substring only.

* Use early return instead of else.

* Add type hint to filter_clients_by_type.

* Activate search on keyboard input.

* Clear search box when filtering by type.

* Update Launcher.py

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

---------

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2025-04-19 23:27:03 +02:00
Jérémie Bolduc
efe2b7c539 Core: Support default value with cache_self1 (#4667)
* add cache_self1_default and tests

* merge the two decorators

* just change the defaults of the wrap lol

* add test for default and default
2025-04-19 17:55:02 +02:00
Fabian Dill
e090153d93 LttP: fix generation if other games are involved (#4901) 2025-04-19 15:44:55 +02:00
Silvris
5088b02bfe Unittests: fix world unittests with unittest module (#4895) 2025-04-19 15:42:20 +02:00
Nicholas Saylor
57a716b57a LTTP: Update to options API (#4134)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-04-18 23:41:38 +02:00
Aaron Wagener
1b51714f3b LTTP: Rip Lttp specific entrance code out of core and use Region helpers (#1960) 2025-04-18 23:34:34 +02:00
ScootyPuffJr1
cb3d35faf9 LttP: Add keydrop locations to location groups (#4465) 2025-04-18 20:50:51 +02:00
Fabian Dill
a0c83b4854 Core: no longer log ID ranges on generate (#4013)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-04-18 20:49:08 +02:00
Fabian Dill
1b3ee0e94f Core: require clients to support overlapping IDs (#4451) 2025-04-18 20:41:09 +02:00
Mysteryem
552a6e7f1c Stardew Valley: Precollect building items in deterministic order (#4883)
#4239 refactored buildings, but introduced iteration of a set when precollecting the building items into start inventory.

The iteration order of sets varies between separate Python processes due to set order being partially based on the hashes of the objects in the set and because Python processes each have a random hash seed by default.
2025-04-18 18:41:46 +02:00
qwint
38bfb1087b Webhost: fix get_seeds api endpoint (#4889) 2025-04-18 18:15:59 +02:00
qwint
2dc55873f0 Webhost: add link to new session page (#4857)
Co-authored-by: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com>
2025-04-18 04:57:41 +02:00
qwint
4b1898bfaf HK: fix docs whitespace (#4885) 2025-04-18 00:57:17 +02:00
Silvris
125bf6f270 Core: Post-KivyMD cleanup 2 and enhancements (#4876)
* Adds a new class allowing TextFields to be resized
* Resizes most CommonClient components to be more in-line with pre-KivyMD
* Change the color of SelectableLabels and TooltipLabels to white
* Fixed ClientTabs not correctly showing the current tab indicator
* The server label now features a (i) icon to indicate that it can be hovered over.
* Changed the default `primary_palette` to `Lightsteelblue` and the default `dynamic_scheme_name` to `VIBRANT`
* Properly set attributes on `KivyJSONToTextParser.TextColors` so that proper typing can be utilized if an individual value is needed
* Fixed some buttons being discolored permanently once pressed
* Sped up the animations of button ripples and tab switching
* Added the ability to insert a new tab to `GameManager.add_client_tab`
* Hovering over the "Command" button in CommonClient will now display the contents of `/help` as a popup (note: this popup can be too large on default height for adequately large /help (SC2 Client), but should always fit fine on fullscreen).
* Fixed invalid sizing of MessageBox errors, and changed their text color to white
2025-04-16 00:09:27 +02:00
Seldom
1873c52aa6 Terraria: 1.4.4 and Calamity support (#3847)
* Terraria integration

* Precollected items for debugging

* Fix item classification

* Golem requires Plantera's Bulb

* Pumpkin Moon requires Dungeon

* Progressive Dungeon

* Reorg, Options.py work

* Items are boss flags

* Removed unused option

* Removed nothing

* Wall, Plantera, and Zenith goals

* Achievements and items

* Fixed The Cavalry and Completely Awesome achievements

* Made "Dead Men Tell No Tales" a grindy achievement

* Some docs, Python 3.8 compat

* docs

* Fix extra item and "Head in the Clouds" being included when achievements are disabled

* Requested changes

* Fix potential thread unsafety, replace Nothing with 50 Silver

* Remove a log

* Corrected heading

* Added incompatible mods list

* In-progress calamity integration

* Terraria events progress

* Rules use events

* Removed an intentional crash I accidentally left in

* Fixed infinite loop

* Moved rules to data file

* Moved item rewards to data file

* Generating from data file

* Fixed broken Mech Boss goal

* Changes Calamity makes to vanilla rules, Calamity final bosses goal

* Added Deerclops, fixed Zenith goal

* Final detailed vanilla pass

* Disable calamity goals

* Typo

* Fixed some reward items not adding to item pool

* In-progress unit test fixes

* Unit test fixes

* `.apworld` compat

* Organized rewards file, made Frog Leg and Fllpper available in vanilla

* Water Walking Boots and Titan Glove rewards

* Add goals to slot data

* Fixed Hammush logic in Post-Mech goal

* Fixed coin rewards

* Updated Terraria docs

* Formatted

* Deathlink in-progress

* Boots of the Hero is grindy

* Fixed zenith goal not placing an item

* Address review

* Gelatin World Tour is grindy

* Difficulty notice

* Switched some achievements' grindiness

* Added "Hey! Listen!" achievement

* Terarria Python 3.8 compat

* Fixed Terraria You and What Army logic

* Calamity minion accessories

* Typo

* Calamity integration

* `deathlink` -> `death_link`

Co-authored-by: Zach Parks <zach@alliware.com>

* Missing `.`

Co-authored-by: Zach Parks <zach@alliware.com>

* Incorrect type annotation

Co-authored-by: Zach Parks <zach@alliware.com>

* `deathlink` -> `death_link` 2

Co-authored-by: Zach Parks <zach@alliware.com>

* Style

Co-authored-by: Zach Parks <zach@alliware.com>

* Markdown style

Co-authored-by: Zach Parks <zach@alliware.com>

* Markdown style 2

Co-authored-by: Zach Parks <zach@alliware.com>

* Address review

* Fix bad merge

* Terraria utility mod recommendations

* Calamity minion armor logic

* ArmorMinions -> Armor Minions, boss rush goal, fixed unplaced item

* Fixed unplaced item

* Started on Terraria 1.4.4

* Crate logic

* getfixedboi, 1.4.4 achievements, shimmer, town slimes, `Rule`, `Condition`, etc

* More clam getfixedboi logic, bar decraft logic, `NotGetfixedboi` -> `Not Getfixedboi`

* Calamity fixes

* Calamity crate ore logic

* Fixed item accessibility not generating in getfixedboi, fixed not generating with incompatible options, fixed grindy function

* Early achievements, separate achievement category options

* Infinity +1 Sword achievement can be location in later goals

* The Frequent Flyer is impossible in Calamity getfixedboi

* Add Enchanted Sword and Starfury for starting inventories

* Don't Dread on Me is redundant in Calamity

* In Calamity getfixedboi, Queen Bee summons enemies who drop Plague Cell Canisters

* Can't use Gelatin Crystal outside Hallow

* You can't get the Terminus without flags

* Typo

* Options difficult warnings

* Robbing the Grave is Hardmode

* Don't reserve an ID for unused Victory item

* Plantera is accessible early in Calamity via Giant Plantera's Bulbs

* Unshuffled Life Crystal and Defender Medal items

* Comment about Midas' Blessing

* Update worlds/terraria/Options.py

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

* Remove stray expression

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

* Review suggestions

* Option naming caps consistency, add Laser Drill, Lunatic Cultist alt reqs, fix Eldritch Soul Artifact, Ceaseless Void reqs Dungeon

* Cal Clone doesn't drop Broken Hero Sword anymore, Laser Drill is weaker in Calamity

Co-authored-by: Seatori <92278897+Seatori@users.noreply.github.com>

* Fix Acid Rain logic

* Fix XB-∞ Hekate failing accessibility checks (by commenting it out bc it doesn't affect logic)

* Hardmode ores being fishable early in Calamity is not a bug anymore

* Mecha Mayhem is inaccessible in getfixedboi

* Update worlds/terraria/Rules.dsv

Co-authored-by: Seafo <92278897+Seatori@users.noreply.github.com>

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: Zach Parks <zach@alliware.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Seatori <92278897+Seatori@users.noreply.github.com>
2025-04-15 15:51:05 +02:00
black-sliver
ec1e113b4c Doc: fix parse_yaml in adding games.md (#4872) 2025-04-13 13:10:36 +02:00
agilbert1412
347efac0cd DLC Quest - Skip two long tests in the main pipeline (#4862)
* - Set up the two long tests to only run when the specific config is active

* Apply Black Sliver's suggestion
2025-04-12 02:41:08 +02:00
Jérémie Bolduc
b7b5bf58aa Stardew Valley: Use classvar_matrix to split tests (#4762)
* Unroll tests for better parallelization

* fix ut test

* self review

* bro it's the second time today I have to commit some garbage to have a github action rerun because messenger fails what is this

* my god can the tests plz pass

* code reviews

* code reviews

* move TestRandomWorlds out of long module
2025-04-12 02:19:17 +02:00
Fabian Dill
a324c97815 Factorio: fix FloatRanges writing effectively nil into the mod (#4846) 2025-04-11 20:52:20 +02:00
Natalie Weizenbaum
f263a0bc91 DS3: Mark a lizard location that was previously not annotated (#4860) 2025-04-10 21:18:49 -04:00
Mysteryem
6a9299018c MLSS: Fix generation error with emblem hunt and no digspots (#4859) 2025-04-10 21:17:28 -04:00
Jérémie Bolduc
ee471a48bd Stardew Valley: Fix some determinism issues with entrance rando when playing with mods (#4812) 2025-04-10 14:34:21 -04:00
qwint
879d7c23b7 HK: Workaround for NamedRange webhost bug (#4819) 2025-04-10 14:18:43 -04:00
massimilianodelliubaldini
934b09238e Docs: Update to adding games.md (#4816) 2025-04-10 13:21:33 -04:00
Carter Hesterman
1fd8e4435e Civ 6: Update setup documentation to account for common pitfalls (#4797) 2025-04-10 13:19:03 -04:00
Aaron Wagener
50fd42d0c2 The Messenger: Add a plando guide (#4719) 2025-04-10 13:13:38 -04:00
Aaron Wagener
399958c881 The Messenger: Add an FAQ (#4718) 2025-04-10 13:03:05 -04:00
qwint
78c93d7e39 Docs: Add FAQ section for corrupted metadata debugging (#4705)
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-04-10 13:00:48 -04:00
qwint
e3b8a60584 Webhost: Fix Sphere Tracker crashing on item links (#4855) 2025-04-10 03:29:11 +02:00
Star Rauchenberger
b7263edfd0 Lingo: Removed unnecessary "global" keywords (#4854) 2025-04-10 01:41:07 +02:00
Ziktofel
1ee749b352 SC2 Client: Fix missing mission tooltip after KivyMD switch (#4827) 2025-04-09 22:21:16 +02:00
Alchav
f93734f9e3 Pokemon Red and Blue: PC Item Fix (#4835)
* Pokemon Red and Blue PC Item fix

* Respect non_local_items for PC Item

* prefer exclude if also in priority locations

---------

Co-authored-by: alchav <alchav@jalchavware.com>
2025-04-09 13:20:56 -04:00
Fabian Dill
e211dfa1c2 WebHost: use JS to refresh waitSeed if scripting is enabled (#4843) 2025-04-09 07:43:28 +02:00
Zach 'Phar' Parks
0f7deb1d2a WebHost: Remove styleController.js and replace functionality with HTML/CSS. (#4852)
* ensure footer stays at bottom of page without JS

* Remove some other usages.
2025-04-08 23:46:46 -05:00
black-sliver
f2cb16a5be CI: update action ubuntu build runners to 22.04 (#4847) 2025-04-09 01:38:46 +02:00
Mysteryem
98477e27aa Core: Speed up fill_restrictive item_pool pop loop (#4536)
* Core: Speed up fill_restrictive item_pool pop loop

Items from `reachable_items` are placed in last-in-first-out order, so
items being placed will be towards the end of `item_pool`, but the
iteration to find the item was iterating from the start of `item_pool`.

Now also uses `del` instead of `.pop()` for an additional, tiny,
performance increase.

It is unlikely for there to be a noticeable difference in most cases.
Only generating with many worlds with a high percentage of progression
items and fast access rules is likely to see a difference with this
change.

--skip_output generation of 400 template A Hat in Time yamls with
progression balancing disabled goes from 76s to 43s (43% reduction) for
me with this patch. This placed 43200 progression items out of 89974
items total (48% progression items).

* Fix comment typo

"be" was missing.

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-04-08 23:57:31 +02:00
threeandthreee
4149db1a01 LADX: Stop using Utils.get_options (#4818)
* init

* use get

* Update LinksAwakeningClient.py

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

* Update LinksAwakeningClient.py

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

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
2025-04-08 23:54:50 +02:00
Jérémie Bolduc
9ac921380f Stardew Valley: Refactor buildings to use content packs (#4239)
* create building data object and rename ItemSource to Source to be more generic

# Conflicts:
#	worlds/stardew_valley/content/game_content.py

# Conflicts:
#	worlds/stardew_valley/data/artisan.py
#	worlds/stardew_valley/data/game_item.py
#	worlds/stardew_valley/data/harvest.py
#	worlds/stardew_valley/data/shop.py

* remove compound sources, replace by other requirements which already handle this usecase

* add coops to content packs

* add building progression in game features

* add shippping bin to starting building; remove has_house

* replace config check with feature

* add other buildings in content packs

* not passing

* tests passes, unbelievable

* use newly create methods more

* use new assets to ease readability

* self review

* fix flake8 maybe

* properly split rule for mapping cave systems

* fix tractor garage name

* self review

* add upgrade_from to farm house buldings

* don't override building name variable in logic

* remove has_group from buildings

* mark some items easy in grinding logic so blueprints buildings can be in more early spheres

* move stuff around to maybe avoid future conflicts cuz I have like 10 PRs opened right now

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

* disable shop source for mapping cave systems

* bunch of code review changes

* add petbowl and farmhouse to autobuilding

* set min easy items to 300

* fix farm type
2025-04-08 12:37:45 -04:00
CookieCat
286e24629f AHIT: Add start_inventory_from_pool and get_filler_item_name (#4798)
* Update __init__.py

* Update Options.py
2025-04-08 12:26:30 -04:00
Emily
ab2efc0c5c kvui: actually fix [u] and [/u] appearing in copied hints (#4842) 2025-04-08 18:06:19 +02:00
NewSoupVi
60d6078e1f Wind Waker: Don't collect nonprogression #4826 2025-04-07 23:17:25 +02:00
black-sliver
f94492b2d3 CI: ignore F824 (#4790)
This is an added check in flake8 that does not really fit the goal
of the github action and currently throws a lot of errors.
2025-04-07 00:39:25 +02:00
Star Rauchenberger
f03bb61747 Lingo: Add "shuffle_postgame" flag to slot data (#4825)
This allows the tracker to see whether postgame is shuffled in the player's world, and if it's not, allows it to hide locations/paintings accordingly.
2025-04-07 00:02:34 +02:00
Silvris
dc4e8bae98 Core: post-KivyMD cleanup (#4815)
* Removed now unused imports from Launcher
* Moved ImageIcon and ImageButton to use ApAsyncImage for compatibility with apworlds
* Adjusted image size in the Launcher from 40x40 to 48x48. This is already larger than the size in previous versions, and a docs update is soon to follow.
* Expose `dynamic_scheme_contrast` to user.kv, allowing users to set high contrast.
* ScrollBox's default scroll_type was set to only content, so the scrollbar in Launcher was nonfunctional.
* Adjusted the spacing of the title of a component when a description is present to be closer to the center.
* Launcher now scrolls to the top automatically when changing between filters
2025-04-06 20:11:16 +02:00
Star Rauchenberger
ac26f8be8b Lingo: Mark some items as ProgUseful (#4822) 2025-04-06 19:44:33 +02:00
black-sliver
8c79499573 SoE: remove use of deprecated Utils.get_options() (#4821) 2025-04-06 17:00:14 +02:00
black-sliver
63fbcc5fc8 WebHost: custom proc title for Generator and MultiHoster (#4310)
* WebHost: custom proctitle for Generator and MultiHoster

* Update setproctitle to 1.3.5
2025-04-06 13:50:24 +02:00
Fabian Dill
cad217af19 Core: update cert file daily in customserver.py (#4454) 2025-04-06 05:31:14 +02:00
Exempt-Medic
a6ad4a8293 Docs: Remove false claim that rules can be done in generate_basic (#4809) 2025-04-05 13:51:22 -04:00
Silvris
503999cb32 Core: KivyMD and Launcher overhaul (#3934)
Shifts the contents of `kvui.py`, and thus all CommonClient-based clients as well as Launcher, to using KivyMD. KivyMD is an extension for Kivy that is almost fully compatible with pre-existing Kivy components, while providing Material Design support for theming and overall visual design as well as useful pre-existing built in components such as Snackbars, Tooltips, and a built-in File Manager (not currently being used).

As a part of this shift, the launcher was completely overhauled, adding the ability to filter the list of components down to each type of component, the ability to define favorite components and filter to them, and add shortcuts for launcher components to the desktop. An optional description field was added to Component for display within the new launcher.

The theme (Light/Dark) and primary palette have also been exposed to users via client/user.kv.
2025-04-05 18:46:24 +02:00
threeandthreee
c2d8f2443e LADX: more tracker support (#4355)
* init

* oops
2025-04-05 18:39:31 +02:00
Ishigh1
4571ed7e2f Core: Made want_reply follow the specs in the docs #4750 2025-04-05 18:35:00 +02:00
Exempt-Medic
ef5cbd3ba3 Adventure: Set Victory Condition Earlier (#4810) 2025-04-05 12:30:08 -04:00
Aaron Wagener
5c162bd7ce Core: add an is_event property to Item to match the one on Location (#3401) 2025-04-05 18:07:06 +02:00
NewSoupVi
7bdaaa25c1 Core: Prevent worlds from using LogicMixin incorrectly (having class variables without an init_mixin) (#3974)
* Core: Prevent people from using LogicMixin incorrectly

There's a world that ran into some issues because it defined its custom LogicMixin variables at the class level.

This caused "instance bleed" when new CollectionState objects were created.

I don't think there is ever a reason to have a non-function class variable on LogicMixin without also having `init_mixin`, so this asserts that this is the case.

Tested:
Doesn't fail any current worlds
Correctly fails the world in question

Also, not gonna call out that world because it was literally my fault for explaining it to them wrong :D

* Verbose af

* Update AutoWorld.py
2025-04-05 18:06:30 +02:00
NewSoupVi
9a5a02b654 MultiServer Extend datastore "update" operation to work on lists as well, acting as a pseudo "set union". #4666 2025-04-05 18:05:58 +02:00
Mysteryem
4fea6b6e9b Core: Remove Location.__hash__ (#4274)
`Location` does not override `__eq__` so should not override `__hash__`.

With this patch, this makes operations on sets of locations slightly
faster because they will use `object.__hash__` rather than
`Location.__hash__`.

`object.__hash__` is about 4 to 5 times faster than `Location.__hash__`
for me. Generation often uses sets of locations, so this slightly speeds
up generation.

The only place I could find that was hashing locations directly was
`WitnessLocationHint.__hash__`, but it has implemented a matching
`__eq__`, so is fine.

For security reasons, Python randomizes its hash seed each time it is
started, so the result of the `hash()` function is nondeterministic and
can't have been used by worlds for anything that needed to be
deterministic and can't have been used to compare information hashed at
generation time to information hashed by a client.
2025-04-05 17:53:59 +02:00
Mysteryem
bd8b8822ac Core: Pass maximum exploration states in distribute_items_restrictive (#4535)
The base state passed to fill_restrictive should be as maximal as
possible otherwise fill_restrictive has to repeatedly re-sweep and
collect from advancement locations that were reachable from before
fill_restrictive has placed a single item.

This is not added within fill_restrictive itself because it is common
for fills to be performed using a partial 'all_state', which is already
a maximum exploration state.

With --skip_output generation of every template yaml, except FF, KH
and Shivers, this prevented repeatedly re-sweeping 576 advancement
locations in every sweep within progression fill, reducing the
generation time from 124s to 113s for me (8.8% reduction, averaged over
5 generations each).
2025-04-05 17:50:19 +02:00
NewSoupVi
0a44c3ec49 The Witness: Move the Easter Egg Hunt option group lower so that the tooltip isn't cut off (#4789) 2025-04-05 17:48:18 +02:00
NewSoupVi
3262984386 The Witness: Option tooltip clarifications (#4807)
* Missing colon

* Clarify Panel Hunt

* Unnecessary line break

* that wasn't meant to be in here
2025-04-05 17:47:16 +02:00
LiquidCat64
180265c8f4 CVCotM: Fix DeathLinks sent by a different instance of the same slot not being received. (#4726)
* Fix same-slot-different-player DeathLinks not being received.

* A few more comments.
2025-04-05 10:27:51 -04:00
PinkSwitch
a9b4d33cd2 Yoshi's Island: Fix Piece of Luigi not goaling until reset (#4709) 2025-04-05 16:07:37 +02:00
Mysteryem
5dfb9b28f7 Core: Improve iteration speed of Region.Register objects (#4583)
Without implementing __iter__ directly, calling iter() on a
Region.Register on Python 3.12 would return a new generator implemented
as follows:
```py
        def __iter__(self) -> int:
            i = 0
            try:
                while True:
                    v = self[i]
                    yield v
                    i += 1
            except IndexError:
                return None
```
This was determined by disassembling the returned generator with
dis.dis() and then constructing a function that disassembles into the
same bytecode.

The iterator returned by `iter(self._list)` is faster than this
generator, so using it slightly improves generation performance on
average.

Iteration of Region.Register objects is used a lot in
`CollectionState.update_reachable_regions` in both of the private
_update methods that get called. The performance gain here will vary
depending on how many regions a world has and how many exits those
regions have on average.

For a game like Blasphemous, with a lot of regions and exits, generation
of 10 template Blasphemous yamls with `--skip_output --seed 1` and
progression balancing disabled went from 19.0s to 16.4s (14.2% reduction
in generation duration).
2025-04-05 15:59:39 +02:00
Benjamin S Wolf
ec75793ac3 Core: Add spoiler-only output mode (#4059)
* Core: Add spoiler-only output mode

* spoiler-only exceptions

* Move new errors to mystery_argparse
2025-04-05 09:50:52 -04:00
CodeGorilla
cd4da36863 GER: Only consider usable exits when calculating dead-ends (#4701)
* Only consider usable exits when calculating whether or not a region is a dead-end

* Update EntranceLookup unit tests

* Add new dead-end test

* Add additional explanation to the new test

* minor formatting tweak

based on review feedback

---------

Co-authored-by: CodeGorilla <3672561+Ars-Ignis@users.noreply.github.com>
2025-04-05 09:21:38 -04:00
Nocallia
1749e22569 Stardew: Fix minor grammar issues in Options (#4800) 2025-04-05 08:20:51 -04:00
axe-y
0cce88cfbc DLC Quest: Fix more items than location with non existing start inventory (#4735)
* DLC Quest Bug Fix
Start inventory item that do not exist in the present world do not make more trap item to appear anymore

* Update worlds/dlcquest/Items.py

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

* DLC Quest Bug Fix
did the recommendation of Mysteryem and made the item not exist in the pool of item created

* DLC Quest Bug Fix
did the recommendation of agilbert1412 and made a check by name instead of item to itemData

* DLC Quest Bug Fix
overcook failed test

* DLC Quest Bug Fix
re-type correctly a type hint

---------

Co-authored-by: Mysteryem <Mysteryem@users.noreply.github.com>
2025-04-05 08:19:54 -04:00
black-sliver
61e83a300b Clients: stop updating datapackage in persistent_storage (#4799)
Still uses things that are in there but stops writing to it.
2025-04-05 11:51:01 +02:00
Exempt-Medic
136a13aac7 Docs: Include that DeathLink cause can be an empty string (#4729) 2025-04-04 22:39:18 -04:00
massimilianodelliubaldini
2c90db9ae7 Docs: Additional detail and organization to adding games.md (#4805)
* Additional detail and organization to adding games.md

* Minor fixes.

* Update docs/adding games.md

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

* Code review updates.

* More updates.

* Client icon blurb.

* Update docs/adding games.md

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

* Revert one line.

* Filler item name blurb.

* Updates for Violet.

* Reorganize client expectations.

* Missed a line delete.

* Doctor's orders

---------

Co-authored-by: qwint <qwint.42@gmail.com>
2025-04-05 04:18:47 +02:00
Richard Snider
507e051a5a Core: Handle integer arguments in player names gracefully (#4151) 2025-04-05 03:36:20 +02:00
Scipio Wright
b5bf9ed1d7 TUNIC: Error message in the spot that UT errors at if you have an old APWorld #4788
Schnice and Shrimple
2025-04-05 00:53:13 +02:00
Fabian Dill
215eb7e473 core: increment version (#4808) 2025-04-04 23:25:37 +02:00
qwint
f42233699a Core: make accessibility_corrections only state.remove if the location was collected 2025-04-04 23:20:45 +02:00
massimilianodelliubaldini
1bec68df4d WebHost: Standardize some 404 redirects (#4642) 2025-04-04 23:11:45 +02:00
CodeGorilla
d8576e72eb Pokemon Red/Blue: Set allow_partial_entrances to true when building a state for ER #4802
Co-authored-by: CodeGorilla <3672561+Ars-Ignis@users.noreply.github.com>
2025-04-04 10:48:47 +02:00
Fabian Dill
7265468e8d kvui: fix [u] and [/u] appearing in copied hints (#4794) 2025-04-03 09:22:02 +02:00
Fabian Dill
d07f36dedd Core: increment version (#4787) 2025-04-02 05:35:39 +02:00
Scipio Wright
364a1b71ec TUNIC: Note Death Link and Trap Link in-game toggles on Game Info page (#4741)
* Note death link and trap link in game info page

* Update worlds/tunic/docs/en_TUNIC.md

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

* Turn it into a bulleted list
2025-04-01 19:55:19 -04:00
Sanjay Govind
daee6d210f CommonClient: don't update ui hints if there is no ui (#4791) 2025-04-02 01:54:27 +02:00
Bryce Wilson
96be0071e6 Pokemon Emerald: Move recent change to new version (#4793) 2025-04-02 00:50:39 +02:00
threeandthreee
ff8e1dfb47 Launcher: Remove an unnecessary global (#4785) 2025-04-01 21:28:59 +02:00
LiquidCat64
d26db6f213 CV64: Fix some unrandomized locations containing unintended items on specific settings (#4728)
* Fix some unrandomized locations on specific settings.

* Remove now-unnecessary comment
2025-04-01 12:37:49 -04:00
Fabian Dill
bb6c753583 FFMQ: fix remote code execution (#4786) 2025-04-01 18:19:07 +02:00
Mysteryem
ca08e4b950 Super Metroid: Replace random module with world random in variaRandomizer (#4429) 2025-04-01 18:14:47 +02:00
Bryce Wilson
5a6b02dbd3 Pokemon Emerald: Fix pre-fill problems (#4686)
Co-authored-by: Mysteryem <Mysteryem@users.noreply.github.com>
2025-04-01 18:12:43 +02:00
jamesbrq
14416b1050 MLSS: Fix issue with door opening earlier than intended (#4737) 2025-04-01 18:10:51 +02:00
Carter Hesterman
da4e6fc532 Civ6: Sanitize player/item values before they go in the XML (#4755) 2025-04-01 18:09:59 +02:00
Justus Lind
57d8b69a6d Muse Dash: Update Song List to Muse Dash Legend. (#4775)
* Add Muse Dash Legend songs.

* Add a new SFX trap
2025-04-01 18:08:09 +02:00
Silvris
c9d8a8661c kvui: Fix hint tab formatting regression (#4778)
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2025-04-01 18:06:49 +02:00
Fabian Dill
4a3d23e0e6 Core: update cx-Freeze to 8.0.0 & Worlds: fix packages missing __init__.py (#4773) 2025-04-01 16:29:32 +02:00
PoryGone
a3666f2ae5 SA2B: Fix critical typo #4779 2025-03-30 22:19:24 +02:00
Kaito Sinclaire
c3e000e574 id Tech 1 games: Logic updates (Feb '25) (#4677)
- Across Doom 1993 and Doom 2, any items that are accessible in Ultra-Violence from the start of the level without putting the player in any danger are now considered in logic when that level is first received, without needing any weapons available. This is intended to give generation more possible outs for bad placements.
  - This affects the following maps in Doom 1993:
    - Toxin Refinery (E1M3): 1 location.
    - Command Control (E1M4): 1 location.
    - Computer Station (E1M7): 1 location.
    - Deimos Lab (E2M4): 1 location.
    - Tower of Babel (E2M8): 1 location.
    - Unholy Cathedral (E3M5): 1 location.
  - This affects the following maps in Doom 2:
    - The Waste Tunnels (MAP05): 2 locations.
    - Dead Simple (MAP07): 2 locations.
    - The Pit (MAP09): 1 location.
    - Refueling Base (MAP10): 1 location.
    - Nirvana (MAP21): 1 location, except see below.
    - Icon of Sin (MAP30): 9 locations.
    - Grosse (MAP32): 2 locations.
- Doom 2 has had some more significant logical adjustments made.
  - The following Pro tricks have been added to Pro logic:
    - Circle of Death (MAP11): Lowering the exit wall without the Red key by hitting the switch to do so from the nukage. This makes three items previously locked behind the Red key available early, as well as the exit.
    - Suburbs (MAP16): Reaching the exit without any keys, as the gap between the pillar and the wall is large enough to let you through if you position yourself well. While multiple other squeeze glides exist (for example, you can skip the Yellow key in MAP21 by using one), this one is significantly easier than the rest; it does not require much precision, nor does it require vertical mouse movement.
    - Nirvana (MAP21): Skipping the Blue key, as there is a gigantic gap between the bars that attempt to block you.
    - The Chasm (MAP24): Skipping the Blue key by going extremely far through the nukage and finding one of a couple specific teleporters is now considered a Pro trick, and standard logic now expects the key to be obtained.
  - The following levels have had other logic adjustments:
    - The Waste Tunnels (MAP05): Requirements lowered to Shotgun + Super Shotgun + (Chaingun | Plasma gun).
    - The Crusher (MAP06): Requirements lowered to Shotgun + (Chaingun | Plasma gun) for areas immediately accessible. Going beyond the Blue key door also requires Super Shotgun.
    - The Factory (MAP12): The outdoors area, and the little room to the right of where you start, are accessible in sphere 1. These three items are all easily obtainable with only the pistol. The remaining items that are not in the central area are accessible with (Super Shotgun | Plasma gun), while the items in that area are accessible with Super Shotgun + Chaingun + (Plasma gun | BFG9000). This fixes Episode 2 not having an available sphere 1, and allows solo Episode 2 games.
    - Nirvana (MAP21): As above, the item in the starting room is accessible in sphere 1. Every other item that doesn't require a key is accessible with (Super Shotgun | Plasma gun). The room in which you use the Yellow key is accessible with Super Shotgun + Chaingun + (Plasma gun | BFG9000). This fixes Episode 3 not having an available sphere 1, and allows solo Episode 3 games.
    - The Catacombs (MAP22): The four items in the opening room only require (Shotgun | Super Shotgun | Plasma gun). The rest of the level is as before.
    - Bloodfalls (MAP25): Requirements lowered to Shotgun + Super Shotgun + Chaingun, as this level is unusually easy for its placement in the game. Progressing past the Blue key door additionally requires (Rocket launcher | Plasma gun | BFG9000) solely to deal with the Arch-vile at the end of the level.
    - Wolfenstein (MAP31): Requirements lowered to Chaingun + (Shotgun | Super Shotgun). This is closer to what the game expects from a non-secret hunting player from a pistol start.
- The following logic bugs in Heretic have been fixed:
  - Quay (E5M3): An item in a Blue key locked hallway was previously marked as being in the "Main" region, thus considered to be accessible without that key. It has been moved to the appropriate "Blue" region.
  - Courtyard (E5M4): Logic previously assumed you could reach the Wings of Wrath from the opening room, when that isn't actually possible. Changing this moved some items previously in the "Main" region into a new "Green" region, and items previously in the "Kakis" (Yellow OR Green) are now in a "Yellow" region instead. Fixes #4662.
- For known problematic solo episodes, some additional special cases have been added.
  - Doom 1993, Episode 3: One of either the Shotgun or Chaingun is placed early. Slough of Despair (E3M2) is given as an additional starting level.
  - Doom 2, Episode 3: One of either the Super Shotgun or Plasma gun is placed early.
  - Heretic, Episode 1: The Docks (E1M1) - Yellow key is placed early.
- The following levels (and thus, their items and locations) were renamed, due to typos or other oddities:
  - `Barrels o Fun (MAP23)` -> `Barrels o' Fun (MAP23)`
  - `Wolfenstein2 (MAP31)` -> `Wolfenstein (MAP31)`
  - `Grosse2 (MAP32)` -> `Grosse (MAP32)`
  - `D'Sparil'S Keep (E3M8)` -> `D'Sparil's Keep (E3M8)`
  - `The Aquifier (E3M9)` -> `The Aquifer (E3M9)`
2025-03-29 17:32:33 +01:00
Justus Lind
dd5481930a Muse Dash: Update docs to recommend MelonLoader 0.7.0 rather than 0.6.1 (#4776)
* Tiny version update.

* Update wording because there is no longer a latest button
2025-03-29 01:35:35 +01:00
Scipio Wright
842328c661 TUNIC: Update swamp and atoll fuse logic with weaponry (#4760)
* Update swamp and atoll fuse logic with weaponry

* Add it to the swamp and cath rules too
2025-03-28 21:12:16 +01:00
PoryGone
8f75384e2e SA2B - v2.4 Logic Fixes (#4770)
* Logic tweaks

* Docs updates

* Delete extra file

* One more logic tweak

* Add missing logic change
2025-03-28 21:11:31 +01:00
Fabian Dill
193faa00ce Factorio: fix energylink type back to int (#4768) 2025-03-28 00:28:10 +01:00
Star Rauchenberger
5e5383b399 Lingo: Add painting display names (#4707)
* Lingo: Add painting display names

* Reordered some paintings

* Update generated.dat
2025-03-27 01:32:39 +01:00
threeandthreee
cb6b29dbe3 LADX: fix for unconnected entrances in other worlds #4771 2025-03-25 22:30:25 +01:00
Fabian Dill
82b0819051 Core: ensure requirements files end on newline (#4761) 2025-03-24 22:26:30 +01:00
Jérémie Bolduc
e12ab4afa4 Stardew Valley: Move test option presets to their own file (#4349) 2025-03-24 03:32:34 +01:00
Justus Lind
1416f631cc Core: Add a test that checks all registered patches matches the name of a registered world (#4633)
Co-authored-by: qwint <qwint.42@gmail.com>
2025-03-24 03:30:44 +01:00
Fabian Dill
dbaac47d1e Core: update various requirements (#4731) 2025-03-23 17:24:50 +01:00
Jonathan Tan
cf0ae5e31b The Wind Waker: Implement New Game (#4458)
Adds The Legend of Zelda: The Wind Waker as a supported game in Archipelago. The game uses [LagoLunatic's randomizer](https://github.com/LagoLunatic/wwrando) as its base (regarding logic, options, etc.) and builds from there.
2025-03-23 00:42:17 +01:00
BadMagic100
8891f07362 Core: Allow and require user-provided target name when splitting 1-way entrances for GER (#4746)
* [Core][GER] Allow and require user-provided target name when splitting 1-way entrances

* Move target naming onto a parameter of disconnect_entrance_for_randomization
2025-03-22 20:58:35 +01:00
NewSoupVi
d78974ec59 The Witness: Bump Required Client Version to 0.6.0 (#4763)
The beta client releases already report this.
2025-03-22 20:57:22 +01:00
NewSoupVi
32be26c4d7 The Witness: Make sure the 2025 April Fools feature does not go live with RC3 (#4758) 2025-03-22 20:52:18 +01:00
Jérémie Bolduc
9de49aa419 Stardew Valley: Move all the goal logic into its own file (#4383) 2025-03-22 20:29:16 +01:00
PoryGone
294a67a4b4 SA2B: v2.4 - Minigame Madness (#4663)
Changelog:

Features:
- New Goal
  - Minigame Madness
    - Win a certain number of each type of Minigame Trap, then defeat the Finalhazard to win!
	- How many of each Minigame are required can be set by an Option
	- When the required amount of a Minigame has been received, that Minigame can be replayed in the Chao World Lobby
- New optional Location Checks
  - Bigsanity
    - Go fishing with Big in each stage for a Location Check
  - Itemboxsanity
    - Either Extra Life Boxes or All Item Boxes
- New Items
  - New Traps
    - Literature Trap
	- Controller Drift Trap
	- Poison Trap
	- Bee Trap
  - New Minigame Traps
    - Breakout Trap
	- Fishing Trap
	- Trivia Trap
	- Pokemon Trivia Trap
	- Pokemon Count Trap
	- Number Sequence Trap
	- Light Up Path Trap
	- Pinball Trap
	- Math Quiz Trap
	- Snake Trap
	- Input Sequence Trap
- Trap Link
  - When you receive a trap, you send a copy of it to every other player with Trap Link enabled
- Boss Gate Plando
- Expert Logic Difficulty
	- Use at your own risk. This difficulty requires complete mastery of SA2.
- Missions can now be enabled and disabled per-character, instead of just per-style
- Minigame Difficulty can now be set to "Chaos", which selects a new difficulty randomly per-trap received

Quality of Life:
- Gate Stages and Mission Orders are now displayed in the spoiler log
- Additional play stats are saved and displayed with the randomizer credits
- Stage Locations progress UI now displays in multiple pages when Itemboxsanity is enabled
- Current stage mission order and progress are now shown when paused in-level
- Chaos Emeralds are now shown when paused in-level
- Location Name Groups were created
- Moved SA2B to the new Options system
- Option Presets were created
- Error Messages are more obvious

Bug Fixes:
- Added missing `Dry Lagoon - 12 Animals` location
- Flying Dog boss should no longer crash when you have done at least 3 Intermediate Kart Races
- Invincibility can no longer be received in the King Boom Boo fight, preventing a crash
- Chaos Emeralds should no longer disproportionately end up in Cannon's Core or the final Level Gate
- Going into submenus from the pause menu should no longer reset traps
- `Sonic - Magic Gloves` are now plural
- Junk items will no longer cause a crash when in a falling state
- Chao Garden:
	- Prevent races from occasionally becoming uncompletable when using the "Prize Only" option
	- Properly allow Hero Chao to participate in Dark Races
	- Don't allow the Chao Garden to send locations when connected to an invalid server
	- Prevent the Chao Garden from resetting your life count
	- Fix Chao World Entrance Shuffle causing inaccessible Neutral Garden
	- Fix pressing the 'B' button to take you to the proper location in Chao World Entrance Shuffle
	- Prevent Chao Karate progress icon overflow
	- Prevent changing Chao Timescale while paused or while a Minigame is active
- Logic Fixes:
	- `Mission Street - Chao Key 1` (Hard Logic) now requires no upgrades
	- `Mission Street - Chao Key 2` (Hard Logic) now requires no upgrades
	- `Crazy Gadget - Hidden 1` (Standard Logic) now requires `Sonic - Bounce Bracelet` instead of `Sonic - Light Shoes`
	- `Lost Colony - Hidden 1` (Standard Logic) now requires `Eggman - Jet Engine`
	- `Mad Space - Gold Beetle` (Standard Logic) now only requires `Rouge - Iron Boots`
	- `Cosmic Wall - Gold Beetle` (Standard and Hard Logic) now only requires `Eggman - Jet Engine`
2025-03-22 13:00:07 +01:00
panicbit
0e99888926 LADX: Stop spamming location checks over network (#4757) 2025-03-21 17:10:17 +01:00
qwint
74cbf10930 Civ6: Use AutoPatchRegister to make patch downloadable on webhost #4752 2025-03-20 19:28:16 +01:00
BadMagic100
08d2909b0e Hollow Knight: Include Lumafly links to install mods in docs (#4745) 2025-03-20 11:49:55 -04:00
CaitSith2
0949b11436 ALttP: Don't crash generation if sprite paths don't exist (#4725) 2025-03-20 14:48:30 +01:00
Aaron Wagener
9cdffe7f63 The Messenger: Add display names to the plando options (#4748) 2025-03-19 15:52:14 -04:00
Bryce Wilson
8b2a883669 Pokemon Emerald: Update changelog (#4747) 2025-03-19 02:17:01 +01:00
NewSoupVi
b7fc96100c Revert "Core: update websockets (#4732)" (#4753)
This reverts commit 42eaeb92f0.
2025-03-19 01:39:18 +01:00
Aaron Wagener
63cbc00a40 The Messenger: Fix corrupted future rule (#4749) 2025-03-18 19:01:31 -04:00
CodeGorilla
57b94dba6f Options: Add a column for player ID to --csv_output (#4715) 2025-03-17 21:43:00 +01:00
ironminer888
0dd188e108 LADX: Add more specific "item icon guessing" support for some games (#4706)
* DKC3, PKMN R/B/Em, M&L specific item matches

* MLSS Bean types are now discrete

* Add Doom 1/2 items

* Add Doom 1/2 items, actually

* Add Inscryption items

* Add more SA2B items, Minecraft

* Add VVVVVV

* Add misc items, comma fixes

* Hat in Time items

* Misc changes

* Expand TODO

* Add more OoT items, Pokemon consumables

* KH2

* KH1, adjust KH2 items

* Formatting fixes

* more item changes, fix kh1 name

* Fix KH1 name

* Add Full Heal to MEDICINE graphics

* Final comma fixes before PR

* Add Full Restore as Medicine

* Move some names to generic, drink fixes, double-quotes consistency fix

* moved ROCK SMASH match to PHRASES dict

* Removed some redundant name checks, remove Old Amber check from Emerald

* Added "PASS" generic check as "LETTER" sprite

* Removed TODO

* Corrected KH1 name for real this time

* Icon assignment now uppers freogin item string during comparison

* Doom skull keys are now NIGHTMARE_KEY, added QUILL as generic for FEATHER

* KH2 armor is Blunic, accessories are Ribbons

* KH1 accessories/armor are Blunic

* "ROCK SMASH" is now "BOMB"

* Removed extra space
2025-03-17 11:50:57 -04:00
PoryGone
bf8c840293 Celeste 64: v1.3 Content Update (#4581)
### Features:

- New optional Location Checks
	- Checkpointsanity
- Hair Color
	- Allows for setting of Maddy's hair color in each of No Dash, One Dash, Two Dash, and Feather states
- Other Player Ghosts
	- A game config option allows you to see ghosts of other Celeste 64 players in the multiworld

### Quality of Life:

- Checkpoint Warping
	- Received Checkpoint items allow for warping to their respective checkpoint
		- These items are on their respective checkpoint location if Checkpointsanity is disabled
	- Logic accounts for being able to warp to otherwise inaccessible areas
	- Checkpoints are a possible option for a starting item on Standard Logic + Move Shuffle + Checkpointsanity
- New Options toggle to enable/disable background input

### Bug Fixes:

- Traffic Blocks now correctly appear disabled within Cassettes
2025-03-17 02:46:34 +01:00
black-sliver
c0244f3018 Tests: unroll 2 player gen, add parametrization helper, add docs (#4648)
* Tests: unroll test_multiworlds.TestTwoPlayerMulti

Also adds a helper function that other tests can use to unroll tests.

* Docs: add more details to docs/tests.md

* Explain parametrization, subtests and link to the new helper
* Mention some performance details and work-arounds
* Mention multithreading / pytest-xdist

* Tests: make param.classvar_matrix accept sets

* CI: add test/param.py to type checking

* Tests: add missing typing to test/param.py

* Tests: fix typo in test/param.py doc comment

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

* update docs

* Docs: reword note on performance

---------

Co-authored-by: qwint <qwint.42@gmail.com>
2025-03-17 00:16:02 +01:00
black-sliver
8af8502202 CI: pin some actions (#4744) 2025-03-17 00:02:00 +01:00
Fabian Dill
42eaeb92f0 Core: update websockets (#4732) 2025-03-16 22:13:12 +01:00
Alchav
7f35eb8867 Pokémon R/B: Allow generating with all items linked (#4330)
* Pokémon R/B: Allow generating with all items linked

* check priority/excluded locations for pc_item

* Update regions.py

* Un-remove regions.py code
2025-03-16 12:33:24 -04:00
BadMagic100
785569c40c Core: Generic ER fails in stage 1 when the last available target is an indirect conditioned dead end (#4679)
* Add test that stage1 ER will not fail due to speculative sweeping an indirect conditioned dead end

* Skip speculative sweep if it's the last entrance placement

* Better implementation of needs_speculative_sweep

* pep8
2025-03-15 18:56:07 +01:00
Scipio Wright
a9eb70a881 OoT: Remove Outdated Spanish Setup Guide (#4736)
* Remove spanish setup guide from webworld

* Update __init__.py

* Update __init__.py
2025-03-15 07:16:06 -04:00
Scipio Wright
5d3d0c8625 WebHost: Update text for options you can't modify (#4614) 2025-03-15 07:10:07 -04:00
Scipio Wright
7e32feeea3 Webhost: Update random option wording on webhost (#4555)
* Update random option wording on webhost

* Update WebHostLib/templates/playerOptions/macros.html

Co-authored-by: Jouramie <16137441+Jouramie@users.noreply.github.com>
2025-03-15 07:09:04 -04:00
neocerber
0d1935e757 SC2: Add a description of mission order and the impact of collect on a SC2 world (#4398)
* Added mission order to randomized stuff, added a mention to the default option collect on goal, added an issue about mission order progress vs AP collect

* Remove false menion of collect being note modifyable after the mworld was gen

* Simplification of some sentences

* American spelling, header newline, and other

* Revert gray to grey, corrected some colors

* Forgot a gray -> grey

* Replace how the faction color option is described to side-step difference within yaml and client. Both fr/en.
2025-03-14 11:35:58 -04:00
Benny D
9b3ee018e9 Core/Various Worlds: Fix crash/freeze with unicode characters (#4671)
replace colorama.init with just_fix_windows_console
2025-03-14 08:24:37 +01:00
NewSoupVi
1de411ec89 The Witness: Change Regions, Areas and Connections from Dict[str, Any] to dataclasses&NamedTuples (#4415)
* Change Regions, Areas and Connections to dataclasses/NamedTuples

* Move to new file

* we do a little renaming

* Purge the 'lambda' naming in favor of 'rule' or 'WitnessRule'

* missed one

* unnecessary change

* omega oops

* NOOOOOOOO

* Merge error

* mypy thing
2025-03-13 23:59:09 +01:00
LiquidCat64
3192799bbf CVCotM: Clarify the Wii U VC version is unsupported (#4734)
* Comment out VC ROM hash usages and clarify that it's unsupported.

* Update worlds/cvcotm/docs/en_Castlevania - Circle of the Moon.md

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

* Update worlds/cvcotm/docs/setup_en.md

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

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-03-13 00:21:09 +01:00
Aaron Wagener
2c8dded52f The Messenger: Fix some transition plando issues (#4720)
* don't allow one-way and two-way entrances to be connected to each other

* add special handling for the tower hq nodes since they share the same parent region
2025-03-10 22:13:49 -04:00
justinspatz
06111ac6cf OOT: Have beehives that only appear as a child not be in logic if only adult can break beehives (#4646)
* Change the logic for the 3 Zora's Domain Beehives to support new rule

Implement new logic changes to these 3 locations

* Update LogicHelpers.json with new rule for beehives that only appear for child link

Added below the "can_break_upper_beehive" a new helper called "can_break_upper_beehive_child" which removes the requirement for hookshot to avoid a logic error in the Zora Domain Beehives where it checks whether child or adult can break beehives, even though these beehives do not appear as an adult.

* Update LogicHelpers.json moving the call for is_child

As is_child is already called for can_use (Boomerang), it's a bit redundant to include the check for using the Boomerang, so it's being moved to be with the Bombchu check to ensure that it's not expected if the Bombchu Logic Rule is turned on that Adult can use bombchus to break the beehives. This effectively does the same thing, but should be better on performance.
2025-03-10 17:39:45 +01:00
agilbert1412
d83294efa7 Stardew valley: Fix Aurora Vineyard Tablet logic (#4512)
* - Add requirement on Aurora Vineyard tablet to start the quest

* - Add rule for using the aurora vineyard staircase

* - Added a test for the tablet

* - Add a few missing items to the test

* - Introduce a new item to split the quest from the door and avoir ER issues

* - Optimize imports

* - Forgot to generate the item

* fix Aurora mess

# Conflicts:
#	worlds/stardew_valley/rules.py
#	worlds/stardew_valley/test/mods/TestMods.py

* fix a couple errors in the cherry picked commit, added a method to improve readability and reduce chance of human error on story quest conditions

* - remove blank line

* - Code review comments

* - fixed weird assert name

* - fixed accidentally surviving line

* - Fixed imports

---------

Co-authored-by: Jouramie <16137441+Jouramie@users.noreply.github.com>
2025-03-10 11:39:35 -04:00
Dinopony
be550ff6fb Landstalker: Several small fixes (#4675)
* Landstalker: Fixed duplicate entrance names when using the "No teleport tree requirements" option

* Landstalker: Fixed more cases of duplicate entrance names when using "Shuffle Trees" with open trees

* Landstalker: Fixed endgame locations being present in "Reach Kazalt" goal

* Landstalker: Fixed Lithograph hint pointing at the wrong player

* Landstalker: Updated docs to remove the link to Steam since game got delisted

* Landstalker: Fixed high value hint_count rarely failing at generation

* Landstalker: Fixed dynamic shop prices being potentially invalid in case of a progression balancing (changes by ExemptMedic)
2025-03-10 11:35:58 -04:00
Patrick Lübcke
dd55409209 Pokémon R/B: Fix Rock Tunnel B1F randomization (#4670)
* Bottom to central path sealed off

* Bottom-to-left-path to right path sealed off

* Central opening (r4444): Left unsealed, paths seperated

* Top right half rocks fixed

* Middle to top opening sealed

* Right hallway seal correctly positioned

* Top right ladder: Fixed overlapping walls
2025-03-10 11:35:40 -04:00
Mysteryem
e267714d44 AHiT: Rework Subcon Forest Boss Arena, Boss Firewall and YCHE logic (#4494)
A new `Subcon Forest - Behind Boss Firewall` region is added for
`Subcon Village - Snatcher Statue Chest`. `Subcon Forest Area` connects
to this new region, requiring either the first
`Progressive Painting Unlock`, or Expert logic +
`NoPaintingSkips: false`.

A new `Subcon Forest Boss Arena` region is added for
`Subcon Forest - Boss Arena Chest` because this is immediately
accessible from YCHE. There are connections to this region from
`Your Contract has Expired` (no requirements) and from
`Subcon Forest - Behind Boss Firewall` (requiring either Hard logic or
`Hookshot Badge` + `TOD Access`).

A reverse connection is also added to Expert logic, for
`Subcon Forest Boss Arena` -> `Subcon Forest - Behind Boss Firewall`.
This could be extended to include Hard logic if there is a reasonable
Cherry Bridge setup.

A reverse connection is also added to Expert logic, for
`Subcon Forest - Behind Boss Firewall` -> `Subcon Forest Area`, so long
as `NoPaintingSkips: false` because it is impossible to burn the
paintings to remove the firewall, from behind the firewall.

A new `Your Contract has Expired - Post Fight` region is added for the
Snatcher post fight cutscene to prevent the Snatcher Hover trick giving
access to YCHE, which would otherwise also give access to the new
`Subcon Forest Boss Arena` Region.

The paintings and boss arena gap logic for `Snatcher Statue Chest` and
`Boss Arena Chest` are now handled using the connections to/from these
new regions rather than being on the locations themselves.

The logic for `Act Completion (Toilet of Doom)` remains unchanged
because it has to be in the `Toilet of Doom` region.

In Expert logic, with `NoPaintingSkips: false`, YCHE is added as a rift
access region to Subcon Forest Time Rift entrances.

The `YCHE Access` event is no longer used and has been removed.

- Fixes painting skips logic for Subcon Village - Snatcher Statue Chest
- Fixes Subcon Forest - Boss Arena Chest being inaccessible from YCHE
- Adds Expert logic to reach `Snatcher Statue Chest` from YCHE
- Adds Expert logic to skip the boss firewall in reverse from YCHE so
long as painting skips are not removed from logic
- Adds Expert logic to access Subcon Forest Time Rift entrances from
YCHE so long as painting skips are not removed from logic
2025-03-10 11:34:10 -04:00
Aaron Wagener
7c30c4a169 The Messenger: Transition Shuffle (#4402)
* The Messenger: transition rando

* remove unused import

* always link both directions for plando when using coupled transitions

* er_type was renamed to randomization_type

* use frozenset for things that shouldn't change

* review suggestions

* do portal and transition shuffle in `connect_entrances`

* remove some unnecessary connections that were causing entrance caching collisions

* add test for strictest possible ER settings

* use unittest.skip on the skipped test, so we don't waste time doing setUp and tearDown

* use the world helpers

* make the plando connection description more verbose

* always add searing crags portal if portal shuffle is disabled

* guarantee an arbitrary number of locations with first connection

* make the constraints more lenient for a bit more variety
2025-03-10 11:16:09 -04:00
Alchav
4882366ffc LTTP: Fix TR Big Key Door Entrance Logic (#4712) 2025-03-10 15:56:05 +01:00
Carter Hesterman
5f73c245fc New Game Implementation: Civilization VI (#3736)
* Init

* remove submodule

* Init

* Update docs

* Fix tests

* Update to use apcivvi

* Update Readme and codeowners

* Minor changes

* Remove .value from options (except starting hint)

* Minor updates

* remove unnecessary property

* Cleanup Rules and Region

* Fix output file generation

* Implement feedback

* Remove 'AP' tag and fix issue with format strings and using same quotes

* Update worlds/civ_6/__init__.py

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

* Minor docs changes

* minor updates

* Small rework of create items

* Minor updates

* Remove unused variable

* Move client to Launcher Components with rest of similar clients

* Revert "Move client to Launcher Components with rest of similar clients"

This reverts commit f9fd5df9fd.

* modify component

* Fix generation issues

* Fix tests

* Minor change

* Add improvement and test case

* Minor options changes

* .

* Preliminary Review

* Fix failing test due to slot data serialization

* Format json

* Remove exclude missable boosts

* Update options (update goody hut text, make research multiplier a range)

* Update docs punctuation and slot data init

* Move priority/excluded locations into options

* Implement docs PR feedback

* PR Feedback for options

* PR feedback misc

* Update location classification and fix client type

* Fix typings

* Update research cost multiplier

* Remove unnecessary location priority code

* Remove extrenous use of items()

* WIP PR Feedback

* WIP PR Feedback

* Add victory event

* Add option set for death link effect

* PR improvements

* Update post fill hint to support items with multiple classifications

* remove unnecessary len

* Move location exclusion logic

* Update test to use set instead of accidental dict

* Update docs around progressive eras and boost locations

* Update docs for options to be more readable

* Fix issue with filler items and prehints

* Update filler_data to be static

* Update links in docs

* Minor updates and PR feedback

* Update boosts data

* Update era required items

* Update existing techs

* Update existing techs

* move boost data class

* Update reward data

* Update prereq data

* Update new items and progressive districts

* Remove unused code

* Make filler item name func more efficient

* Update death link text

* Move Civ6 to the end of readme

* Fix bug with hidden locations and location.name

* Partial PR Feedback Implementation

* Format changes

* Minor review feedback

* Modify access rules to use list created in generate_early

* Modify boost rules to precalculate requirements

* Remove option checks from access rules

* Fix issue with pre initialized dicts

* Add inno setup for civ6 client

* Update inno_setup.iss

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: Exempt-Medic <ExemptMedic@Gmail.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-03-10 14:53:26 +01:00
NewSoupVi
21ffc0fc54 Band-aid Linux Build breaking with the release of PyGObject 3.52.1 (#4716)
* Band-aid Linux Build breaking with the release of PyGObject 3.52.1

* Update build.yml

* Release workflow as well
2025-03-10 14:43:52 +01:00
Scipio Wright
e95a41cf93 TUNIC: Add another alias for ladders #4714 2025-03-10 14:24:37 +01:00
Silvris
04771fa4f0 Core: fix pickling plando texts (#4711) 2025-03-09 20:00:00 +01:00
jamesbrq
2639796255 MLSS: Add new goal + Update basepatch to standalone equivalent (#4409)
* Item groups + small changes

* Add alternate goal

* New Locations and Logic Updates + Basepatch

* Update basepatch.bsdiff

* Update Basepatch

* Update basepatch.bsdiff

* Update bowsers castle logic with emblem hunt

* Update Archipelago Unittests.run.xml

* Update Archipelago Unittests.run.xml

* Fix for overlapping ROM addresses

* Update Rom.py

* Update __init__.py

* Update basepatch.bsdiff

* Update Rom.py

* Update client with new helper function

* Update basepatch.bsdiff

* Update worlds/mlss/__init__.py

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

* Update worlds/mlss/__init__.py

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

* Review Refactor

* Review Refactor

---------

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

* fix

* remove unneeded imports

* self review

* remove unneeded imports

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

* rearrange imports

* Dict to dict

* remove optional typing

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

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

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

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

* Pokemon Emerald: Refactor encounter data on maps

* Pokemon Emerald: Remove unused import

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

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

* Timespinner: Add support for upstream Lock Key Amadeus flag

* Timespinner: Add support for upstream Risky Warps flag

* Timespinner: Add support for upstream Pyramid Start flag

* Timespinner: fix error in lab connectivity logic

* Timespinner: use has_all to simplify one check

Per PR suggestion.

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

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

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

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

* Timespinner: add newly added Gate Keep option from rando

* Timespinner: adjust the laser access colours in the tracker

* Timespinner: fix an item description in the tracker

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

* Timespinner: add support for new upstream flag Royal Roadblock

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

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

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

* Timespinner: fix region logic for the left pyramid warp

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

* Timespinner: apply suggested spacing fix

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

---------

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

* Rules for breakable regions

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

* Make it work in not pot shuffle

* Fix after merge

* Fix item id overlap

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

* Fix groups getting overwritten

* Rename, add new breakables

* Rename more stuff

* Time to rename them again

* Make it actually default for breakable shuffle

* Burn the signs down

* Fix west courtyard pot regions

* Fix fortress courtyard and beneath the fortress loc groups again

* More missing loc group conversions

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

* Update worlds/tunic/__init__.py

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

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

* replace option usage with calling feature

* add comment explaining why some logic is a weird place

* replace item creation logic with feature

* self review and add unit tests

* rename test cuz I named them too long

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

* self review again

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

* damn it 3.11 why are you like this

* use blacksmith region when checking vanilla tools

* fix rule

* move can mine using in tool logic

* remove changes to performance test

* properly set the option I guess

* properly set options 2

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

* Do not progression balance capacity up items

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

* Clean up the new options a bit

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

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

* Do the visibility change for Heretic as well

* Update required client version

* Remove spoiler log restriction on options

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

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

* Pokemon Emerald: Remove accidentally added print

* Pokemon Emerald: Update changelog

* Pokemon Emerald: Adjust changelog

* Pokemon Emerald: Remove unnecessary else

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

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

* add stat increase protection and ingame yml stuff

* idk how I forgot these

* reword things

* Update worlds/kh2/Client.py

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

* 3.12 compat

* too long of a line

* why didnt I do this before lol

* reading is hard

* missed one

* forgot the self

* fix crash if you get datapackage that isnt kh2

* update to main?

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

* reverting this because I'm bad at my job

---------

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

* Missing new line.

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

* Part way through location improvement

* Fixed location tracking

* Preliminary entrance tracking support

* Actually send entrance messages

* Store found entrances on the server

* Bit of cleanup

* Added rupee count, items linked to checks

* Send Magpie a handshAck

* Got my own version wrong

* Remove the Beta name

* Only send slot_data if there's something in it

* Ask the server for entrance updates

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

* Oops, server storage is shared between worlds

* Deal with null responses from the server

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

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

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

* Fixes

* Fixes and unit tests

* renaming some variables

* Fix the thing

* unit test for elevator egg

* Docstring

* reword

* Fix duplicate locations I think?

* Remove debug thing

* Add the tests back lol

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

* Improve hint text for easter eggs

* Update worlds/witness/options.py

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

* Update worlds/witness/player_logic.py

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

* Update worlds/witness/options.py

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

* Update worlds/witness/player_logic.py

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

* Update worlds/witness/rules.py

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

* Update test_easter_egg_shuffle.py

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

* Move one of them

* Improve logic

* Lol

* Moar

* Adjust unit tests

* option docstring adjustment

* Recommend door shuffle

* Don't overlap IDs

* Option description idk

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

* Fix merge

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

* oop

* space

* This can be earlier than I thought, apparently.

* buffer

* Comment

* Make sure the option is VERY visible

* Some mypy stuff

* apparently ruff wants this

* .

* durinig

* Update options.py

* Explain the additional effects of each difficulty

* Fix logic of flood room secret

* Add Southern Peninsula Area

* oop

---------

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

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

* Change option comparison

* Change option checking and fix some stuff

- also keep prayer first on low hex counts

* Update option defaulting

* Update option checking

* Fix option assignment again

* Show player name in option warning

* Add new option to universal tracker stuff

* Update __init__.py

* Make helper method for getting total hexagons in itempool

* Update options.py

* Update option value passthrough

* Change ability shuffle to default on

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

* formatting

* pull zig's tarin's gift improvements

* typing

* alias groups for progressive items

* change tarins gift option a bit

* add bush breakers item group

* fix typo

* bush_breaker option, respect non_local_items

* review suggestions

* cleaner
thx exempt

* Update worlds/ladx/__init__.py

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

* fix gen failures for dungeon shuffle

* exclude shovel based on entrance mapping

---------

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

* Adds lots of Marin Flavour Text (#32)

* Updates of Splash text 24-09-18

* Re-Adds '

* use pkgutil

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

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

* cutting deathlink jokes

---------

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

* drop piracy-adjacent jokes

* marin text was too long

* more submissions

* no longer looking for new maintainer

---------

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

* use helper method instead

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

Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-03-05 23:48:03 +01:00
BadMagic100
cd761db170 Core: Do GER speculative sweep membership checks against a set #4698 2025-02-27 19:21:48 +01:00
Aaron Wagener
026011323e The Messenger: Fix 0 Required Power Seals (#4692) 2025-02-27 11:42:41 -05:00
Silvris
adc5f3a07d MM2: Fix Shuffled Weaknesses Seed Bleed (#4689) 2025-02-27 11:13:37 -05:00
BadMagic100
69940374e1 Core: Only consider requested exits during ER placement and speculative sweep #4684 2025-02-27 17:12:35 +01:00
Fabian Dill
4a7232c6f3 Merge branch 'main' into multiserver_discord_webhook 2024-10-18 00:35:06 +02:00
Fabian Dill
3ad7f55d6b Merge branch 'main' into multiserver_discord_webhook 2024-02-20 01:19:38 +01:00
Fabian Dill
342093c510 Merge branch 'main' into multiserver_discord_webhook 2024-01-05 21:30:29 +01:00
Fabian Dill
609cb22c91 Update MultiServer.py 2024-01-04 11:22:00 +01:00
Fabian Dill
0607051718 remove async 2024-01-03 22:14:35 +01:00
Fabian Dill
61fd11b351 MultiServer: add discord webhook support 2024-01-03 22:13:53 +01:00
663 changed files with 39489 additions and 20960 deletions

View File

@@ -2,6 +2,7 @@
"include": [
"../BizHawkClient.py",
"../Patch.py",
"../test/param.py",
"../test/general/test_groups.py",
"../test/general/test_helpers.py",
"../test/general/test_memory.py",

View File

@@ -65,7 +65,7 @@ jobs:
continue-on-error: false
if: env.diff != '' && matrix.task == 'flake8'
run: |
flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }}
flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }}
- name: "flake8: Lint modified files"
continue-on-error: true

View File

@@ -21,12 +21,17 @@ env:
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
jobs:
# build-release-macos: # LF volunteer
build-win: # RCs will still be built and signed by hand
build-win: # RCs and releases may still be built and signed by hand
runs-on: windows-latest
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
@@ -65,6 +70,18 @@ jobs:
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
dist/${{ env.ZIP_NAME }}
setups/${{ env.SETUP_NAME }}
- name: Check build loads expected worlds
shell: bash
run: |
@@ -99,8 +116,8 @@ jobs:
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2004:
runs-on: ubuntu-20.04
build-ubuntu2204:
runs-on: ubuntu-22.04
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v4
@@ -132,7 +149,7 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"
@@ -142,6 +159,16 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
- name: Attest Build
if: ${{ github.event_name == 'workflow_dispatch' }}
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/${{ env.APPIMAGE_NAME }}*
dist/${{ env.TAR_NAME }}
- name: Build Again
run: |
source venv/bin/activate

View File

@@ -36,9 +36,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ilammy/msvc-dev-cmd@v1
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756
if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@v1
- uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73
with:
build-type: 'Release'
- name: Build tests

View File

@@ -11,6 +11,11 @@ env:
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
permissions: # permissions required for attestation
id-token: 'write'
attestations: 'write'
contents: 'write' # additionally required for release
jobs:
create-release:
runs-on: ubuntu-latest
@@ -26,11 +31,79 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer
build-release-ubuntu2004:
runs-on: ubuntu-20.04
build-release-win:
runs-on: windows-latest
if: ${{ true }} # change to false to skip if release is built by hand
needs: create-release
steps:
- name: Set env
shell: bash
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v4
- name: Install python
uses: actions/setup-python@v5
with:
python-version: '~3.12.7'
check-latest: true
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
choco install innosetup --version=6.2.2 --allow-downgrade
- name: Build
run: |
python -m pip install --upgrade pip
python setup.py build_exe --yes
if ( $? -eq $false ) {
Write-Error "setup.py failed!"
exit 1
}
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME"
echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV
New-Item -Path dist -ItemType Directory -Force
cd build
Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
- name: Build Setup
run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
if ( $? -eq $false ) {
Write-Error "Building setup failed!"
exit 1
}
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher.exe
build/exe.*/ArchipelagoLauncherDebug.exe
build/exe.*/ArchipelagoGenerate.exe
build/exe.*/ArchipelagoServer.exe
setups/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:
draft: true # see above
prerelease: false
name: Archipelago ${{ env.RELEASE_VERSION }}
files: |
setups/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-release-ubuntu2204:
runs-on: ubuntu-22.04
needs: create-release
steps:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
@@ -64,7 +137,7 @@ jobs:
# charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
"${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"
@@ -74,6 +147,14 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -
- name: Attest Build
uses: actions/attest-build-provenance@v2
with:
subject-path: |
build/exe.*/ArchipelagoLauncher
build/exe.*/ArchipelagoGenerate
build/exe.*/ArchipelagoServer
dist/*
- name: Add to Release
uses: softprops/action-gh-release@975c1b265e11dd76618af1c374e7981f9a6ff44a
with:

2
.gitignore vendored
View File

@@ -4,11 +4,13 @@
*_Spoiler.txt
*.bmbp
*.apbp
*.apcivvi
*.apl2ac
*.apm3
*.apmc
*.apz5
*.aptloz
*.aptww
*.apemerald
*.pyc
*.pyd

View File

@@ -1,3 +1,4 @@
import sys
from worlds.ahit.Client import launch
import Utils
import ModuleUpdate
@@ -5,4 +6,4 @@ ModuleUpdate.update()
if __name__ == "__main__":
Utils.init_logging("AHITClient", exception_logger="Client")
launch()
launch(*sys.argv[1:])

View File

@@ -511,7 +511,7 @@ if __name__ == '__main__':
import colorama
colorama.init()
colorama.just_fix_windows_console()
asyncio.run(main())
colorama.deinit()

View File

@@ -9,8 +9,9 @@ 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,
from typing import (AbstractSet, Any, Callable, ClassVar, Dict, Iterable, Iterator, List, Literal, Mapping, NamedTuple,
Optional, Protocol, Set, Tuple, Union, TYPE_CHECKING)
import dataclasses
from typing_extensions import NotRequired, TypedDict
@@ -54,12 +55,21 @@ class HasNameAndPlayer(Protocol):
player: int
@dataclasses.dataclass
class PlandoItemBlock:
player: int
from_pool: bool
force: bool | Literal["silent"]
worlds: set[int] = dataclasses.field(default_factory=set)
items: list[str] = dataclasses.field(default_factory=list)
locations: list[str] = dataclasses.field(default_factory=list)
resolved_locations: list[Location] = dataclasses.field(default_factory=list)
count: dict[str, int] = dataclasses.field(default_factory=dict)
class MultiWorld():
debug_types = False
player_name: Dict[int, str]
plando_texts: List[Dict[str, str]]
plando_items: List[List[Dict[str, Any]]]
plando_connections: List
worlds: Dict[int, "AutoWorld.World"]
groups: Dict[int, Group]
regions: RegionManager
@@ -83,6 +93,8 @@ class MultiWorld():
start_location_hints: Dict[int, Options.StartLocationHints]
item_links: Dict[int, Options.ItemLinks]
plando_item_blocks: Dict[int, List[PlandoItemBlock]]
game: Dict[int, str]
random: random.Random
@@ -160,13 +172,12 @@ class MultiWorld():
self.local_early_items = {player: {} for player in self.player_ids}
self.indirect_connections = {}
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
self.plando_item_blocks = {}
for player in range(1, players + 1):
def set_player_attr(attr: str, val) -> None:
self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('plando_items', [])
set_player_attr('plando_texts', {})
set_player_attr('plando_connections', [])
set_player_attr('plando_item_blocks', [])
set_player_attr('game', "Archipelago")
set_player_attr('completion_condition', lambda state: True)
self.worlds = {}
@@ -223,7 +234,7 @@ class MultiWorld():
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
for option_key in all_keys:
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
f"Please use `self.options.{option_key}` instead.")
f"Please use `self.options.{option_key}` instead.", True)
option.update(getattr(args, option_key, {}))
setattr(self, option_key, option)
@@ -427,7 +438,8 @@ class MultiWorld():
def get_location(self, location_name: str, player: int) -> Location:
return self.regions.location_cache[player][location_name]
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False) -> CollectionState:
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False,
collect_pre_fill_items: bool = True) -> CollectionState:
cached = getattr(self, "_all_state", None)
if use_cache and cached:
return cached.copy()
@@ -436,10 +448,11 @@ class MultiWorld():
for item in self.itempool:
self.worlds[item.player].collect(ret, item)
for player in self.player_ids:
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
if collect_pre_fill_items:
for player in self.player_ids:
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
ret.sweep_for_advancements()
if use_cache:
@@ -616,7 +629,7 @@ class MultiWorld():
locations: Set[Location] = set()
events: Set[Location] = set()
for location in self.get_filled_locations():
if type(location.item.code) is int:
if type(location.item.code) is int and type(location.address) is int:
locations.add(location)
else:
events.add(location)
@@ -1022,9 +1035,6 @@ class Entrance:
connected_region: Optional[Region] = None
randomization_group: int
randomization_type: EntranceType
# LttP specific, TODO: should make a LttPEntrance
addresses = None
target = None
def __init__(self, player: int, name: str = "", parent: Optional[Region] = None,
randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None:
@@ -1043,10 +1053,8 @@ class Entrance:
return False
def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None:
def connect(self, region: Region) -> None:
self.connected_region = region
self.target = target
self.addresses = addresses
region.entrances.append(self)
def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool:
@@ -1106,6 +1114,9 @@ class Region:
def __len__(self) -> int:
return self._list.__len__()
def __iter__(self):
return iter(self._list)
# This seems to not be needed, but that's a bit suspicious.
# def __del__(self):
# self.clear()
@@ -1200,6 +1211,48 @@ class Region:
for location, address in locations.items():
self.locations.append(location_type(self.player, location, address, self))
def add_event(
self,
location_name: str,
item_name: str | None = None,
rule: Callable[[CollectionState], bool] | None = None,
location_type: type[Location] | None = None,
item_type: type[Item] | None = None,
show_in_spoiler: bool = True,
) -> Item:
"""
Adds an event location/item pair to the region.
:param location_name: Name for the event location.
:param item_name: Name for the event item. If not provided, defaults to location_name.
:param rule: Callable to determine access for this event location within its region.
:param location_type: Location class to create the event location with. Defaults to BaseClasses.Location.
:param item_type: Item class to create the event item with. Defaults to BaseClasses.Item.
:param show_in_spoiler: Will be passed along to the created event Location's show_in_spoiler attribute.
:return: The created Event Item
"""
if location_type is None:
location_type = Location
if item_name is None:
item_name = location_name
if item_type is None:
item_type = Item
event_location = location_type(self.player, location_name, None, self)
event_location.show_in_spoiler = show_in_spoiler
if rule is not None:
event_location.access_rule = rule
event_item = item_type(item_name, ItemClassification.progression, None, self.player)
event_location.place_locked_item(event_item)
self.locations.append(event_location)
return event_item
def connect(self, connecting_region: Region, name: Optional[str] = None,
rule: Optional[Callable[[CollectionState], bool]] = None) -> Entrance:
"""
@@ -1310,9 +1363,6 @@ class Location:
multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None
return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})'
def __hash__(self):
return hash((self.name, self.player))
def __lt__(self, other: Location):
return (self.player, self.name) < (other.player, other.name)
@@ -1416,6 +1466,10 @@ class Item:
def flags(self) -> int:
return self.classification.as_flag()
@property
def is_event(self) -> bool:
return self.code is None
def __eq__(self, other: object) -> bool:
if not isinstance(other, Item):
return NotImplemented

View File

@@ -196,25 +196,11 @@ class CommonContext:
self.lookup_type: typing.Literal["item", "location"] = lookup_type
self._unknown_item: typing.Callable[[int], str] = lambda key: f"Unknown {lookup_type} (ID: {key})"
self._archipelago_lookup: typing.Dict[int, str] = {}
self._flat_store: typing.Dict[int, str] = Utils.KeyedDefaultDict(self._unknown_item)
self._game_store: typing.Dict[str, typing.ChainMap[int, str]] = collections.defaultdict(
lambda: collections.ChainMap(self._archipelago_lookup, Utils.KeyedDefaultDict(self._unknown_item)))
self.warned: bool = False
# noinspection PyTypeChecker
def __getitem__(self, key: str) -> typing.Mapping[int, str]:
# TODO: In a future version (0.6.0?) this should be simplified by removing implicit id lookups support.
if isinstance(key, int):
if not self.warned:
# Use warnings instead of logger to avoid deprecation message from appearing on user side.
self.warned = True
warnings.warn(f"Implicit name lookup by id only is deprecated and only supported to maintain "
f"backwards compatibility for now. If multiple games share the same id for a "
f"{self.lookup_type}, name could be incorrect. Please use "
f"`{self.lookup_type}_names.lookup_in_game()` or "
f"`{self.lookup_type}_names.lookup_in_slot()` instead.")
return self._flat_store[key] # type: ignore
return self._game_store[key]
def __len__(self) -> int:
@@ -254,7 +240,6 @@ class CommonContext:
id_to_name_lookup_table = Utils.KeyedDefaultDict(self._unknown_item)
id_to_name_lookup_table.update({code: name for name, code in name_to_id_lookup_table.items()})
self._game_store[game] = collections.ChainMap(self._archipelago_lookup, id_to_name_lookup_table)
self._flat_store.update(id_to_name_lookup_table) # Only needed for legacy lookup method.
if game == "Archipelago":
# Keep track of the Archipelago data package separately so if it gets updated in a custom datapackage,
# it updates in all chain maps automatically.
@@ -356,7 +341,6 @@ class CommonContext:
self.item_names = self.NameLookupDict(self, "item")
self.location_names = self.NameLookupDict(self, "location")
self.versions = {}
self.checksums = {}
self.jsontotextparser = JSONtoTextParser(self)
@@ -413,7 +397,8 @@ class CommonContext:
await self.server.socket.close()
if self.server_task is not None:
await self.server_task
self.ui.update_hints()
if self.ui:
self.ui.update_hints()
async def send_msgs(self, msgs: typing.List[typing.Any]) -> None:
""" `msgs` JSON serializable """
@@ -570,7 +555,6 @@ class CommonContext:
# DataPackage
async def prepare_data_package(self, relevant_games: typing.Set[str],
remote_date_package_versions: typing.Dict[str, int],
remote_data_package_checksums: typing.Dict[str, str]):
"""Validate that all data is present for the current multiworld.
Download, assimilate and cache missing data from the server."""
@@ -579,33 +563,26 @@ class CommonContext:
needed_updates: typing.Set[str] = set()
for game in relevant_games:
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
if game not in remote_data_package_checksums:
continue
remote_version: int = remote_date_package_versions.get(game, 0)
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
if not remote_checksum: # custom data package and no checksum for this game
needed_updates.add(game)
continue
cached_version: int = self.versions.get(game, 0)
cached_checksum: typing.Optional[str] = self.checksums.get(game)
# no action required if cached version is new enough
if (not remote_checksum and (remote_version > cached_version or remote_version == 0)) \
or remote_checksum != cached_checksum:
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
if remote_checksum != cached_checksum:
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
if ((remote_checksum or remote_version <= local_version and remote_version != 0)
and remote_checksum == local_checksum):
if remote_checksum == local_checksum:
self.update_game(network_data_package["games"][game], game)
else:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
cache_version: int = cached_game.get("version", 0)
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
# download remote version if cache is not new enough
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
if remote_checksum != cache_checksum:
needed_updates.add(game)
else:
self.update_game(cached_game, game)
@@ -615,7 +592,6 @@ class CommonContext:
def update_game(self, game_package: dict, game: str):
self.item_names.update_game(game, game_package["item_name_to_id"])
self.location_names.update_game(game, game_package["location_name_to_id"])
self.versions[game] = game_package.get("version", 0)
self.checksums[game] = game_package.get("checksum")
def update_data_package(self, data_package: dict):
@@ -624,9 +600,6 @@ class CommonContext:
def consume_network_data_package(self, data_package: dict):
self.update_data_package(data_package)
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
current_cache.update(data_package["games"])
Utils.persistent_store("datapackage", "games", current_cache)
logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}")
for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data)
@@ -889,9 +862,8 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
# update data package
data_package_versions = args.get("datapackage_versions", {})
data_package_checksums = args.get("datapackage_checksums", {})
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
await ctx.prepare_data_package(set(args["games"]), data_package_checksums)
await ctx.server_auth(args['password'])
@@ -1128,7 +1100,7 @@ def run_as_textclient(*args):
args = handle_url_arg(args, parser=parser)
# use colorama to display colored text highlighting on windows
colorama.init()
colorama.just_fix_windows_console()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -261,7 +261,7 @@ if __name__ == '__main__':
parser = get_base_parser()
args = parser.parse_args()
colorama.init()
colorama.just_fix_windows_console()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -1,12 +0,0 @@
from __future__ import annotations
import ModuleUpdate
ModuleUpdate.update()
from worlds.factorio.Client import check_stdin, launch
import Utils
if __name__ == "__main__":
Utils.init_logging("FactorioClient", exception_logger="Client")
check_stdin()
launch()

370
Fill.py
View File

@@ -4,7 +4,7 @@ import logging
import typing
from collections import Counter, deque
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, PlandoItemBlock
from Options import Accessibility
from worlds.AutoWorld import call_all
@@ -75,9 +75,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
items_to_place.append(reachable_items[next_player].pop())
for item in items_to_place:
for p, pool_item in enumerate(item_pool):
# The items added into `reachable_items` are placed starting from the end of each deque in
# `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`.
for p, pool_item in enumerate(reversed(item_pool), start=1):
if pool_item is item:
item_pool.pop(p)
del item_pool[-p]
break
maximum_exploration_state = sweep_from_pool(
@@ -98,7 +100,7 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# if minimal accessibility, only check whether location is reachable if game not beatable
if multiworld.worlds[item_to_place.player].options.accessibility == Accessibility.option_minimal:
perform_access_check = not multiworld.has_beaten_game(maximum_exploration_state,
item_to_place.player) \
item_to_place.player) \
if single_player_placement else not has_beaten_game
else:
perform_access_check = True
@@ -240,7 +242,7 @@ def remaining_fill(multiworld: MultiWorld,
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
total = min(len(itempool), len(locations))
total = min(len(itempool), len(locations))
placed = 0
# Optimisation: Decide whether to do full location.can_fill check (respect excluded), or only check the item rule
@@ -341,17 +343,19 @@ def fast_fill(multiworld: MultiWorld,
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
maximum_exploration_state = sweep_from_pool(state, pool)
minimal_players = {player for player in multiworld.player_ids if multiworld.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [location for location in multiworld.get_locations() if location.player in minimal_players and
minimal_players = {player for player in multiworld.player_ids if
multiworld.worlds[player].options.accessibility == "minimal"}
unreachable_locations = [location for location in multiworld.get_locations() if
location.player in minimal_players and
not location.can_reach(maximum_exploration_state)]
for location in unreachable_locations:
if (location.item is not None and location.item.advancement and location.address is not None and not
location.locked and location.item.player not in minimal_players):
pool.append(location.item)
state.remove(location.item)
location.item = None
if location in state.advancements:
state.advancements.remove(location)
state.remove(location.item)
locations.append(location)
if pool and locations:
locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY)
@@ -363,7 +367,7 @@ def inaccessible_location_rules(multiworld: MultiWorld, state: CollectionState,
unreachable_locations = [location for location in locations if not location.can_reach(maximum_exploration_state)]
if unreachable_locations:
def forbid_important_item_rule(item: Item):
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != 'minimal')
return not ((item.classification & 0b0011) and multiworld.worlds[item.player].options.accessibility != "minimal")
for location in unreachable_locations:
add_item_rule(location, forbid_important_item_rule)
@@ -500,13 +504,15 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if prioritylocations:
# "priority fill"
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
maximum_exploration_state = sweep_from_pool(multiworld.state)
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority", one_item_per_player=True, allow_partial=True)
if prioritylocations:
# retry with one_item_per_player off because some priority fills can fail to fill with that optimization
fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool,
maximum_exploration_state = sweep_from_pool(multiworld.state)
fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool,
single_player_placement=single_player, swap=False, on_place=mark_for_locking,
name="Priority Retry", one_item_per_player=False)
accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool)
@@ -514,14 +520,15 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if progitempool:
# "advancement/progression fill"
maximum_exploration_state = sweep_from_pool(multiworld.state)
if panic_method == "swap":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True,
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True,
name="Progression", single_player_placement=single_player)
elif panic_method == "raise":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
name="Progression", single_player_placement=single_player)
elif panic_method == "start_inventory":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False,
fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False,
allow_partial=True, name="Progression", single_player_placement=single_player)
if progitempool:
for item in progitempool:
@@ -672,9 +679,9 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if multiworld.worlds[player].options.progression_balancing > 0
}
if not balanceable_players:
logging.info('Skipping multiworld progression balancing.')
logging.info("Skipping multiworld progression balancing.")
else:
logging.info(f'Balancing multiworld progression for {len(balanceable_players)} Players.')
logging.info(f"Balancing multiworld progression for {len(balanceable_players)} Players.")
logging.debug(balanceable_players)
state: CollectionState = CollectionState(multiworld)
checked_locations: typing.Set[Location] = set()
@@ -772,7 +779,7 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
if player in threshold_percentages):
break
elif not balancing_sphere:
raise RuntimeError('Not all required items reachable. Something went terribly wrong here.')
raise RuntimeError("Not all required items reachable. Something went terribly wrong here.")
# Gather a set of locations which we can swap items into
unlocked_locations: typing.Dict[int, typing.Set[Location]] = collections.defaultdict(set)
for l in unchecked_locations:
@@ -788,8 +795,8 @@ def balance_multiworld_progression(multiworld: MultiWorld) -> None:
testing = items_to_test.pop()
reducing_state = state.copy()
for location in itertools.chain((
l for l in items_to_replace
if l.item.player == player
l for l in items_to_replace
if l.item.player == player
), items_to_test):
reducing_state.collect(location.item, True, location)
@@ -862,52 +869,30 @@ def swap_location_item(location_1: Location, location_2: Location, check_locked:
location_2.item.location = location_2
def distribute_planned(multiworld: MultiWorld) -> None:
def warn(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure', 'none', False, 'warn', 'warning']:
logging.warning(f'{warning}')
def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlock]]:
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f'{warning}')
logging.debug(f"{warning}")
def failed(warning: str, force: typing.Union[bool, str]) -> None:
if force in [True, 'fail', 'failure']:
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
non_early_locations: typing.Dict[int, typing.List[str]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc.name)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc.name)
world_name_lookup = multiworld.world_name_lookup
block_value = typing.Union[typing.List[str], typing.Dict[str, typing.Any], str]
plando_blocks: typing.List[typing.Dict[str, typing.Any]] = []
player_ids = set(multiworld.player_ids)
plando_blocks: dict[int, list[PlandoItemBlock]] = dict()
player_ids: set[int] = set(multiworld.player_ids)
for player in player_ids:
for block in multiworld.plando_items[player]:
block['player'] = player
if 'force' not in block:
block['force'] = 'silent'
if 'from_pool' not in block:
block['from_pool'] = True
elif not isinstance(block['from_pool'], bool):
from_pool_type = type(block['from_pool'])
raise Exception(f'Plando "from_pool" has to be boolean, not {from_pool_type} for player {player}.')
if 'world' not in block:
target_world = False
else:
target_world = block['world']
plando_blocks[player] = []
for block in multiworld.worlds[player].options.plando_items:
new_block: PlandoItemBlock = PlandoItemBlock(player, block.from_pool, block.force)
target_world = block.world
if target_world is False or multiworld.players == 1: # target own world
worlds: typing.Set[int] = {player}
worlds: set[int] = {player}
elif target_world is True: # target any worlds besides own
worlds = set(multiworld.player_ids) - {player}
elif target_world is None: # target all worlds
@@ -917,172 +902,197 @@ def distribute_planned(multiworld: MultiWorld) -> None:
for listed_world in target_world:
if listed_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block['force'])
block.force)
continue
worlds.add(world_name_lookup[listed_world])
elif type(target_world) == int: # target world by slot number
if target_world not in range(1, multiworld.players + 1):
failed(
f"Cannot place item in world {target_world} as it is not in range of (1, {multiworld.players})",
block['force'])
block.force)
continue
worlds = {target_world}
else: # target world by slot name
if target_world not in world_name_lookup:
failed(f"Cannot place item to {target_world}'s world as that world does not exist.",
block['force'])
block.force)
continue
worlds = {world_name_lookup[target_world]}
block['world'] = worlds
new_block.worlds = worlds
items: block_value = []
if "items" in block:
items = block["items"]
if 'count' not in block:
block['count'] = False
elif "item" in block:
items = block["item"]
if 'count' not in block:
block['count'] = 1
else:
failed("You must specify at least one item to place items with plando.", block['force'])
continue
items: list[str] | dict[str, typing.Any] = block.items
if isinstance(items, dict):
item_list: typing.List[str] = []
item_list: list[str] = []
for key, value in items.items():
if value is True:
value = multiworld.itempool.count(multiworld.worlds[player].create_item(key))
item_list += [key] * value
items = item_list
if isinstance(items, str):
items = [items]
block['items'] = items
new_block.items = items
locations: block_value = []
if 'location' in block:
locations = block['location'] # just allow 'location' to keep old yamls compatible
elif 'locations' in block:
locations = block['locations']
locations: list[str] = block.locations
if isinstance(locations, str):
locations = [locations]
if isinstance(locations, dict):
location_list = []
for key, value in locations.items():
location_list += [key] * value
locations = location_list
locations_from_groups: list[str] = []
resolved_locations: list[Location] = []
for target_player in worlds:
world_locations = multiworld.get_unfilled_locations(target_player)
for group in multiworld.worlds[target_player].location_name_groups:
if group in locations:
locations_from_groups.extend(multiworld.worlds[target_player].location_name_groups[group])
resolved_locations.extend(location for location in world_locations
if location.name in [*locations, *locations_from_groups])
new_block.locations = sorted(dict.fromkeys(locations))
new_block.resolved_locations = sorted(set(resolved_locations))
count = block.count
if not count:
count = len(new_block.items)
if isinstance(count, int):
count = {"min": count, "max": count}
if "min" not in count:
count["min"] = 0
if "max" not in count:
count["max"] = len(new_block.items)
new_block.count = count
plando_blocks[player].append(new_block)
return plando_blocks
def resolve_early_locations_for_planned(multiworld: MultiWorld):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
swept_state = multiworld.state.copy()
swept_state.sweep_for_advancements()
reachable = frozenset(multiworld.get_reachable_locations(swept_state))
early_locations: dict[int, list[Location]] = collections.defaultdict(list)
non_early_locations: dict[int, list[Location]] = collections.defaultdict(list)
for loc in multiworld.get_unfilled_locations():
if loc in reachable:
early_locations[loc.player].append(loc)
else: # not reachable with swept state
non_early_locations[loc.player].append(loc)
for player in multiworld.plando_item_blocks:
removed = []
for block in multiworld.plando_item_blocks[player]:
locations = block.locations
resolved_locations = block.resolved_locations
worlds = block.worlds
if "early_locations" in locations:
locations.remove("early_locations")
for target_player in worlds:
locations += early_locations[target_player]
resolved_locations += early_locations[target_player]
if "non_early_locations" in locations:
locations.remove("non_early_locations")
for target_player in worlds:
locations += non_early_locations[target_player]
resolved_locations += non_early_locations[target_player]
block['locations'] = list(dict.fromkeys(locations))
if block.count["max"] > len(block.items):
count = block.count["max"]
failed(f"Plando count {count} greater than items specified", block.force)
block.count["max"] = len(block.items)
if block.count["min"] > len(block.items):
block.count["min"] = len(block.items)
if block.count["max"] > len(block.resolved_locations) > 0:
count = block.count["max"]
failed(f"Plando count {count} greater than locations specified", block.force)
block.count["max"] = len(block.resolved_locations)
if block.count["min"] > len(block.resolved_locations):
block.count["min"] = len(block.resolved_locations)
block.count["target"] = multiworld.random.randint(block.count["min"],
block.count["max"])
if not block['count']:
block['count'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if isinstance(block['count'], int):
block['count'] = {'min': block['count'], 'max': block['count']}
if 'min' not in block['count']:
block['count']['min'] = 0
if 'max' not in block['count']:
block['count']['max'] = (min(len(block['items']), len(block['locations'])) if
len(block['locations']) > 0 else len(block['items']))
if block['count']['max'] > len(block['items']):
count = block['count']
failed(f"Plando count {count} greater than items specified", block['force'])
block['count'] = len(block['items'])
if block['count']['max'] > len(block['locations']) > 0:
count = block['count']
failed(f"Plando count {count} greater than locations specified", block['force'])
block['count'] = len(block['locations'])
block['count']['target'] = multiworld.random.randint(block['count']['min'], block['count']['max'])
if not block.count["target"]:
removed.append(block)
if block['count']['target'] > 0:
plando_blocks.append(block)
for block in removed:
multiworld.plando_item_blocks[player].remove(block)
def distribute_planned_blocks(multiworld: MultiWorld, plando_blocks: list[PlandoItemBlock]):
def warn(warning: str, force: bool | str) -> None:
if isinstance(force, bool):
logging.warning(f"{warning}")
else:
logging.debug(f"{warning}")
def failed(warning: str, force: bool | str) -> None:
if force is True:
raise Exception(warning)
else:
warn(warning, force)
# shuffle, but then sort blocks by number of locations minus number of items,
# so less-flexible blocks get priority
multiworld.random.shuffle(plando_blocks)
plando_blocks.sort(key=lambda block: (len(block['locations']) - block['count']['target']
if len(block['locations']) > 0
else len(multiworld.get_unfilled_locations(player)) - block['count']['target']))
plando_blocks.sort(key=lambda block: (len(block.resolved_locations) - block.count["target"]
if len(block.resolved_locations) > 0
else len(multiworld.get_unfilled_locations(block.player)) -
block.count["target"]))
for placement in plando_blocks:
player = placement['player']
player = placement.player
try:
worlds = placement['world']
locations = placement['locations']
items = placement['items']
maxcount = placement['count']['target']
from_pool = placement['from_pool']
worlds = placement.worlds
locations = placement.resolved_locations
items = placement.items
maxcount = placement.count["target"]
from_pool = placement.from_pool
candidates = list(multiworld.get_unfilled_locations_for_players(locations, sorted(worlds)))
multiworld.random.shuffle(candidates)
multiworld.random.shuffle(items)
count = 0
err: typing.List[str] = []
successful_pairs: typing.List[typing.Tuple[int, Item, Location]] = []
claimed_indices: typing.Set[typing.Optional[int]] = set()
for item_name in items:
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((index_to_delete, item, location))
claimed_indices.add(index_to_delete)
candidates.remove(location)
count = count + 1
break
else:
err.append(f"Can't place item at {location} due to fill condition not met.")
else:
err.append(f"{item_name} not allowed at {location}.")
else:
err.append(f"Cannot place {item_name} into already filled location {location}.")
item_candidates = []
if from_pool:
instances = [item for item in multiworld.itempool if item.player == player and item.name in items]
for item in multiworld.random.sample(items, maxcount):
candidate = next((i for i in instances if i.name == item), None)
if candidate is None:
warn(f"Could not remove {item} from pool for {multiworld.player_name[player]} as "
f"it's already missing from it", placement.force)
candidate = multiworld.worlds[player].create_item(item)
else:
err.append(f"Mismatch between {item_name} and {location}, only one is an event.")
if count == maxcount:
break
if count < placement['count']['min']:
m = placement['count']['min']
failed(
f"Plando block failed to place {m - count} of {m} item(s) for {multiworld.player_name[player]}, error(s): {' '.join(err)}",
placement['force'])
# 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 index is not None: # If this item is from_pool and was found in the pool, remove it.
multiworld.itempool.pop(index)
multiworld.itempool.remove(candidate)
instances.remove(candidate)
item_candidates.append(candidate)
else:
item_candidates = [multiworld.worlds[player].create_item(item)
for item in multiworld.random.sample(items, maxcount)]
if any(item.code is None for item in item_candidates) \
and not all(item.code is None for item in item_candidates):
failed(f"Plando block for player {player} ({multiworld.player_name[player]}) contains both "
f"event items and non-event items. "
f"Event items: {[item for item in item_candidates if item.code is None]}, "
f"Non-event items: {[item for item in item_candidates if item.code is not None]}",
placement.force)
continue
else:
is_real = item_candidates[0].code is not None
candidates = [candidate for candidate in locations if candidate.item is None
and bool(candidate.address) == is_real]
multiworld.random.shuffle(candidates)
allstate = multiworld.get_all_state(False)
mincount = placement.count["min"]
allowed_margin = len(item_candidates) - mincount
fill_restrictive(multiworld, allstate, candidates, item_candidates, lock=True,
allow_partial=True, name="Plando Main Fill")
if len(item_candidates) > allowed_margin:
failed(f"Could not place {len(item_candidates)} "
f"of {mincount + allowed_margin} item(s) "
f"for {multiworld.player_name[player]}, "
f"remaining items: {item_candidates}",
placement.force)
if from_pool:
multiworld.itempool.extend([item for item in item_candidates if item.code is not None])
except Exception as e:
raise Exception(
f"Error running plando for player {player} ({multiworld.player_name[player]})") from e

View File

@@ -10,8 +10,8 @@ import sys
import urllib.parse
import urllib.request
from collections import Counter
from typing import Any, Dict, Tuple, Union
from itertools import chain
from typing import Any
import ModuleUpdate
@@ -54,12 +54,22 @@ def mystery_argparse():
parser.add_argument("--skip_output", action="store_true",
help="Skips generation assertion and output stages and skips multidata and spoiler output. "
"Intended for debugging and testing purposes.")
parser.add_argument("--spoiler_only", action="store_true",
help="Skips generation assertion and multidata, outputting only a spoiler log. "
"Intended for debugging and testing purposes.")
args = parser.parse_args()
if args.skip_output and args.spoiler_only:
parser.error("Cannot mix --skip_output and --spoiler_only")
elif args.spoiler == 0 and args.spoiler_only:
parser.error("Cannot use --spoiler_only when --spoiler=0. Use --skip_output or set --spoiler to a different value")
if not os.path.isabs(args.weights_file_path):
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
return args
@@ -67,7 +77,7 @@ def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None) -> Tuple[argparse.Namespace, int]:
def main(args=None) -> tuple[argparse.Namespace, int]:
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
if __name__ == "__main__" and "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded before logging init.")
@@ -85,7 +95,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
logging.info("Race mode enabled. Using non-deterministic random source.")
random.seed() # reset to time-based random source
weights_cache: Dict[str, Tuple[Any, ...]] = {}
weights_cache: dict[str, tuple[Any, ...]] = {}
if args.weights_file_path and os.path.exists(args.weights_file_path):
try:
weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path)
@@ -108,6 +118,8 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
raise Exception("Cannot mix --sameoptions with --meta")
else:
meta_weights = None
player_id = 1
player_files = {}
for file in os.scandir(args.player_files_path):
@@ -164,10 +176,11 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
erargs.outputpath = args.outputpath
erargs.skip_prog_balancing = args.skip_prog_balancing
erargs.skip_output = args.skip_output
erargs.spoiler_only = args.spoiler_only
erargs.name = {}
erargs.csv_output = args.csv_output
settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \
settings_cache: dict[str, tuple[argparse.Namespace, ...]] = \
{fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.sameoptions else None)
for fname, yamls in weights_cache.items()}
@@ -199,7 +212,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
path = player_path_cache[player]
if path:
try:
settings: Tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
settings: tuple[argparse.Namespace, ...] = settings_cache[path] if settings_cache[path] else \
tuple(roll_settings(yaml, args.plando) for yaml in weights_cache[path])
for settingsObject in settings:
for k, v in vars(settingsObject).items():
@@ -229,7 +242,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
return erargs, seed
def read_weights_yamls(path) -> Tuple[Any, ...]:
def read_weights_yamls(path) -> tuple[Any, ...]:
try:
if urllib.parse.urlparse(path).scheme in ('https', 'file'):
yaml = str(urllib.request.urlopen(path).read(), "utf-8-sig")
@@ -239,7 +252,20 @@ def read_weights_yamls(path) -> Tuple[Any, ...]:
except Exception as e:
raise Exception(f"Failed to read weights ({path})") from e
return tuple(parse_yamls(yaml))
from yaml.error import MarkedYAMLError
try:
return tuple(parse_yamls(yaml))
except MarkedYAMLError as ex:
if ex.problem_mark:
lines = yaml.splitlines()
if ex.context_mark:
relevant_lines = "\n".join(lines[ex.context_mark.line:ex.problem_mark.line+1])
else:
relevant_lines = lines[ex.problem_mark.line]
error_line = " " * ex.problem_mark.column + "^"
raise Exception(f"{ex.context} {ex.problem} on line {ex.problem_mark.line}:"
f"\n{relevant_lines}\n{error_line}")
raise ex
def interpret_on_off(value) -> bool:
@@ -279,33 +305,35 @@ def get_choice(option, root, value=None) -> Any:
raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.")
class SafeDict(dict):
def __missing__(self, key):
return '{' + key + '}'
class SafeFormatter(string.Formatter):
def get_value(self, key, args, kwargs):
if isinstance(key, int):
if key < len(args):
return args[key]
else:
return "{" + str(key) + "}"
else:
return kwargs.get(key, "{" + key + "}")
def handle_name(name: str, player: int, name_counter: Counter):
name_counter[name.lower()] += 1
number = name_counter[name.lower()]
new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")])
new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number,
NUMBER=(number if number > 1 else ''),
player=player,
PLAYER=(player if player > 1 else '')))
new_name = SafeFormatter().vformat(new_name, (), {"number": number,
"NUMBER": (number if number > 1 else ''),
"player": player,
"PLAYER": (player if player > 1 else '')})
# Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace.
# Could cause issues for some clients that cannot handle the additional whitespace.
new_name = new_name.strip()[:16].strip()
if new_name == "Archipelago":
raise Exception(f"You cannot name yourself \"{new_name}\"")
return new_name
def roll_percentage(percentage: Union[int, float]) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
def update_weights(weights: dict, new_weights: dict, update_type: str, name: str) -> dict:
logging.debug(f'Applying {new_weights}')
cleaned_weights = {}
@@ -350,7 +378,7 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
return weights
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
def roll_meta_option(option_key, game: str, category_dict: dict) -> Any:
from worlds import AutoWorldRegister
if not game:
@@ -371,7 +399,7 @@ def roll_linked_options(weights: dict) -> dict:
if "name" not in option_set:
raise ValueError("One of your linked options does not have a name.")
try:
if roll_percentage(option_set["percentage"]):
if Options.roll_percentage(option_set["percentage"]):
logging.debug(f"Linked option {option_set['name']} triggered.")
new_options = option_set["options"]
for category_name, category_options in new_options.items():
@@ -404,7 +432,7 @@ def roll_triggers(weights: dict, triggers: list, valid_keys: set) -> dict:
trigger_result = get_choice("option_result", option_set)
result = get_choice(key, currently_targeted_weights)
currently_targeted_weights[key] = result
if result == trigger_result and roll_percentage(get_choice("percentage", option_set, 100)):
if result == trigger_result and Options.roll_percentage(get_choice("percentage", option_set, 100)):
for category_name, category_options in option_set["options"].items():
currently_targeted_weights = weights
if category_name:
@@ -435,6 +463,14 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
"""
Roll options from specified weights, usually originating from a .yaml options file.
Important note:
The same weights dict is shared between all slots using the same yaml (e.g. generic weights file for filler slots).
This means it should never be modified without making a deepcopy first.
"""
from worlds import AutoWorldRegister
if "linked_options" in weights:
@@ -500,10 +536,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
handle_option(ret, game_weights, option_key, option, plando_options)
valid_keys.add(option_key)
# TODO remove plando_items after moving it to the options system
valid_keys.add("plando_items")
if PlandoOptions.items in plando_options:
ret.plando_items = copy.deepcopy(game_weights.get("plando_items", []))
if ret.game == "A Link to the Past":
# TODO there are still more LTTP options not on the options system
valid_keys |= {"sprite_pool", "sprite", "random_sprite_on_event"}

View File

@@ -1,16 +1,14 @@
"""
Archipelago launcher for bundled app.
Archipelago Launcher
* if run with APBP as argument, launch corresponding client.
* if run with executable as argument, run it passing argv[2:] as arguments
* if run without arguments, open launcher GUI
* If run with a patch file as argument, launch corresponding client with the patch file as an argument.
* If run with component name as argument, run it passing argv[2:] as arguments.
* If run without arguments or unknown arguments, open launcher GUI.
Scroll down to components= to add components to the launcher as well as setup.py
Additional components can be added to worlds.LauncherComponents.components.
"""
import argparse
import itertools
import logging
import multiprocessing
import shlex
@@ -18,12 +16,14 @@ import subprocess
import sys
import urllib.parse
import webbrowser
from collections.abc import Callable, Sequence
from os.path import isfile
from shutil import which
from typing import Callable, Optional, Sequence, Tuple, Union
from typing import Any
if __name__ == "__main__":
import ModuleUpdate
ModuleUpdate.update()
import settings
@@ -85,12 +85,16 @@ def browse_files():
def open_folder(folder_path):
if is_linux:
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, folder_path])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, folder_path])
else:
webbrowser.open(folder_path)
return
if exe:
subprocess.Popen([exe, folder_path])
else:
logging.warning(f"No file browser available to open {folder_path}")
def update_settings():
@@ -105,16 +109,17 @@ components.extend([
Component("Generate Template Options", func=generate_yamls),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("Unrated/18+ Discord Server", icon="discord",
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("Browse Files", func=browse_files),
])
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
def handle_uri(path: str, launch_args: tuple[str, ...]) -> None:
url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query)
launch_args = (path, *launch_args)
client_component = None
client_component = []
text_client_component = None
if "game" in queries:
game = queries["game"][0]
@@ -122,52 +127,43 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
game = "Archipelago"
for component in components:
if component.supports_uri and component.game_name == game:
client_component = component
client_component.append(component)
elif component.display_name == "Text Client":
text_client_component = component
if client_component is None:
from kvui import MDButton, MDButtonText
from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText
from kivymd.uix.divider import MDDivider
if not client_component:
run_component(text_client_component, *launch_args)
return
else:
popup_text = MDDialogSupportingText(text="Select client to open and connect with.")
component_buttons = [MDDivider()]
for component in [text_client_component, *client_component]:
component_buttons.append(MDButton(
MDButtonText(text=component.display_name),
on_release=lambda *args, comp=component: run_component(comp, *launch_args),
style="text"
))
component_buttons.append(MDDivider())
from kvui import App, Button, BoxLayout, Label, Window
MDDialog(
# Headline
MDDialogHeadlineText(text="Connect to Multiworld"),
# Text
popup_text,
# Content
MDDialogContentContainer(
*component_buttons,
orientation="vertical"
),
class Popup(App):
def __init__(self):
self.title = "Connect to Multiworld"
self.icon = r"data/icon.png"
super().__init__()
def build(self):
layout = BoxLayout(orientation="vertical")
layout.add_widget(Label(text="Select client to open and connect with."))
button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4))
text_client_button = Button(
text=text_client_component.display_name,
on_release=lambda *args: run_component(text_client_component, *launch_args)
)
button_row.add_widget(text_client_button)
game_client_button = Button(
text=client_component.display_name,
on_release=lambda *args: run_component(client_component, *launch_args)
)
button_row.add_widget(game_client_button)
layout.add_widget(button_row)
return layout
def _stop(self, *largs):
# see run_gui Launcher _stop comment for details
self.root_window.close()
super()._stop(*largs)
Popup().run()
).open()
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
def identify(path: None | str) -> tuple[None | str, None | Component]:
if path is None:
return None, None
for component in components:
@@ -178,7 +174,7 @@ def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Comp
return None, None
def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
def get_exe(component: str | Component) -> Sequence[str] | None:
if isinstance(component, str):
name = component
component = None
@@ -220,100 +216,189 @@ def launch(exe, in_terminal=False):
subprocess.Popen(exe)
refresh_components: Optional[Callable[[], None]] = None
def create_shortcut(button: Any, component: Component) -> None:
from pyshortcuts import make_shortcut
script = sys.argv[0]
wkdir = Utils.local_path()
script = f"{script} \"{component.display_name}\""
make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"),
startmenu=False, terminal=False, working_dir=wkdir)
button.menu.dismiss()
def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage
refresh_components: Callable[[], None] | None = None
def run_gui(path: str, args: Any) -> None:
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
from kivy.properties import ObjectProperty
from kivy.core.window import Window
from kivy.uix.relativelayout import RelativeLayout
from kivy.metrics import dp
from kivymd.uix.button import MDIconButton, MDButton
from kivymd.uix.card import MDCard
from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText
from kivymd.uix.textfield import MDTextField
class Launcher(App):
from kivy.lang.builder import Builder
class LauncherCard(MDCard):
component: Component | None
image: str
context_button: MDIconButton = ObjectProperty(None)
def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs):
self.component = component
self.image = image_path
super().__init__(args, kwargs)
class Launcher(ThemedApp):
base_title: str = "Archipelago Launcher"
container: ContainerLayout
grid: GridLayout
_tool_layout: Optional[ScrollBox] = None
_client_layout: Optional[ScrollBox] = None
top_screen: MDFloatLayout = ObjectProperty(None)
navigation: MDGridLayout = ObjectProperty(None)
grid: MDGridLayout = ObjectProperty(None)
button_layout: ScrollBox = ObjectProperty(None)
search_box: MDTextField = ObjectProperty(None)
cards: list[LauncherCard]
current_filter: Sequence[str | Type] | None
def __init__(self, ctx=None):
def __init__(self, ctx=None, path=None, args=None):
self.title = self.base_title + " " + Utils.__version__
self.ctx = ctx
self.icon = r"data/icon.png"
self.favorites = []
self.launch_uri = path
self.launch_args = args
self.cards = []
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
persistent = Utils.persistent_load()
if "launcher" in persistent:
if "favorites" in persistent["launcher"]:
self.favorites.extend(persistent["launcher"]["favorites"])
if "filter" in persistent["launcher"]:
if persistent["launcher"]["filter"]:
filters = []
for filter in persistent["launcher"]["filter"].split(", "):
if filter == "favorites":
filters.append(filter)
else:
filters.append(Type[filter])
self.current_filter = filters
super().__init__()
def _refresh_components(self) -> None:
def set_favorite(self, caller):
if caller.component.display_name in self.favorites:
self.favorites.remove(caller.component.display_name)
caller.icon = "star-outline"
else:
self.favorites.append(caller.component.display_name)
caller.icon = "star"
def build_button(component: Component) -> Widget:
def build_card(self, component: Component) -> LauncherCard:
"""
Builds a card widget for a given component.
:param component: The component associated with the button.
:return: The created Card Widget.
"""
Builds a button widget for a given component.
button_card = LauncherCard(component=component,
image_path=icon_paths[component.icon])
Args:
component (Component): The component associated with the button.
def open_menu(caller):
caller.menu.open()
Returns:
None. The button is added to the parent grid layout.
menu_items = [
{
"text": "Add shortcut on desktop",
"leading_icon": "laptop",
"on_release": lambda: create_shortcut(button_card.context_button, component)
}
]
button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items)
button_card.context_button.bind(on_release=open_menu)
"""
button = Button(text=component.display_name, size_hint_y=None, height=40)
button.component = component
button.bind(on_release=self.component_action)
if component.icon != "icon":
image = ApAsyncImage(source=icon_paths[component.icon],
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
box_layout = RelativeLayout(size_hint_y=None, height=40)
box_layout.add_widget(button)
box_layout.add_widget(image)
return box_layout
return button
return button_card
def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None:
if not type_filter:
type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC]
favorites = "favorites" in type_filter
# clear before repopulating
assert self._tool_layout and self._client_layout, "must call `build` first"
tool_children = reversed(self._tool_layout.layout.children)
assert self.button_layout, "must call `build` first"
tool_children = reversed(self.button_layout.layout.children)
for child in tool_children:
self._tool_layout.layout.remove_widget(child)
client_children = reversed(self._client_layout.layout.children)
for child in client_children:
self._client_layout.layout.remove_widget(child)
self.button_layout.layout.remove_widget(child)
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
cards = [card for card in self.cards if card.component.type in type_filter
or favorites and card.component.display_name in self.favorites]
for (tool, client) in itertools.zip_longest(itertools.chain(
_tools.items(), _miscs.items(), _adjusters.items()
), _clients.items()):
# column 1
if tool:
self._tool_layout.layout.add_widget(build_button(tool[1]))
# column 2
if client:
self._client_layout.layout.add_widget(build_button(client[1]))
self.current_filter = type_filter
for card in cards:
self.button_layout.layout.add_widget(card)
top = self.button_layout.children[0].y + self.button_layout.children[0].height \
- self.button_layout.height
scroll_percent = self.button_layout.convert_distance_to_scroll(0, top)
self.button_layout.scroll_y = max(0, min(1, scroll_percent[1]))
def filter_clients_by_type(self, caller: MDButton):
self._refresh_components(caller.type)
self.search_box.text = ""
def filter_clients_by_name(self, caller: MDTextField, name: str) -> None:
if len(name) == 0:
self._refresh_components(self.current_filter)
return
sub_matches = [
card for card in self.cards
if name.lower() in card.component.display_name.lower() and card.component.type != Type.HIDDEN
]
self.button_layout.layout.clear_widgets()
for card in sub_matches:
self.button_layout.layout.add_widget(card)
def build(self):
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
self._tool_layout = ScrollBox()
self._tool_layout.layout.orientation = "vertical"
self.grid.add_widget(self._tool_layout)
self._client_layout = ScrollBox()
self._client_layout.layout.orientation = "vertical"
self.grid.add_widget(self._client_layout)
self._refresh_components()
self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv"))
self.grid = self.top_screen.ids.grid
self.navigation = self.top_screen.ids.navigation
self.button_layout = self.top_screen.ids.button_layout
self.search_box = self.top_screen.ids.search_box
self.set_colors()
self.top_screen.md_bg_color = self.theme_cls.backgroundColor
global refresh_components
refresh_components = self._refresh_components
Window.bind(on_drop_file=self._on_drop_file)
Window.bind(on_keyboard=self._on_keyboard)
return self.container
for component in components:
self.cards.append(self.build_card(component))
self._refresh_components(self.current_filter)
# Uncomment to re-enable the Kivy console/live editor
# Ctrl-E to enable it, make sure numlock/capslock is disabled
# from kivy.modules.console import create_console
# create_console(Window, self.top_screen)
return self.top_screen
def on_start(self):
if self.launch_uri:
handle_uri(self.launch_uri, self.launch_args)
self.launch_uri = None
self.launch_args = None
@staticmethod
def component_action(button):
MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5},
size_hint_x=0.5).open()
if button.component.func:
button.component.func()
else:
@@ -327,13 +412,28 @@ def run_gui():
else:
logging.warning(f"unable to identify component for {file}")
def _on_keyboard(self, window: Window, key: int, scancode: int, codepoint: str, modifier: list[str]):
# Activate search as soon as we start typing, no matter if we are focused on the search box or not.
# Focus first, then capture the first character we type, otherwise it gets swallowed and lost.
# Limit text input to ASCII non-control characters (space bar to tilde).
if not self.search_box.focus:
self.search_box.focus = True
if key in range(32, 126):
self.search_box.text += codepoint
def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
# Closing the window explicitly cleans it up.
self.root_window.close()
super()._stop(*largs)
Launcher().run()
def on_stop(self):
Utils.persistent_store("launcher", "favorites", self.favorites)
Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter
for filter in self.current_filter))
super().on_stop()
Launcher(path=path, args=args).run()
# avoiding Launcher reference leak
# and don't try to do something with widgets after window closed
@@ -352,7 +452,7 @@ def run_component(component: Component, *args):
logging.warning(f"Component {component} does not appear to be executable.")
def main(args: Optional[Union[argparse.Namespace, dict]] = None):
def main(args: argparse.Namespace | dict | None = None):
if isinstance(args, argparse.Namespace):
args = {k: v for k, v in args._get_kwargs()}
elif not args:
@@ -360,16 +460,14 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
path = args.get("Patch|Game|Component|url", None)
if path is not None:
if path.startswith("archipelago://"):
handle_uri(path, args.get("args", ()))
return
file, component = identify(path)
if file:
args['file'] = file
if component:
args['component'] = component
if not component:
logging.warning(f"Could not identify Component responsible for {path}")
if not path.startswith("archipelago://"):
file, component = identify(path)
if file:
args['file'] = file
if component:
args['component'] = component
if not component:
logging.warning(f"Could not identify Component responsible for {path}")
if args["update_settings"]:
update_settings()
@@ -378,7 +476,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
elif "component" in args:
run_component(args["component"], *args["args"])
elif not args["update_settings"]:
run_gui()
run_gui(path, args.get("args", ()))
if __name__ == '__main__':
@@ -400,6 +498,7 @@ if __name__ == '__main__':
main(parser.parse_args())
from worlds.LauncherComponents import processes
for process in processes:
# we await all child processes to close before we tear down the process host
# this makes it feel like each one is its own program, as the Launcher is closed now

View File

@@ -26,12 +26,14 @@ import typing
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop)
from NetUtils import ClientStatus
from worlds.ladx import LinksAwakeningWorld
from worlds.ladx.Common import BASE_ID as LABaseID
from worlds.ladx.GpsTracker import GpsTracker
from worlds.ladx.TrackerConsts import storage_key
from worlds.ladx.ItemTracker import ItemTracker
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
from worlds.ladx.Tracker import LocationTracker, MagpieBridge, Check
class GameboyException(Exception):
@@ -50,22 +52,6 @@ class BadRetroArchResponse(GameboyException):
pass
def magpie_logo():
from kivy.uix.image import CoreImage
binary_data = """
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
binary_data = base64.b64decode(binary_data)
data = io.BytesIO(binary_data)
return CoreImage(data, ext="png").texture
class LAClientConstants:
# Connector version
VERSION = 0x01
@@ -100,19 +86,23 @@ class LAClientConstants:
WRamCheckSize = 0x4
WRamSafetyValue = bytearray([0]*WRamCheckSize)
wRamStart = 0xC000
hRamStart = 0xFF80
hRamSize = 0x80
MinGameplayValue = 0x06
MaxGameplayValue = 0x1A
VictoryGameplayAndSub = 0x0102
class RAGameboy():
cache = []
cache_start = 0
cache_size = 0
last_cache_read = None
socket = None
def __init__(self, address, port) -> None:
self.cache_start = LAClientConstants.wRamStart
self.cache_size = LAClientConstants.hRamStart + LAClientConstants.hRamSize - LAClientConstants.wRamStart
self.address = address
self.port = port
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@@ -131,9 +121,14 @@ class RAGameboy():
async def get_retroarch_status(self):
return await self.send_command("GET_STATUS")
def set_cache_limits(self, cache_start, cache_size):
self.cache_start = cache_start
self.cache_size = cache_size
def set_checks_range(self, checks_start, checks_size):
self.checks_start = checks_start
self.checks_size = checks_size
def set_location_range(self, location_start, location_size, critical_addresses):
self.location_start = location_start
self.location_size = location_size
self.critical_location_addresses = critical_addresses
def send(self, b):
if type(b) is str:
@@ -188,21 +183,57 @@ class RAGameboy():
if not await self.check_safe_gameplay():
return
cache = []
remaining_size = self.cache_size
while remaining_size:
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
remaining_size -= len(block)
cache += block
attempts = 0
while True:
# RA doesn't let us do an atomic read of a large enough block of RAM
# Some bytes can't change in between reading location_block and hram_block
location_block = await self.read_memory_block(self.location_start, self.location_size)
hram_block = await self.read_memory_block(LAClientConstants.hRamStart, LAClientConstants.hRamSize)
verification_block = await self.read_memory_block(self.location_start, self.location_size)
valid = True
for address in self.critical_location_addresses:
if location_block[address - self.location_start] != verification_block[address - self.location_start]:
valid = False
if valid:
break
attempts += 1
# Shouldn't really happen, but keep it from choking
if attempts > 5:
return
checks_block = await self.read_memory_block(self.checks_start, self.checks_size)
if not await self.check_safe_gameplay():
return
self.cache = cache
self.cache = bytearray(self.cache_size)
start = self.checks_start - self.cache_start
self.cache[start:start + len(checks_block)] = checks_block
start = self.location_start - self.cache_start
self.cache[start:start + len(location_block)] = location_block
start = LAClientConstants.hRamStart - self.cache_start
self.cache[start:start + len(hram_block)] = hram_block
self.last_cache_read = time.time()
async def read_memory_block(self, address: int, size: int):
block = bytearray()
remaining_size = size
while remaining_size:
chunk = await self.async_read_memory(address + len(block), remaining_size)
remaining_size -= len(chunk)
block += chunk
return block
async def read_memory_cache(self, addresses):
# TODO: can we just update once per frame?
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
await self.update_cache()
if not self.cache:
@@ -359,11 +390,12 @@ class LinksAwakeningClient():
auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode()
self.auth = auth
async def wait_and_init_tracker(self):
async def wait_and_init_tracker(self, magpie: MagpieBridge):
await self.wait_for_game_ready()
self.tracker = LocationTracker(self.gameboy)
self.item_tracker = ItemTracker(self.gameboy)
self.gps_tracker = GpsTracker(self.gameboy)
magpie.gps_tracker = self.gps_tracker
async def recved_item_from_ap(self, item_id, from_player, next_index):
# Don't allow getting an item until you've got your first check
@@ -405,9 +437,11 @@ class LinksAwakeningClient():
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
await self.gameboy.update_cache()
await self.tracker.readChecks(item_get_cb)
await self.item_tracker.readItems()
await self.gps_tracker.read_location()
await self.gps_tracker.read_entrances()
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
if self.deathlink_debounce and current_health != 0:
@@ -457,7 +491,7 @@ class LinksAwakeningContext(CommonContext):
la_task = None
client = None
# TODO: does this need to re-read on reset?
found_checks = []
found_checks = set()
last_resend = time.time()
magpie_enabled = False
@@ -465,6 +499,10 @@ class LinksAwakeningContext(CommonContext):
magpie_task = None
won = False
@property
def slot_storage_key(self):
return f"{self.slot_info[self.slot].name}_{storage_key}"
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None:
self.client = LinksAwakeningClient()
self.slot_data = {}
@@ -476,9 +514,9 @@ class LinksAwakeningContext(CommonContext):
def run_gui(self) -> None:
import webbrowser
import kvui
from kvui import Button, GameManager
from kivy.uix.image import Image
from kvui import GameManager
from kivy.metrics import dp
from kivymd.uix.button import MDButton, MDButtonText
class LADXManager(GameManager):
logging_pairs = [
@@ -491,23 +529,27 @@ class LinksAwakeningContext(CommonContext):
b = super().build()
if self.ctx.magpie_enabled:
button = Button(text="", size=(30, 30), size_hint_x=None,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
image = Image(size=(16, 16), texture=magpie_logo())
button.add_widget(image)
def set_center(_, center):
image.center = center
button.bind(center=set_center)
button = MDButton(MDButtonText(text="Open Tracker"), style="filled", size=(dp(100), dp(70)), radius=5,
size_hint_x=None, size_hint_y=None, pos_hint={"center_y": 0.55},
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
button.height = self.server_connect_bar.height
self.connect_layout.add_widget(button)
return b
self.ui = LADXManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def send_checks(self):
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
async def send_new_entrances(self, entrances: typing.Dict[str, str]):
# Store the entrances we find on the server for future sessions
message = [{
"cmd": "Set",
"key": self.slot_storage_key,
"default": {},
"want_reply": False,
"operations": [{"operation": "update", "value": entrances}],
}]
await self.send_msgs(message)
had_invalid_slot_data = None
@@ -537,13 +579,19 @@ class LinksAwakeningContext(CommonContext):
await self.send_msgs(message)
self.won = True
async def request_found_entrances(self):
await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}])
# Ask for updates so that players can co-op entrances in a seed
await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}])
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
if self.ENABLE_DEATHLINK:
self.client.pending_deathlink = True
def new_checks(self, item_ids, ladxr_ids):
self.found_checks += item_ids
create_task_log_exception(self.send_checks())
self.found_checks.update(item_ids)
create_task_log_exception(self.check_locations(self.found_checks))
if self.magpie_enabled:
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
@@ -571,16 +619,40 @@ class LinksAwakeningContext(CommonContext):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
self.slot_data = args.get("slot_data", {})
# This is sent to magpie over local websocket to make its own connection
self.slot_data.update({
"server_address": self.server_address,
"slot_name": self.player_names[self.slot],
"password": self.password,
})
# We can process linked items on already-checked checks now that we have slot_data
if self.client.tracker:
checked_checks = set(self.client.tracker.all_checks) - set(self.client.tracker.remaining_checks)
self.add_linked_items(checked_checks)
# TODO - use watcher_event
if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], start=args["index"]):
self.client.recvd_checks[index] = item
if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]:
self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key])
if cmd == "SetReply" and self.magpie_enabled and args["key"] == self.slot_storage_key:
self.client.gps_tracker.receive_found_entrances(args["value"])
async def sync(self):
sync_msg = [{'cmd': 'Sync'}]
await self.send_msgs(sync_msg)
def add_linked_items(self, checks: typing.List[Check]):
for check in checks:
if check.value and check.linkedItem:
linkedItem = check.linkedItem
if 'condition' not in linkedItem or (self.slot_data and linkedItem['condition'](self.slot_data)):
self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty'])
item_id_lookup = get_locations_to_id()
async def run_game_loop(self):
@@ -589,6 +661,8 @@ class LinksAwakeningContext(CommonContext):
checkMetadataTable[check.id])] for check in ladxr_checks]
self.new_checks(checks, [check.id for check in ladxr_checks])
self.add_linked_items(ladxr_checks)
async def victory():
await self.send_victory()
@@ -622,21 +696,38 @@ class LinksAwakeningContext(CommonContext):
if not self.client.recvd_checks:
await self.sync()
await self.client.wait_and_init_tracker()
await self.client.wait_and_init_tracker(self.magpie)
min_tick_duration = 0.1
last_tick = time.time()
while True:
await self.client.main_tick(on_item_get, victory, deathlink)
await asyncio.sleep(0.1)
now = time.time()
tick_duration = now - last_tick
sleep_duration = max(min_tick_duration - tick_duration, 0)
await asyncio.sleep(sleep_duration)
last_tick = now
if self.last_resend + 5.0 < now:
self.last_resend = now
await self.send_checks()
await self.check_locations(self.found_checks)
if self.magpie_enabled:
try:
self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker)
self.magpie.slot_data = self.slot_data
if self.slot_data and "slot_data" in self.magpie.features and not self.magpie.has_sent_slot_data:
self.magpie.slot_data = self.slot_data
await self.magpie.send_slot_data()
if self.client.gps_tracker.needs_found_entrances:
await self.request_found_entrances()
self.client.gps_tracker.needs_found_entrances = False
new_entrances = await self.magpie.send_gps(self.client.gps_tracker)
if new_entrances:
await self.send_new_entrances(new_entrances)
except Exception:
# Don't let magpie errors take out the client
pass
@@ -647,8 +738,8 @@ class LinksAwakeningContext(CommonContext):
await asyncio.sleep(1.0)
def run_game(romfile: str) -> None:
auto_start = typing.cast(typing.Union[bool, str],
Utils.get_options()["ladx_options"].get("rom_start", True))
auto_start = LinksAwakeningWorld.settings.rom_start
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
@@ -705,6 +796,6 @@ async def main():
await ctx.shutdown()
if __name__ == '__main__':
colorama.init()
colorama.just_fix_windows_console()
asyncio.run(main())
colorama.deinit()

View File

@@ -370,7 +370,7 @@ if __name__ == "__main__":
import colorama
colorama.init()
colorama.just_fix_windows_console()
asyncio.run(main())
colorama.deinit()

67
Main.py
View File

@@ -7,14 +7,13 @@ import tempfile
import time
import zipfile
import zlib
from typing import Dict, List, Optional, Set, Tuple, Union
import worlds
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, distribute_planned, \
flood_items
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from Fill import FillError, balance_multiworld_progression, distribute_items_restrictive, flood_items, \
parse_planned_blocks, distribute_planned_blocks, resolve_early_locations_for_planned
from Options import StartInventoryPool
from Utils import __version__, output_path, version_tuple, get_settings
from Utils import __version__, output_path, version_tuple
from settings import get_settings
from worlds import AutoWorld
from worlds.generic.Rules import exclusion_rules, locality_rules
@@ -22,7 +21,7 @@ from worlds.generic.Rules import exclusion_rules, locality_rules
__all__ = ["main"]
def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None):
def main(args, seed=None, baked_server_options: dict[str, object] | None = None):
if not baked_server_options:
baked_server_options = get_settings().server_options.as_dict()
assert isinstance(baked_server_options, dict)
@@ -37,9 +36,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger = logging.getLogger()
multiworld.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
multiworld.plando_options = args.plando_options
multiworld.plando_items = args.plando_items.copy()
multiworld.plando_texts = args.plando_texts.copy()
multiworld.plando_connections = args.plando_connections.copy()
multiworld.game = args.game.copy()
multiworld.player_name = args.name.copy()
multiworld.sprite = args.sprite.copy()
@@ -56,32 +52,18 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
max_item = 0
max_location = 0
for cls in AutoWorld.AutoWorldRegister.world_types.values():
if cls.item_id_to_name:
max_item = max(max_item, max(cls.item_id_to_name))
max_location = max(max_location, max(cls.location_id_to_name))
item_digits = len(str(max_item))
location_digits = len(str(max_location))
item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values())))
del max_item, max_location
for name, cls in AutoWorld.AutoWorldRegister.world_types.items():
if not cls.hidden and len(cls.item_names) > 0:
logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} "
f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - "
f"{max(cls.item_id_to_name):{item_digits}}) | "
f"{len(cls.location_names):{location_count}} "
f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - "
f"{max(cls.location_id_to_name):{location_digits}})")
logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | "
f"Locations: {len(cls.location_names):{location_count}}")
del item_digits, location_digits, item_count, location_count
del item_count, location_count
# This assertion method should not be necessary to run if we are not outputting any multidata.
if not args.skip_output:
if not args.skip_output and not args.spoiler_only:
AutoWorld.call_stage(multiworld, "assert_generate")
AutoWorld.call_all(multiworld, "generate_early")
@@ -149,13 +131,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
multiworld.worlds[1].options.non_local_items.value = set()
multiworld.worlds[1].options.local_items.value = set()
multiworld.plando_item_blocks = parse_planned_blocks(multiworld)
AutoWorld.call_all(multiworld, "connect_entrances")
AutoWorld.call_all(multiworld, "generate_basic")
# remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible.
fallback_inventory = StartInventoryPool({})
depletion_pool: Dict[int, Dict[str, int]] = {
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
}
@@ -164,7 +148,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
}
if target_per_player:
new_itempool: List[Item] = []
new_itempool: list[Item] = []
# Make new itempool with start_inventory_from_pool items removed
for item in multiworld.itempool:
@@ -193,8 +177,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
multiworld._all_state = None
logger.info("Running Item Plando.")
distribute_planned(multiworld)
resolve_early_locations_for_planned(multiworld)
distribute_planned_blocks(multiworld, [x for player in multiworld.plando_item_blocks
for x in multiworld.plando_item_blocks[player]])
logger.info('Running Pre Main Fill.')
@@ -224,6 +209,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info(f'Beginning output...')
outfilebase = 'AP_' + multiworld.seed_name
if args.spoiler_only:
if args.spoiler > 1:
logger.info('Calculating playthrough.')
multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2)
multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase))
logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start)
return multiworld
output = tempfile.TemporaryDirectory()
with output as temp_dir:
output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__
@@ -238,7 +232,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
pool.submit(AutoWorld.call_single, multiworld, "generate_output", player, temp_dir))
# collect ER hint info
er_hint_data: Dict[int, Dict[int, str]] = {}
er_hint_data: dict[int, dict[int, str]] = {}
AutoWorld.call_all(multiworld, 'extend_hint_information', er_hint_data)
def write_multidata():
@@ -279,7 +273,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in multiworld.groups[location.item.player]["players"]:
precollected_hints[player].add(hint)
locations_data: Dict[int, Dict[int, Tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
locations_data: dict[int, dict[int, tuple[int, int, int]]] = {player: {} for player in multiworld.player_ids}
for location in multiworld.get_filled_locations():
if type(location.address) == int:
assert location.item.code is not None, "item code None should be event, " \
@@ -306,13 +300,14 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
game_world.game: worlds.network_data_package["games"][game_world.game]
for game_world in multiworld.worlds.values()
}
data_package["Archipelago"] = worlds.network_data_package["games"]["Archipelago"]
checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {}
checks_in_area: dict[int, dict[str, int | list[int]]] = {}
# get spheres -> filter address==None -> skip empty
spheres: List[Dict[int, Set[int]]] = []
spheres: list[dict[int, set[int]]] = []
for sphere in multiworld.get_sendable_spheres():
current_sphere: Dict[int, Set[int]] = collections.defaultdict(set)
current_sphere: dict[int, set[int]] = collections.defaultdict(set)
for sphere_location in sphere:
current_sphere[sphere_location.player].add(sphere_location.address)

View File

@@ -46,8 +46,9 @@ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, Networ
SlotType, LocationStore, Hint, HintStatus
from BaseClasses import ItemClassification
min_client_version = Version(0, 1, 6)
colorama.init()
min_client_version = Version(0, 5, 0)
colorama.just_fix_windows_console()
def remove_from_list(container, value):
@@ -66,9 +67,13 @@ def pop_from_container(container, value):
return container
def update_dict(dictionary, entries):
dictionary.update(entries)
return dictionary
def update_container_unique(container, entries):
if isinstance(container, list):
existing_container_as_set = set(container)
container.extend([entry for entry in entries if entry not in existing_container_as_set])
else:
container.update(entries)
return container
def queue_gc():
@@ -109,7 +114,7 @@ modify_functions = {
# lists/dicts:
"remove": remove_from_list,
"pop": pop_from_container,
"update": update_dict,
"update": update_container_unique,
}
@@ -1821,7 +1826,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.clients[team][slot].append(client)
client.version = args['version']
client.tags = args['tags']
client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags
client.no_locations = bool(client.tags & _non_game_messages.keys())
# set NoText for old PopTracker clients that predate the tag to save traffic
client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1))
connected_packet = {
@@ -1895,7 +1900,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
old_tags = client.tags
client.tags = args["tags"]
if set(old_tags) != set(client.tags):
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
client.no_locations = bool(client.tags & _non_game_messages.keys())
client.no_text = "NoText" in client.tags or (
"PopTracker" in client.tags and client.version < (0, 5, 1)
)
@@ -1978,14 +1983,21 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
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)
concerning_slots = ctx.slot_set(hint.receiving_player) | {hint.finding_player}
for slot in concerning_slots:
ctx.replace_hint(client.team, slot, hint, new_hint)
ctx.save()
ctx.on_changed_hints(client.team, hint.finding_player)
ctx.on_changed_hints(client.team, hint.receiving_player)
for slot in concerning_slots:
ctx.on_changed_hints(client.team, slot)
elif cmd == 'StatusUpdate':
update_client_status(ctx, client, args["status"])
if client.no_locations and args["status"] == ClientStatus.CLIENT_GOAL:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd",
"text": "Trackers can't register Goal Complete",
"original_cmd": cmd}])
else:
update_client_status(ctx, client, args["status"])
elif cmd == 'Say':
if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable():
@@ -2037,7 +2049,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
value = func(value, operation["value"])
ctx.stored_data[args["key"]] = args["value"] = value
targets = set(ctx.stored_data_notification_clients[args["key"]])
if args.get("want_reply", True):
if args.get("want_reply", False):
targets.add(client)
if targets:
ctx.broadcast(targets, [args])
@@ -2356,7 +2368,6 @@ class ServerCommandProcessor(CommonCommandProcessor):
known_options = (f"{option}: {option_type}" for option, option_type in self.ctx.simple_options.items())
self.output(f"Unrecognized option '{option_name}', known: {', '.join(known_options)}")
return False
if value_type == bool:
def value_type(input_text: str):
return input_text.lower() not in {"off", "0", "false", "none", "null", "no"}
@@ -2390,6 +2401,75 @@ class ServerCommandProcessor(CommonCommandProcessor):
f"approximately totaling {Utils.format_SI_prefix(total, power=1024)}B")
self.output("\n".join(texts))
def _cmd_discord_webhook(self, webhook_url: str):
"""Needs to be supplied with a Discord WebHook url as parameter,
which will then relay the server log to a discord channel."""
import discord_webhook
initial_response = discord_webhook.DiscordWebhook(webhook_url, wait=True,
content="Beginning Discord Logging").execute()
if initial_response.ok:
import queue
response_queue = queue.SimpleQueue()
class Emitter(threading.Thread):
def run(self):
record: typing.Optional[logging.LogRecord] = None
while True:
time.sleep(1)
# check for leftover record from last iteration
message = record.msg if record else ""
while 1:
try:
record = response_queue.get_nowait()
except queue.Empty:
break
else:
if record is None:
return # shutdown
if len(record.msg) > 1999:
continue # content size limit
if len(message) + len(record.msg) > 2000:
break # reached content size limit in total
else:
message += "\n" + record.msg
record = None
if message:
try:
response = discord_webhook.DiscordWebhook(
webhook_url, rate_limit_retry=True, content=message.strip()).execute()
if response.status_code not in (200, 204):
shutdown()
logging.info(f"Disabled Discord WebHook due to error code {response.status_code}.")
return
# just in case to prevent an error-loop logging itself
except Exception as e:
shutdown()
logging.error("Disabled Discord WebHook due to error.")
logging.exception(e)
return
emitter = Emitter()
emitter.daemon = True
emitter.start()
class DiscordLogger(logging.Handler):
"""Logs to Discord WebHook"""
def emit(self, record: logging.LogRecord):
response_queue.put(record)
handler = DiscordLogger()
def shutdown():
response_queue.put(None)
logging.getLogger().removeHandler(handler)
logging.getLogger().addHandler(handler)
self.output("Discord Link established.")
else:
self.output("Discord Link could not be established. Check your webhook url.")
async def console(ctx: Context):
import sys
@@ -2412,8 +2492,10 @@ async def console(ctx: Context):
def parse_args() -> argparse.Namespace:
from settings import get_settings
parser = argparse.ArgumentParser()
defaults = Utils.get_settings()["server_options"].as_dict()
defaults = get_settings().server_options.as_dict()
parser.add_argument('multidata', nargs="?", default=defaults["multidata"])
parser.add_argument('--host', default=defaults["host"])
parser.add_argument('--port', default=defaults["port"], type=int)

View File

@@ -346,7 +346,7 @@ if __name__ == '__main__':
import colorama
colorama.init()
colorama.just_fix_windows_console()
asyncio.run(main())
colorama.deinit()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import abc
import collections
import functools
import logging
import math
@@ -23,6 +24,12 @@ if typing.TYPE_CHECKING:
import pathlib
def roll_percentage(percentage: int | float) -> bool:
"""Roll a percentage chance.
percentage is expected to be in range [0, 100]"""
return random.random() < (float(percentage) / 100)
class OptionError(ValueError):
pass
@@ -866,15 +873,49 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mappin
def __len__(self) -> int:
return self.value.__len__()
# __getitem__ fallback fails for Counters, so we define this explicitly
def __contains__(self, item) -> bool:
return item in self.value
class ItemDict(OptionDict):
class OptionCounter(OptionDict):
min: int | None = None
max: int | None = None
def __init__(self, value: dict[str, int]) -> None:
super(OptionCounter, self).__init__(collections.Counter(value))
def verify(self, world: type[World], player_name: str, plando_options: PlandoOptions) -> None:
super(OptionCounter, self).verify(world, player_name, plando_options)
range_errors = []
if self.max is not None:
range_errors += [
f"\"{key}: {value}\" is higher than maximum allowed value {self.max}."
for key, value in self.value.items() if value > self.max
]
if self.min is not None:
range_errors += [
f"\"{key}: {value}\" is lower than minimum allowed value {self.min}."
for key, value in self.value.items() if value < self.min
]
if range_errors:
range_errors = [f"For option {getattr(self, 'display_name', self)}:"] + range_errors
raise OptionError("\n".join(range_errors))
class ItemDict(OptionCounter):
verify_item_name = True
def __init__(self, value: typing.Dict[str, int]):
if any(item_count is None for item_count in value.values()):
raise Exception("Items must have counts associated with them. Please provide positive integer values in the format \"item\": count .")
if any(item_count < 1 for item_count in value.values()):
raise Exception("Cannot have non-positive item counts.")
min = 0
def __init__(self, value: dict[str, int]) -> None:
# Backwards compatibility: Cull 0s to make "in" checks behave the same as when this wasn't a OptionCounter
value = {item_name: amount for item_name, amount in value.items() if amount != 0}
super(ItemDict, self).__init__(value)
@@ -984,7 +1025,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
if isinstance(data, typing.Iterable):
for text in data:
if isinstance(text, typing.Mapping):
if random.random() < float(text.get("percentage", 100)/100):
if roll_percentage(text.get("percentage", 100)):
at = text.get("at", None)
if at is not None:
if isinstance(at, dict):
@@ -1010,7 +1051,7 @@ class PlandoTexts(Option[typing.List[PlandoText]], VerifyKeys):
else:
raise OptionError("\"at\" must be a valid string or weighted list of strings!")
elif isinstance(text, PlandoText):
if random.random() < float(text.percentage/100):
if roll_percentage(text.percentage):
texts.append(text)
else:
raise Exception(f"Cannot create plando text from non-dictionary type, got {type(text)}")
@@ -1134,7 +1175,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
for connection in data:
if isinstance(connection, typing.Mapping):
percentage = connection.get("percentage", 100)
if random.random() < float(percentage / 100):
if roll_percentage(percentage):
entrance = connection.get("entrance", None)
if is_iterable_except_str(entrance):
entrance = random.choice(sorted(entrance))
@@ -1152,7 +1193,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
percentage
))
elif isinstance(connection, PlandoConnection):
if random.random() < float(connection.percentage / 100):
if roll_percentage(connection.percentage):
value.append(connection)
else:
raise Exception(f"Cannot create connection from non-Dict type, got {type(connection)}.")
@@ -1257,42 +1298,47 @@ class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing
accessibility: Accessibility
def as_dict(self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
def as_dict(
self,
*option_names: str,
casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
toggles_as_bools: bool = False,
) -> dict[str, typing.Any]:
"""
Returns a dictionary of [str, Option.value]
:param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
:param toggles_as_bools: whether toggle options should be output as bools instead of strings
:param option_names: Names of the options to get the values of.
:param casing: Casing of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`.
:param toggles_as_bools: Whether toggle options should be returned as bools instead of ints.
:return: A dictionary of each option name to the value of its Option. If the option is an OptionSet, the value
will be returned as a sorted list.
"""
assert option_names, "options.as_dict() was used without any option names."
option_results = {}
for option_name in option_names:
if option_name in type(self).type_hints:
if casing == "snake":
display_name = option_name
elif casing == "camel":
split_name = [name.title() for name in option_name.split("_")]
split_name[0] = split_name[0].lower()
display_name = "".join(split_name)
elif casing == "pascal":
display_name = "".join([name.title() for name in option_name.split("_")])
elif casing == "kebab":
display_name = option_name.replace("_", "-")
else:
raise ValueError(f"{casing} is invalid casing for as_dict. "
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
else:
if option_name not in type(self).type_hints:
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
if casing == "snake":
display_name = option_name
elif casing == "camel":
split_name = [name.title() for name in option_name.split("_")]
split_name[0] = split_name[0].lower()
display_name = "".join(split_name)
elif casing == "pascal":
display_name = "".join([name.title() for name in option_name.split("_")])
elif casing == "kebab":
display_name = option_name.replace("_", "-")
else:
raise ValueError(f"{casing} is invalid casing for as_dict. "
"Valid names are 'snake', 'camel', 'pascal', 'kebab'.")
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
value = bool(value)
option_results[display_name] = value
return option_results
@@ -1313,6 +1359,7 @@ class StartInventory(ItemDict):
verify_item_name = True
display_name = "Start Inventory"
rich_text_doc = True
max = 10000
class StartInventoryPool(StartInventory):
@@ -1428,6 +1475,131 @@ class ItemLinks(OptionList):
link["item_pool"] = list(pool)
@dataclass(frozen=True)
class PlandoItem:
items: list[str] | dict[str, typing.Any]
locations: list[str]
world: int | str | bool | None | typing.Iterable[str] | set[int] = False
from_pool: bool = True
force: bool | typing.Literal["silent"] = "silent"
count: int | bool | dict[str, int] = False
percentage: int = 100
class PlandoItems(Option[typing.List[PlandoItem]]):
"""Generic items plando."""
default = ()
supports_weighting = False
display_name = "Plando Items"
def __init__(self, value: typing.Iterable[PlandoItem]) -> None:
self.value = list(deepcopy(value))
super().__init__()
@classmethod
def from_any(cls, data: typing.Any) -> Option[typing.List[PlandoItem]]:
if not isinstance(data, typing.Iterable):
raise OptionError(f"Cannot create plando items from non-Iterable type, got {type(data)}")
value: typing.List[PlandoItem] = []
for item in data:
if isinstance(item, typing.Mapping):
percentage = item.get("percentage", 100)
if not isinstance(percentage, int):
raise OptionError(f"Plando `percentage` has to be int, not {type(percentage)}.")
if not (0 <= percentage <= 100):
raise OptionError(f"Plando `percentage` has to be between 0 and 100 (inclusive) not {percentage}.")
if roll_percentage(percentage):
count = item.get("count", False)
items = item.get("items", [])
if not items:
items = item.get("item", None) # explicitly throw an error here if not present
if not items:
raise OptionError("You must specify at least one item to place items with plando.")
count = 1
if isinstance(items, str):
items = [items]
elif not isinstance(items, (dict, list)):
raise OptionError(f"Plando 'items' has to be string, list, or "
f"dictionary, not {type(items)}")
locations = item.get("locations", [])
if not locations:
locations = item.get("location", ["Everywhere"])
if locations:
count = 1
if isinstance(locations, str):
locations = [locations]
if not isinstance(locations, list):
raise OptionError(f"Plando `location` has to be string or list, not {type(locations)}")
world = item.get("world", False)
from_pool = item.get("from_pool", True)
force = item.get("force", "silent")
if not isinstance(from_pool, bool):
raise OptionError(f"Plando 'from_pool' has to be true or false, not {from_pool!r}.")
if not (isinstance(force, bool) or force == "silent"):
raise OptionError(f"Plando `force` has to be true or false or `silent`, not {force!r}.")
value.append(PlandoItem(items, locations, world, from_pool, force, count, percentage))
elif isinstance(item, PlandoItem):
if roll_percentage(item.percentage):
value.append(item)
else:
raise OptionError(f"Cannot create plando item from non-Dict type, got {type(item)}.")
return cls(value)
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if not self.value:
return
from BaseClasses import PlandoOptions
if not (PlandoOptions.items & plando_options):
# plando is disabled but plando options were given so overwrite the options
self.value = []
logging.warning(f"The plando items module is turned off, "
f"so items for {player_name} will be ignored.")
else:
# filter down item groups
for plando in self.value:
# confirm a valid count
if isinstance(plando.count, dict):
if "min" in plando.count and "max" in plando.count:
if plando.count["min"] > plando.count["max"]:
raise OptionError("Plando cannot have count `min` greater than `max`.")
items_copy = plando.items.copy()
if isinstance(plando.items, dict):
for item in items_copy:
if item in world.item_name_groups:
value = plando.items.pop(item)
group = world.item_name_groups[item]
filtered_items = sorted(group.difference(list(plando.items.keys())))
if not filtered_items:
raise OptionError(f"Plando `items` contains the group \"{item}\" "
f"and every item in it. This is not allowed.")
if value is True:
for key in filtered_items:
plando.items[key] = True
else:
for key in random.choices(filtered_items, k=value):
plando.items[key] = plando.items.get(key, 0) + 1
else:
assert isinstance(plando.items, list) # pycharm can't figure out the hinting without the hint
for item in items_copy:
if item in world.item_name_groups:
plando.items.remove(item)
plando.items.extend(sorted(world.item_name_groups[item]))
@classmethod
def get_option_name(cls, value: list[PlandoItem]) -> str:
return ", ".join(["(%s: %s)" % (item.items, item.locations) for item in value]) #TODO: see what a better way to display would be
def __getitem__(self, index: typing.SupportsIndex) -> PlandoItem:
return self.value.__getitem__(index)
def __iter__(self) -> typing.Iterator[PlandoItem]:
yield from self.value
def __len__(self) -> int:
return len(self.value)
class Removed(FreeText):
"""This Option has been Removed."""
rich_text_doc = True
@@ -1450,6 +1622,7 @@ class PerGameCommonOptions(CommonOptions):
exclude_locations: ExcludeLocations
priority_locations: PriorityLocations
item_links: ItemLinks
plando_items: PlandoItems
@dataclass
@@ -1579,6 +1752,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
player_output = {
"Game": multiworld.game[player],
"Name": multiworld.get_player_name(player),
"ID": player,
}
output.append(player_output)
for option_key, option in world.options_dataclass.type_hints.items():
@@ -1591,7 +1765,7 @@ def dump_player_options(multiworld: MultiWorld) -> None:
game_option_names.append(display_name)
with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file:
fields = ["Game", "Name", *all_option_names]
fields = ["ID", "Game", "Name", *all_option_names]
writer = DictWriter(file, fields)
writer.writeheader()
writer.writerows(output)

View File

@@ -9,7 +9,6 @@ Currently, the following games are supported:
* Factorio
* Minecraft
* Subnautica
* Slay the Spire
* Risk of Rain 2
* The Legend of Zelda: Ocarina of Time
* Timespinner
@@ -63,7 +62,6 @@ Currently, the following games are supported:
* TUNIC
* Kirby's Dream Land 3
* Celeste 64
* Zork Grand Inquisitor
* Castlevania 64
* A Short Hike
* Yoshi's Island
@@ -80,6 +78,8 @@ Currently, the following games are supported:
* Saving Princess
* Castlevania: Circle of the Moon
* Inscryption
* Civilization VI
* The Legend of Zelda: The Wind Waker
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

@@ -735,6 +735,6 @@ async def main() -> None:
if __name__ == '__main__':
colorama.init()
colorama.just_fix_windows_console()
asyncio.run(main())
colorama.deinit()

View File

@@ -500,7 +500,7 @@ def main():
import colorama
colorama.init()
colorama.just_fix_windows_console()
asyncio.run(_main())
colorama.deinit()

View File

@@ -47,7 +47,7 @@ class Version(typing.NamedTuple):
return ".".join(str(item) for item in self)
__version__ = "0.6.0"
__version__ = "0.6.2"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -114,6 +114,8 @@ def cache_self1(function: typing.Callable[[S, T], RetType]) -> typing.Callable[[
cache[arg] = res
return res
wrap.__defaults__ = function.__defaults__
return wrap
@@ -137,8 +139,11 @@ def local_path(*path: str) -> str:
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
else:
import __main__
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
if globals().get("__file__") and os.path.isfile(__file__):
# we are running in a normal Python environment
local_path.cached_path = os.path.dirname(os.path.abspath(__file__))
elif hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
# we are running in a normal Python environment, but AP was imported weirdly
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
else:
# pray
@@ -427,6 +432,9 @@ class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module: str, name: str) -> type:
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# used by OptionCounter
if module == "collections" and name == "Counter":
return collections.Counter
# used by MultiServer -> savegame/multidata
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
"SlotType", "NetworkSlot", "HintStatus"}:
@@ -443,7 +451,8 @@ class RestrictedUnpickler(pickle.Unpickler):
else:
mod = importlib.import_module(module)
obj = getattr(mod, name)
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection)):
if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection,
self.options_module.PlandoText)):
return obj
# Forbid everything else.
raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden")
@@ -629,6 +638,8 @@ def get_fuzzy_results(input_word: str, word_list: typing.Collection[str], limit:
import jellyfish
def get_fuzzy_ratio(word1: str, word2: str) -> float:
if word1 == word2:
return 1.01
return (1 - jellyfish.damerau_levenshtein_distance(word1.lower(), word2.lower())
/ max(len(word1), len(word2)))
@@ -649,8 +660,10 @@ def get_intended_text(input_text: str, possible_answers) -> typing.Tuple[str, bo
picks = get_fuzzy_results(input_text, possible_answers, limit=2)
if len(picks) > 1:
dif = picks[0][1] - picks[1][1]
if picks[0][1] == 100:
if picks[0][1] == 101:
return picks[0][0], True, "Perfect Match"
elif picks[0][1] == 100:
return picks[0][0], True, "Case Insensitive Perfect Match"
elif picks[0][1] < 75:
return picks[0][0], False, f"Didn't find something that closely matches '{input_text}', " \
f"did you mean '{picks[0][0]}'? ({picks[0][1]}% sure)"

View File

@@ -214,17 +214,11 @@ class WargrooveContext(CommonContext):
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager, HoverBehavior, ServerToolTip
from kivy.uix.tabbedpanel import TabbedPanelItem
from kivymd.uix.tab import MDTabsItem, MDTabsItemText
from kivy.lang import Builder
from kivy.uix.button import Button
from kivy.uix.togglebutton import ToggleButton
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.image import AsyncImage, Image
from kivy.uix.stacklayout import StackLayout
from kivy.uix.label import Label
from kivy.properties import ColorProperty
from kivy.uix.image import Image
import pkgutil
class TrackerLayout(BoxLayout):
@@ -446,6 +440,6 @@ if __name__ == '__main__':
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.init()
colorama.just_fix_windows_console()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -28,6 +28,6 @@ def get_seeds():
response.append({
"seed_id": seed.id,
"creation_time": seed.creation_time,
"players": get_players(seed.slots),
"players": get_players(seed),
})
return jsonify(response)

View File

@@ -9,7 +9,7 @@ from threading import Event, Thread
from typing import Any
from uuid import UUID
from pony.orm import db_session, select, commit
from pony.orm import db_session, select, commit, PrimaryKey
from Utils import restricted_loads
from .locker import Locker, AlreadyRunningException
@@ -36,12 +36,21 @@ def handle_generation_failure(result: BaseException):
logging.exception(e)
def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None:
from setproctitle import setproctitle
setproctitle(f"Generator ({sid})")
res = gen_game(gen_options, meta=meta, owner=owner, sid=sid)
setproctitle(f"Generator (idle)")
return res
def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
try:
meta = json.loads(generation.meta)
options = restricted_loads(generation.options)
logging.info(f"Generating {generation.id} for {len(options)} players")
pool.apply_async(gen_game, (options,),
pool.apply_async(_mp_gen_game, (options,),
{"meta": meta,
"sid": generation.id,
"owner": generation.owner},
@@ -55,6 +64,10 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
def init_generator(config: dict[str, Any]) -> None:
from setproctitle import setproctitle
setproctitle("Generator (idle)")
try:
import resource
except ModuleNotFoundError:

View File

@@ -227,6 +227,9 @@ def set_up_logging(room_id) -> logging.Logger:
def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue):
from setproctitle import setproctitle
setproctitle(name)
Utils.init_logging(name)
try:
import resource
@@ -247,8 +250,23 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
raise Exception("Worlds system should not be loaded in the custom server.")
import gc
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
del cert_file, cert_key_file, ponyconfig
if not cert_file:
def get_ssl_context():
return None
else:
load_date = None
ssl_context = load_server_cert(cert_file, cert_key_file)
def get_ssl_context():
nonlocal load_date, ssl_context
today = datetime.date.today()
if load_date != today:
ssl_context = load_server_cert(cert_file, cert_key_file)
load_date = today
return ssl_context
del ponyconfig
gc.collect() # free intermediate objects used during setup
loop = asyncio.get_event_loop()
@@ -263,12 +281,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
assert ctx.server is None
try:
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context)
functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context())
await ctx.server
except OSError: # likely port in use
ctx.server = websockets.serve(
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context)
functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context())
await ctx.server
port = 0

View File

@@ -135,6 +135,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
{"bosses", "items", "connections", "texts"}))
erargs.skip_prog_balancing = False
erargs.skip_output = False
erargs.spoiler_only = False
erargs.csv_output = False
name_counter = Counter()

View File

@@ -35,6 +35,12 @@ def start_playing():
@app.route('/games/<string:game>/info/<string:lang>')
@cache.cached()
def game_info(game, lang):
try:
world = AutoWorldRegister.world_types[game]
if lang not in world.web.game_info_languages:
raise KeyError("Sorry, this game's info page is not available in that language yet.")
except KeyError:
return abort(404)
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
@@ -52,6 +58,12 @@ def games():
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
@cache.cached()
def tutorial(game, file, lang):
try:
world = AutoWorldRegister.world_types[game]
if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]:
raise KeyError("Sorry, the tutorial is not available in that language yet.")
except KeyError:
return abort(404)
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))

View File

@@ -6,7 +6,7 @@ from typing import Dict, Union
from docutils.core import publish_parts
import yaml
from flask import redirect, render_template, request, Response
from flask import redirect, render_template, request, Response, abort
import Options
from Utils import local_path
@@ -108,7 +108,7 @@ def option_presets(game: str) -> Response:
f"Expected {option.special_range_names.keys()} or {option.range_start}-{option.range_end}."
presets[preset_name][preset_option_name] = option.value
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.ItemDict)):
elif isinstance(option, (Options.Range, Options.OptionSet, Options.OptionList, Options.OptionCounter)):
presets[preset_name][preset_option_name] = option.value
elif isinstance(preset_option, str):
# Ensure the option value is valid for Choice and Toggle options
@@ -142,7 +142,10 @@ def weighted_options_old():
@app.route("/games/<string:game>/weighted-options")
@cache.cached()
def weighted_options(game: str):
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
try:
return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True)
except KeyError:
return abort(404)
@app.route("/games/<string:game>/generate-weighted-yaml", methods=["POST"])
@@ -197,7 +200,10 @@ def generate_weighted_yaml(game: str):
@app.route("/games/<string:game>/player-options")
@cache.cached()
def player_options(game: str):
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
try:
return render_options_page("playerOptions/playerOptions.html", game, is_complex=False)
except KeyError:
return abort(404)
# YAML generator for player-options
@@ -216,7 +222,7 @@ def generate_yaml(game: str):
for key, val in options.copy().items():
key_parts = key.rsplit("||", 2)
# Detect and build ItemDict options from their name pattern
# Detect and build OptionCounter options from their name pattern
if key_parts[-1] == "qty":
if key_parts[0] not in options:
options[key_parts[0]] = {}

View File

@@ -1,11 +1,12 @@
flask>=3.0.3
werkzeug>=3.0.6
flask>=3.1.0
werkzeug>=3.1.3
pony>=0.7.19
waitress>=3.0.0
waitress>=3.0.2
Flask-Caching>=2.3.0
Flask-Compress>=1.15
Flask-Limiter>=3.8.0
bokeh>=3.5.2
markupsafe>=2.1.5
Flask-Compress>=1.17
Flask-Limiter>=3.12
bokeh>=3.6.3
markupsafe>=3.0.2
Markdown>=3.7
mdx-breakless-lists>=1.0.1
setproctitle>=1.3.5

View File

@@ -23,7 +23,6 @@ window.addEventListener('load', () => {
showdown.setOption('strikethrough', true);
showdown.setOption('literalMidWordUnderscores', true);
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
// Reset the id of all header divs to something nicer
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
@@ -42,10 +41,5 @@ window.addEventListener('load', () => {
scrollTarget?.scrollIntoView();
}
});
}).catch((error) => {
console.error(error);
gameInfo.innerHTML =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}">here</a> to return to safety.</h3>`;
});
});

View File

@@ -6,6 +6,4 @@ window.addEventListener('load', () => {
document.getElementById('file-input').addEventListener('change', () => {
document.getElementById('host-game-form').submit();
});
adjustFooterHeight();
});

View File

@@ -1,47 +0,0 @@
const adjustFooterHeight = () => {
// If there is no footer on this page, do nothing
const footer = document.getElementById('island-footer');
if (!footer) { return; }
// If the body is taller than the window, also do nothing
if (document.body.offsetHeight > window.innerHeight) {
footer.style.marginTop = '0';
return;
}
// Add a margin-top to the footer to position it at the bottom of the screen
const sibling = footer.previousElementSibling;
const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight);
if (margin < 1) {
footer.style.marginTop = '0';
return;
}
footer.style.marginTop = `${margin}px`;
};
const adjustHeaderWidth = () => {
// If there is no header, do nothing
const header = document.getElementById('base-header');
if (!header) { return; }
const tempDiv = document.createElement('div');
tempDiv.style.width = '100px';
tempDiv.style.height = '100px';
tempDiv.style.overflow = 'scroll';
tempDiv.style.position = 'absolute';
tempDiv.style.top = '-500px';
document.body.appendChild(tempDiv);
const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth;
document.body.removeChild(tempDiv);
const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement;
const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0;
document.getElementById('base-header-right').style.marginRight = `${margin}px`;
};
window.addEventListener('load', () => {
window.addEventListener('resize', adjustFooterHeight);
window.addEventListener('resize', adjustHeaderWidth);
adjustFooterHeight();
adjustHeaderWidth();
});

View File

@@ -25,7 +25,6 @@ window.addEventListener('load', () => {
showdown.setOption('literalMidWordUnderscores', true);
showdown.setOption('disableForced4SpacesIndentedSublists', true);
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
adjustHeaderWidth();
const title = document.querySelector('h1')
if (title) {
@@ -49,10 +48,5 @@ window.addEventListener('load', () => {
scrollTarget?.scrollIntoView();
}
});
}).catch((error) => {
console.error(error);
tutorialWrapper.innerHTML =
`<h2>This page is out of logic!</h2>
<h3>Click <a href="${window.location.origin}/tutorial">here</a> to return to safety.</h3>`;
});
});

View File

@@ -36,6 +36,13 @@ html{
body{
margin: 0;
display: flex;
flex-direction: column;
min-height: calc(100vh - 110px);
}
main {
flex-grow: 1;
}
a{

View File

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

View File

@@ -1,5 +1,6 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %}
<title>Page Not Found (404)</title>
@@ -13,5 +14,4 @@
The page you're looking for doesn&apos;t exist.<br />
<a href="/">Click here to return to safety.</a>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'pageWrapper.html' %}
{% set show_footer = True %}
{% block head %}
<title>Upload Multidata</title>
@@ -16,7 +17,9 @@
This page allows you to host a game which was not generated by the website. For example, if you have
generated a game on your own computer, you may upload the zip file created by the generator to
host the game here. This will also provide a tracker, and the ability for your players to download
their patch files.
their patch files if the game is core-verified. For Custom Games, you can find the patch files in
the output .zip file you are uploading here. You need to manually distribute those patch files to
your players.
</p>
<p>In addition to the zip file created by the generator, you may upload a multidata file here as well.</p>
<div id="host-game-form-wrapper">
@@ -27,6 +30,4 @@
</div>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'pageWrapper.html' %}
{% set show_footer = True %}
{% block head %}
<title>Archipelago</title>
@@ -57,5 +58,4 @@
</div>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -5,26 +5,29 @@
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tooltip.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/cookieNotice.css") }}" />
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/globalStyles.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/styleController.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/cookieNotice.js") }}"></script>
{% block head %}
<title>Archipelago</title>
{% endblock %}
</head>
<body>
<main>
{% with messages = get_flashed_messages() %}
{% if messages %}
<div>
{% for message in messages | unique %}
<div class="user-message">{{ message }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% with messages = get_flashed_messages() %}
{% if messages %}
<div>
{% for message in messages | unique %}
<div class="user-message">{{ message }}</div>
{% endfor %}
</div>
{% block body %}
{% endblock %}
</main>
{% if show_footer %}
{% include "islandFooter.html" %}
{% endif %}
{% endwith %}
{% block body %}
{% endblock %}
</body>
</html>

View File

@@ -111,10 +111,19 @@
</div>
{% endmacro %}
{% macro ItemDict(option_name, option) %}
{% macro OptionCounter(option_name, option) %}
{% set relevant_keys = option.valid_keys %}
{% if not relevant_keys %}
{% if option.verify_item_name %}
{% set relevant_keys = world.item_names %}
{% elif option.verify_location_name %}
{% set relevant_keys = world.location_names %}
{% endif %}
{% endif %}
{{ OptionTitle(option_name, option) }}
<div class="option-container">
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
<div class="option-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input type="number" id="{{ option_name }}-{{ item_name }}-qty" name="{{ option_name }}||{{ item_name }}||qty" value="{{ option.default[item_name]|default("0") }}" data-option-name="{{ option_name }}" data-item-name="{{ item_name }}" />
@@ -213,7 +222,7 @@
{% endmacro %}
{% macro RandomizeButton(option_name, option) %}
<div class="randomize-button" data-tooltip="Toggle randomization for this option!">
<div class="randomize-button" data-tooltip="Pick a random value for this option.">
<label for="random-{{ option_name }}">
<input type="checkbox" id="random-{{ option_name }}" name="random-{{ option_name }}" class="randomize-checkbox" data-option-name="{{ option_name }}" {{ "checked" if option.default == "random" }} />
🎲

View File

@@ -93,8 +93,10 @@
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option) }}
{% elif issubclass(option, Options.OptionCounter) and (
option.valid_keys or option.verify_item_name or option.verify_location_name
) %}
{{ inputs.OptionCounter(option_name, option) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
@@ -133,8 +135,10 @@
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option) }}
{% elif issubclass(option, Options.OptionCounter) and (
option.valid_keys or option.verify_item_name or option.verify_location_name
) %}
{{ inputs.OptionCounter(option_name, option) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}

View File

@@ -1,5 +1,6 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %}
<title>Generation failed, please retry.</title>
@@ -15,5 +16,4 @@
{{ seed_error }}
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -1,4 +1,5 @@
{% extends 'pageWrapper.html' %}
{% set show_footer = True %}
{% block head %}
<title>Start Playing</title>
@@ -26,6 +27,4 @@
</p>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

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

View File

@@ -29,7 +29,8 @@
<div id="user-content-wrapper" class="markdown">
<div id="user-content" class="grass-island">
<h1>User Content</h1>
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.
Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.<br/>
Sessions can be saved or synced across devices using the <a href="{{url_for('show_session')}}">Sessions Page.</a>
<h2>Your Rooms</h2>
{% if rooms %}

View File

@@ -1,5 +1,6 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %}
<title>View Seed {{ seed.id|suuid }}</title>
@@ -50,5 +51,4 @@
</table>
</div>
</div>
{% include 'islandFooter.html' %}
{% endblock %}

View File

@@ -1,9 +1,12 @@
{% extends 'pageWrapper.html' %}
{% import "macros.html" as macros %}
{% set show_footer = True %}
{% block head %}
<title>Generation in Progress</title>
<meta http-equiv="refresh" content="1">
<noscript>
<meta http-equiv="refresh" content="1">
</noscript>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/waitSeed.css") }}"/>
{% endblock %}
@@ -15,5 +18,34 @@
Waiting for game to generate, this page auto-refreshes to check.
</div>
</div>
{% include 'islandFooter.html' %}
<script>
const waitSeedDiv = document.getElementById("wait-seed");
async function checkStatus() {
try {
const response = await fetch("{{ url_for('api.wait_seed_api', seed=seed_id) }}");
if (response.status !== 202) {
// Seed is ready; reload page to load seed page.
location.reload();
return;
}
const data = await response.json();
waitSeedDiv.innerHTML = `
<h1>Generation in Progress</h1>
<p>${data.text}</p>
`;
setTimeout(checkStatus, 1000); // Continue polling.
} catch (error) {
waitSeedDiv.innerHTML = `
<h1>Progress Unknown</h1>
<p>${error.message}<br />(Last checked: ${new Date().toLocaleTimeString()})</p>
`;
setTimeout(checkStatus, 1000);
}
}
setTimeout(checkStatus, 1000);
</script>
{% endblock %}

View File

@@ -113,9 +113,18 @@
{{ TextChoice(option_name, option) }}
{% endmacro %}
{% macro ItemDict(option_name, option, world) %}
{% macro OptionCounter(option_name, option, world) %}
{% set relevant_keys = option.valid_keys %}
{% if not relevant_keys %}
{% if option.verify_item_name %}
{% set relevant_keys = world.item_names %}
{% elif option.verify_location_name %}
{% set relevant_keys = world.location_names %}
{% endif %}
{% endif %}
<div class="dict-container">
{% for item_name in (option.valid_keys|sort if (option.valid_keys|length > 0) else world.item_names|sort) %}
{% for item_name in (relevant_keys if relevant_keys is ordered else relevant_keys|sort) %}
<div class="dict-entry">
<label for="{{ option_name }}-{{ item_name }}-qty">{{ item_name }}</label>
<input

View File

@@ -83,8 +83,10 @@
{% elif issubclass(option, Options.FreeText) %}
{{ inputs.FreeText(option_name, option) }}
{% elif issubclass(option, Options.ItemDict) and option.verify_item_name %}
{{ inputs.ItemDict(option_name, option, world) }}
{% elif issubclass(option, Options.OptionCounter) and (
option.valid_keys or option.verify_item_name or option.verify_location_name
) %}
{{ inputs.OptionCounter(option_name, option, world) }}
{% elif issubclass(option, Options.OptionList) and option.valid_keys %}
{{ inputs.OptionList(option_name, option) }}
@@ -100,7 +102,7 @@
{% else %}
<div class="unsupported-option">
This option is not supported. Please edit your .yaml file manually.
This option cannot be modified here. Please edit your .yaml file manually.
</div>
{% endif %}

View File

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

View File

@@ -386,7 +386,7 @@ if __name__ == '__main__':
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a Archipelago Binary Patch file')
args = parser.parse_args()
colorama.init()
colorama.just_fix_windows_console()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -14,23 +14,60 @@
salmon: "FA8072" # typically trap item
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
orange: "FF7700" # Used for command echo
<Label>:
color: "FFFFFF"
<TabbedPanel>:
tab_width: root.width / app.tab_count
# KivyMD theming parameters
theme_style: "Dark" # Light/Dark
primary_palette: "Lightsteelblue" # Many options
dynamic_scheme_name: "VIBRANT"
dynamic_scheme_contrast: 0.0
<MDLabel>:
color: self.theme_cls.primaryColor
<BaseButton>:
ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2
<MDTabsItemBase>:
ripple_color: app.theme_cls.primaryColor
ripple_duration_in_fast: 0.2
<TooltipLabel>:
text_size: self.width, None
size_hint_y: None
height: self.texture_size[1]
font_size: dp(20)
adaptive_height: True
theme_font_size: "Custom"
font_size: "20dp"
markup: True
halign: "left"
<SelectableLabel>:
size_hint: 1, None
theme_text_color: "Custom"
text_color: 1, 1, 1, 1
canvas.before:
Color:
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1)
rgba: (self.theme_cls.primaryColor[0], self.theme_cls.primaryColor[1], self.theme_cls.primaryColor[2], .3) if self.selected else self.theme_cls.surfaceContainerLowestColor
Rectangle:
size: self.size
pos: self.pos
<MarkupDropdownItem>
orientation: "vertical"
MDLabel:
text: root.text
valign: "center"
padding_x: "12dp"
shorten: True
shorten_from: "right"
theme_text_color: "Custom"
markup: True
text_color:
app.theme_cls.onSurfaceVariantColor \
if not root.text_color else \
root.text_color
MDDivider:
md_bg_color:
( \
app.theme_cls.outlineVariantColor \
if not root.divider_color \
else root.divider_color \
) \
if root.divider else \
(0, 0, 0, 0)
<UILog>:
messages: 1000 # amount of messages stored in client logs.
cols: 1
@@ -49,7 +86,7 @@
<HintLabel>:
canvas.before:
Color:
rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1) if self.striped else (0.18, 0.18, 0.18, 1)
rgba: (.0, 0.9, .1, .3) if self.selected else self.theme_cls.surfaceContainerHighColor if self.striped else self.theme_cls.surfaceContainerLowColor
Rectangle:
size: self.size
pos: self.pos
@@ -126,9 +163,12 @@
<ToolTip>:
size: self.texture_size
size_hint: None, None
theme_font_size: "Custom"
font_size: dp(18)
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
halign: "left"
theme_text_color: "Custom"
text_color: (1, 1, 1, 1)
canvas.before:
Color:
rgba: 0.2, 0.2, 0.2, 1
@@ -147,8 +187,38 @@
rectangle: self.x-2, self.y-2, self.width+4, self.height+4
<ServerToolTip>:
pos_hint: {'center_y': 0.5, 'center_x': 0.5}
<AutocompleteHintInput>
<AutocompleteHintInput>:
size_hint_y: None
height: dp(30)
height: "30dp"
multiline: False
write_tab: False
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<ConnectBarTextInput>:
height: "30dp"
multiline: False
write_tab: False
role: "medium"
size_hint_y: None
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<CommandPromptTextInput>:
size_hint_y: None
height: "30dp"
multiline: False
write_tab: False
pos_hint: {"center_x": 0.5, "center_y": 0.5}
<MessageBoxLabel>:
theme_text_color: "Custom"
text_color: 1, 1, 1, 1
<ScrollBox>:
layout: layout
bar_width: "12dp"
scroll_wheel_distance: 40
do_scroll_x: False
scroll_type: ['bars', 'content']
MDBoxLayout:
id: layout
orientation: "vertical"
spacing: 10
size_hint_y: None
height: self.minimum_height

161
data/launcher.kv Normal file
View File

@@ -0,0 +1,161 @@
<LauncherCard>:
id: main
style: "filled"
padding: "4dp"
size_hint: 1, None
height: "75dp"
context_button: context
focus_behavior: False
MDRelativeLayout:
ApAsyncImage:
source: main.image
size: (48, 48)
size_hint: None, None
pos_hint: {"center_x": 0.1, "center_y": 0.5}
MDLabel:
text: main.component.display_name
pos_hint:{"center_x": 0.5, "center_y": 0.75 if main.component.description else 0.65}
halign: "center"
font_style: "Title"
role: "medium"
theme_text_color: "Custom"
text_color: app.theme_cls.primaryColor
MDLabel:
text: main.component.description
pos_hint: {"center_x": 0.5, "center_y": 0.35}
halign: "center"
role: "small"
theme_text_color: "Custom"
text_color: app.theme_cls.primaryColor
MDIconButton:
component: main.component
icon: "star" if self.component.display_name in app.favorites else "star-outline"
style: "standard"
pos_hint:{"center_x": 0.85, "center_y": 0.8}
theme_text_color: "Custom"
text_color: app.theme_cls.primaryColor
detect_visible: False
on_release: app.set_favorite(self)
MDIconButton:
id: context
icon: "menu"
style: "standard"
pos_hint:{"center_x": 0.95, "center_y": 0.8}
theme_text_color: "Custom"
text_color: app.theme_cls.primaryColor
detect_visible: False
MDButton:
pos_hint:{"center_x": 0.9, "center_y": 0.25}
size_hint_y: None
height: "25dp"
component: main.component
on_release: app.component_action(self)
detect_visible: False
MDButtonText:
text: "Open"
#:import Type worlds.LauncherComponents.Type
MDFloatLayout:
id: top_screen
MDGridLayout:
id: grid
cols: 2
spacing: "5dp"
padding: "10dp"
MDGridLayout:
id: navigation
cols: 1
size_hint_x: 0.25
MDButton:
id: all
style: "text"
type: (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "asterisk"
MDButtonText:
text: "All"
MDButton:
id: client
style: "text"
type: (Type.CLIENT, )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "controller"
MDButtonText:
text: "Client"
MDButton:
id: Tool
style: "text"
type: (Type.TOOL, )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "desktop-classic"
MDButtonText:
text: "Tool"
MDButton:
id: adjuster
style: "text"
type: (Type.ADJUSTER, )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "wrench"
MDButtonText:
text: "Adjuster"
MDButton:
id: misc
style: "text"
type: (Type.MISC, )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "dots-horizontal-circle-outline"
MDButtonText:
text: "Misc"
MDButton:
id: favorites
style: "text"
type: ("favorites", )
on_release: app.filter_clients_by_type(self)
MDButtonIcon:
icon: "star"
MDButtonText:
text: "Favorites"
MDNavigationDrawerDivider:
MDGridLayout:
id: main_layout
cols: 1
spacing: "10dp"
MDTextField:
id: search_box
mode: "outlined"
set_text: app.filter_clients_by_name
MDTextFieldLeadingIcon:
icon: "magnify"
MDTextFieldHintText:
text: "Search"
ScrollBox:
id: button_layout

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -45,6 +45,9 @@
# ChecksFinder
/worlds/checksfinder/ @SunCatMC
# Civilization VI
/worlds/civ6/ @hesto2
# Clique
/worlds/clique/ @ThePhar
@@ -181,9 +184,6 @@
# Secret of Evermore
/worlds/soe/ @black-sliver
# Slay the Spire
/worlds/spire/ @KonoTyran
# Stardew Valley
/worlds/stardew_valley/ @agilbert1412
@@ -211,6 +211,9 @@
# Wargroove
/worlds/wargroove/ @FlySniper
# The Wind Waker
/worlds/tww/ @tanjo3
# The Witness
/worlds/witness/ @NewSoupVi @blastron
@@ -226,10 +229,6 @@
# Zillion
/worlds/zillion/ @beauxq
# Zork Grand Inquisitor
/worlds/zork_grand_inquisitor/ @nbrochu
## Active Unmaintained Worlds
# The following worlds in this repo are currently unmaintained, but currently still work in core. If any update breaks

View File

@@ -1,5 +1,8 @@
# Adding Games
Like all contributions to Archipelago, New Game implementations should follow the [Contributing](/docs/contributing.md)
guide.
Adding a new game to Archipelago has two major parts:
* Game Modification to communicate with Archipelago server (hereafter referred to as "client")
@@ -13,30 +16,51 @@ it will not be detailed here.
The client is an intermediary program between the game and the Archipelago server. This can either be a direct
modification to the game, an external program, or both. This can be implemented in nearly any modern language, but it
must fulfill a few requirements in order to function as expected. The specific requirements the game client must follow
to behave as expected are:
must fulfill a few requirements in order to function as expected. Libraries for most modern languages and the spec for
various packets can be found in the [network protocol](/docs/network%20protocol.md) API reference document.
### Hard Requirements
In order for the game client to behave as expected, it must be able to perform these functions:
* Handle both secure and unsecure websocket connections
* Detect and react when a location has been "checked" by the player by sending a network packet to the server
* Receive and parse network packets when the player receives an item from the server, and reward it to the player on
demand
* **Any** of your items can be received any number of times, up to and far surpassing those that the game might
normally expect from features such as starting inventory, item link replacement, or item cheating
* Players and the admin can cheat items to the player at any time with a server command, and these items may not have
a player or location attributed to them
* Reconnect if the connection is unstable and lost while playing
* Be able to change the port for saved connection info
* Rooms hosted on the website attempt to reserve their port, but since there are a limited number of ports, this
privilege can be lost, requiring the room to be moved to a new port
* Reconnect if the connection is unstable and lost while playing
* Keep an index for items received in order to resync. The ItemsReceived Packets are a single list with guaranteed
order.
* Receive items that were sent to the player while they were not connected to the server
* The player being able to complete checks while offline and sending them when reconnecting is a good bonus, but not
strictly required
privilege can be lost, requiring the room to be moved to a new port
* Send a status update packet alerting the server that the player has completed their goal
Libraries for most modern languages and the spec for various packets can be found in the
[network protocol](/docs/network%20protocol.md) API reference document.
Regarding items and locations, the game client must be able to handle these tasks:
#### Location Handling
Send a network packet to the server when it detects a location has been "checked" by the player in-game.
* If actions were taken in game that would usually trigger a location check, and those actions can only ever be taken
once, but the client was not connected when they happened: The client must send those location checks on connection
so that they are not permanently lost, e.g. by reading flags in the game state or save file.
#### Item Handling
Receive and parse network packets from the server when the player receives an item.
* It must reward items to the player on demand, as items can come from other players at any time.
* It must be able to reward copies of an item, up to and beyond the number the game normally expects. This may happen
due to features such as starting inventory, item link replacement, admin commands, or item cheating. **Any** of
your items can be received **any** number of times.
* Admins and players may use server commands to create items without a player or location attributed to them. The
client must be able to handle these items.
* It must keep an index for items received in order to resync. The ItemsReceived Packets are a single list with a
guaranteed order.
* It must be able to receive items that were sent to the player while they were not connected to the server.
### Encouraged Features
These are "nice to have" features for a client, but they are not strictly required. It is encouraged to add them
if possible.
* If your client appears in the Archipelago Launcher, you may define an icon for it that differentiates it from
other clients. The icon size is 48x48 pixels, but smaller or larger images will scale to that size.
## World
@@ -44,35 +68,94 @@ The world is your game integration for the Archipelago generator, webhost, and m
information necessary for creating the items and locations to be randomized, the logic for item placement, the
datapackage information so other game clients can recognize your game data, and documentation. Your world must be
written as a Python package to be loaded by Archipelago. This is currently done by creating a fork of the Archipelago
repository and creating a new world package in `/worlds/`. A bare minimum world implementation must satisfy the
following requirements:
repository and creating a new world package in `/worlds/`.
* A folder within `/worlds/` that contains an `__init__.py`
* A `World` subclass where you create your world and define all of its rules
* A unique game name
* For webhost documentation and behaviors, a `WebWorld` subclass that must be instantiated in the `World` class
definition
* The game_info doc must follow the format `{language_code}_{game_name}.md`
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call
during generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
regarding the API can be found in the [world api doc](/docs/world%20api.md). Before publishing, make sure to also
check out [world maintainer.md](/docs/world%20maintainer.md).
### Hard Requirements
A bare minimum world implementation must satisfy the following requirements:
* It has a folder with the name of your game (or an abbreviation) under `/worlds/`
* The `/worlds/{game}` folder contains an `__init__.py`
* Any subfolders within `/worlds/{game}` that contain `*.py` files also contain an `__init__.py` for frozen build
packaging
* The game folder has at least one game_info doc named with follow the format `{language_code}_{game_name}.md`
* The game folder has at least one setup doc
* There must be a `World` subclass in your game folder (typically in `/worlds/{game}/__init__.py`) where you create
your world and define all of its rules and features
Within the `World` subclass you should also have:
* A [unique game name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L260)
* An [instance](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L295) of a `WebWorld`
subclass for webhost documentation and behaviors
* In your `WebWorld`, if you wrote a game_info doc in more than one language, override the list of
[game info languages](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L210) with the
ones you include.
* In your `WebWorld`, override the list of
[tutorials](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L213) with each tutorial
or setup doc you included in the game folder.
* A mapping for items and locations defining their names and ids for clients to be able to identify them. These are
`item_name_to_id` and `location_name_to_id`, respectively.
* Create an item when `create_item` is called both by your code and externally
* An `options_dataclass` defining the options players have available to them
* A `Region` for your player with the name "Menu" to start from
* Create a non-zero number of locations and add them to your regions
* Create a non-zero number of items **equal** to the number of locations and add them to the multiworld itempool
* All items submitted to the multiworld itempool must not be manually placed by the World. If you need to place specific
items, there are multiple ways to do so, but they should not be added to the multiworld itempool.
`item_name_to_id` and `location_name_to_id`, respectively.
* An implementation of `create_item` that can create an item when called by either your code or by another process
within Archipelago
* At least one `Region` for your player to start from (i.e. the Origin Region)
* The default name of this region is "Menu" but you may configure a different name with
[origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)
* A non-zero number of locations, added to your regions
* A non-zero number of items **equal** to the number of locations, added to the multiworld itempool
* In rare cases, there may be 0-location-0-item games, but this is extremely atypical.
* A set
[completion condition](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#L77) (aka "goal") for
the player.
* Use your player as the index (`multiworld.completion_condition[player]`) for your world's completion goal.
Notable caveats:
* The "Menu" region will always be considered the "start" for the player
* The "Menu" region is *always* considered accessible; i.e. the player is expected to always be able to return to the
### Encouraged Features
These are "nice to have" features for a world, but they are not strictly required. It is encouraged to add them
if possible.
* An implementation of
[get_filler_item_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L473)
* By default, this function chooses any item name from `item_name_to_id`, so you want to limit it to only the true
filler items.
* An `options_dataclass` defining the options players have available to them
* This should be accompanied by a type hint for `options` with the same class name
* A [bug report page](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L220)
* A list of [option groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L226)
for better organization on the webhost
* A dictionary of [options presets](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L223)
for player convenience
* A dictionary of [item name groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L273)
for player convenience
* A dictionary of
[location name groups](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L276)
for player convenience
* Other games may also benefit from your name group dictionaries for hints, features, etc.
### Discouraged or Prohibited Behavior
These are behaviors or implementations that are known to cause various issues. Some of these points have notable
workarounds or preferred methods which should be used instead:
* All items submitted to the multiworld itempool must not be manually placed by the World.
* If you need to place specific items, there are multiple ways to do so, but they should not be added to the
multiworld itempool.
* It is not allowed to use `eval` for most reasons, chiefly due to security concerns.
* It is discouraged to use PyYAML (i.e. `yaml.load`) directly due to security concerns.
* When possible, use `Utils.parse_yaml` instead, as this defaults to the safe loader and the faster C parser.
* When submitting regions or items to the multiworld (`multiworld.regions` and `multiworld.itempool` respectively),
do **not** use `=` as this will overwrite all elements for all games in the seed.
* Instead, use `append`, `extend`, or `+=`.
### Notable Caveats
* The Origin Region will always be considered the "start" for the player
* The Origin Region is *always* considered accessible; i.e. the player is expected to always be able to return to the
start of the game from anywhere
* When submitting regions or items to the multiworld (multiworld.regions and multiworld.itempool respectively), use
`append`, `extend`, or `+=`. **Do not use `=`**
* Regions are simply containers for locations that share similar access rules. They do not have to map to
concrete, physical areas within your game and can be more abstract like tech trees or a questline.
The base World class can be found in [AutoWorld](/worlds/AutoWorld.py). Methods available for your world to call during
generation can be found in [BaseClasses](/BaseClasses.py) and [Fill](/Fill.py). Some examples and documentation
regarding the API can be found in the [world api doc](/docs/world%20api.md).
Before publishing, make sure to also check out [world maintainer.md](/docs/world%20maintainer.md).

View File

@@ -8,7 +8,11 @@ including [Contributing](contributing.md), [Adding Games](<adding games.md>), an
### My game has a restrictive start that leads to fill errors
Hint to the Generator that an item needs to be in sphere one with local_early_items. Here, `1` represents the number of "Sword" items to attempt to place in sphere one.
A "restrictive start" here means having a combination of very few sphere 1 locations and potentially requiring more
than one item to get a player to sphere 2.
One way to fix this is to hint to the Generator that an item needs to be in sphere one with local_early_items.
Here, `1` represents the number of "Sword" items the Generator will attempt to place in sphere one.
```py
early_item_name = "Sword"
self.multiworld.local_early_items[self.player][early_item_name] = 1
@@ -18,15 +22,19 @@ Some alternative ways to try to fix this problem are:
* Add more locations to sphere one of your world, potentially only when there would be a restrictive start
* Pre-place items yourself, such as during `create_items`
* Put items into the player's starting inventory using `push_precollected`
* Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a restrictive start
* Raise an exception, such as an `OptionError` during `generate_early`, to disallow options that would lead to a
restrictive start
---
### I have multiple settings that change the item/location pool counts and need to balance them out
### I have multiple options that change the item/location pool counts and need to make sure I am not submitting more/fewer items than locations
In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be unbalanced. But in real, complex situations, that might be unfeasible.
In an ideal situation your system for producing locations and items wouldn't leave any opportunity for them to be
unbalanced. But in real, complex situations, that might be unfeasible.
If that's the case, you can create extra filler based on the difference between your unfilled locations and your itempool by comparing [get_unfilled_locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#:~:text=get_unfilled_locations) to your list of items to submit
If that's the case, you can create extra filler based on the difference between your unfilled locations and your
itempool by comparing [get_unfilled_locations](https://github.com/ArchipelagoMW/Archipelago/blob/main/BaseClasses.py#:~:text=get_unfilled_locations)
to your list of items to submit
Note: to use self.create_filler(), self.get_filler_item_name() should be defined to only return valid filler item names
```py
@@ -39,7 +47,8 @@ for _ in range(total_locations - len(item_pool)):
self.multiworld.itempool += item_pool
```
A faster alternative to the `for` loop would be to use a [list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions):
A faster alternative to the `for` loop would be to use a
[list comprehension](https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions):
```py
item_pool += [self.create_filler() for _ in range(total_locations - len(item_pool))]
```
@@ -48,21 +57,86 @@ item_pool += [self.create_filler() for _ in range(total_locations - len(item_poo
### I learned about indirect conditions in the world API document, but I want to know more. What are they and why are they necessary?
The world API document mentions how to use `multiworld.register_indirect_condition` to register indirect conditions and **when** you should use them, but not *how* they work and *why* they are necessary. This is because the explanation is quite complicated.
The world API document mentions how to use `multiworld.register_indirect_condition` to register indirect conditions and
**when** you should use them, but not *how* they work and *why* they are necessary. This is because the explanation is
quite complicated.
Region sweep (the algorithm that determines which regions are reachable) is a Breadth-First Search of the region graph. It starts from the origin region, checks entrances one by one, and adds newly reached regions and their entrances to the queue until there is nothing more to check.
Region sweep (the algorithm that determines which regions are reachable) is a Breadth-First Search of the region graph.
It starts from the origin region, checks entrances one by one, and adds newly reached regions and their entrances to
the queue until there is nothing more to check.
For performance reasons, AP only checks every entrance once. However, if an entrance's access_rule depends on region access, then the following may happen:
1. The entrance is checked and determined to be nontraversable because the region in its access_rule hasn't been reached yet during the graph search.
For performance reasons, AP only checks every entrance once. However, if an entrance's access_rule depends on region
access, then the following may happen:
1. The entrance is checked and determined to be nontraversable because the region in its access_rule hasn't been
reached yet during the graph search.
2. Then, the region in its access_rule is determined to be reachable.
This entrance *would* be in logic if it were rechecked, but it won't be rechecked this cycle.
To account for this case, AP would have to recheck all entrances every time a new region is reached until no new regions are reached.
To account for this case, AP would have to recheck all entrances every time a new region is reached until no new
regions are reached.
An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep if a specific region is reached during it.
This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness), using them is significantly faster than just "rechecking each entrance until nothing new is found".
The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is because they call `region.can_reach` on their respective parent/source region.
An indirect condition is how you can manually define that a specific entrance needs to be rechecked during region sweep
if a specific region is reached during it.
This keeps most of the performance upsides. Even in a game making heavy use of indirect conditions (ex: The Witness),
using them is significantly faster than just "rechecking each entrance until nothing new is found".
The reason entrance access rules using `location.can_reach` and `entrance.can_reach` are also affected is because they
call `region.can_reach` on their respective parent/source region.
We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition, and that some games have very complex access rules.
As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682) being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of checking each entrance whenever a region has been reached, although this does come with a performance cost.
Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should be reasonable to know all entrance &rarr; region dependencies, making indirect conditions preferred because they are much faster.
We recognize it can feel like a trap since it will not alert you when you are missing an indirect condition,
and that some games have very complex access rules.
As of [PR #3682 (Core: Region handling customization)](https://github.com/ArchipelagoMW/Archipelago/pull/3682)
being merged, it is possible for a world to opt out of indirect conditions entirely, instead using the system of
checking each entrance whenever a region has been reached, although this does come with a performance cost.
Opting out of using indirect conditions should only be used by games that *really* need it. For most games, it should
be reasonable to know all entrance &rarr; region dependencies, making indirect conditions preferred because they are
much faster.
---
### I uploaded the generated output of my world to the webhost and webhost is erroring on corrupted multidata
The error `Could not load multidata. File may be corrupted or incompatible.` occurs when uploading a locally generated
file where there is an issue with the multidata contained within it. It may come with a description like
`(No module named 'worlds.myworld')` or `(global 'worlds.myworld.names.ItemNames' is forbidden)`
Pickling is a way to compress python objects such that they can be decompressed and be used to rebuild the
python objects. This means that if one of your custom class instances ends up in the multidata, the server would not
be able to load that custom class to decompress the data, which can fail either because the custom class is unknown
(because it cannot load your world module) or the class it's attempting to import to decompress is deemed unsafe.
Common situations where this can happen include:
* Using Option instances directly in slot_data. Ex: using `options.option_name` instead of `options.option_name.value`.
Also, consider using the `options.as_dict("option_name", "option_two")` helper.
* Using enums as Location/Item names in the datapackage. When building out `location_name_to_id` and `item_name_to_id`,
make sure that you are not using your enum class for either the names or ids in these mappings.
---
### Some locations are technically possible to check with few or no items, but they'd be very tedious or frustrating. How do worlds deal with this?
Sometimes the game can be modded to skip these locations or make them less tedious. But when this issue is due to a fundamental aspect of the game, then the general answer is "soft logic" (and its subtypes like "combat logic", "money logic", etc.). For example: you can logically require that a player have several helpful items before fighting the final boss, even if a skilled player technically needs no items to beat it. Randomizer logic should describe what's *fun* rather than what's technically possible.
Concrete examples of soft logic include:
- Defeating a boss might logically require health upgrades, damage upgrades, certain weapons, etc. that aren't strictly necessary.
- Entering a high-level area might logically require access to enough other parts of the game that checking other locations should naturally get the player to the soft-required level.
- Buying expensive shop items might logically require access to a place where you can quickly farm money, or logically require access to enough parts of the game that checking other locations should naturally generate enough money without grinding.
Remember that all items referenced by logic (however hard or soft) must be `progression`. Since you typically don't want to turn a ton of `filler` items into `progression` just for this, it's common to e.g. write money logic using only the rare "$100" item, so the dozens of "$1" and "$10" items in your world can remain `filler`.
---
### What if my game has "missable" or "one-time-only" locations or region connections?
Archipelago logic assumes that once a region or location becomes reachable, it stays reachable forever, no matter what
the player does in-game. Slightly more formally: Receiving an AP item must never cause a region connection or location
to "go out of logic" (become unreachable when it was previously reachable), and receiving AP items is the only kind of
state change that AP logic acknowledges. No other actions or events can change reachability.
So when the game itself does not follow this assumption, the options are:
- Modify the game to make that location/connection repeatable
- If there are both missable and repeatable ways to check the location/traverse the connection, then write logic for
only the repeatable ways
- Don't generate the missable location/connection at all
- For connections, any logical regions will still need to be reachable through other, *repeatable* connections
- For locations, this may require game changes to remove the vanilla item if it affects logic
- Decide that resetting the save file is part of the game's logic, and warn players about that

View File

@@ -117,8 +117,6 @@ flowchart LR
%% Java Based Games
subgraph Java
JM[Mod with Archipelago.MultiClient.Java]
STS[Slay the Spire]
JM <-- Mod the Spire --> STS
subgraph Minecraft
MCS[Minecraft Forge Server]
JMC[Any Java Minecraft Clients]

View File

@@ -470,7 +470,7 @@ The following operations can be applied to a datastorage key
| right_shift | Applies a bitwise right-shift to the current value of the key by `value`. |
| remove | List only: removes the first instance of `value` found in the list. |
| pop | List or Dict: for lists it will remove the index of the `value` given. for dicts it removes the element with the specified key of `value`. |
| update | Dict only: Updates the dictionary with the specified elements given in `value` creating new keys, or updating old ones if they previously existed. |
| update | List or Dict: Adds the elements of `value` to the container if they weren't already present. In the case of a Dict, already present keys will have their corresponding values updated. |
### SetNotify
Used to register your current session for receiving all [SetReply](#SetReply) packages of certain keys to allow your client to keep track of changes.
@@ -756,8 +756,8 @@ Tags are represented as a list of strings, the common client tags follow:
### DeathLink
A special kind of Bounce packet that can be supported by any AP game. It targets the tag "DeathLink" and carries the following data:
| Name | Type | Notes |
|--------|-------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| time | float | Unix Time Stamp of time of death. |
| cause | str | Optional. Text to explain the cause of death. When provided, or checked, this should contain the player name, ex. "Berserker was run over by a train." |
| source | str | Name of the player who first died. Can be a slot name, but can also be a name from within a multiplayer game. |
| Name | Type | Notes |
|--------|-------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| time | float | Unix Time Stamp of time of death. |
| cause | str | Optional. Text to explain the cause of death. When provided, or checked, if the string is non-empty, it should contain the player name, ex. "Berserker was run over by a train." |
| source | str | Name of the player who first died. Can be a slot name, but can also be a name from within a multiplayer game. |

View File

@@ -352,8 +352,15 @@ template. If you set a [Schema](https://pypi.org/project/schema/) on the class w
options system will automatically validate the user supplied data against the schema to ensure it's in the correct
format.
### OptionCounter
This is a special case of OptionDict where the dictionary values can only be integers.
It returns a [collections.Counter](https://docs.python.org/3/library/collections.html#collections.Counter).
This means that if you access a key that isn't present, its value will be 0.
The upside of using an OptionCounter (instead of an OptionDict with integer values) is that an OptionCounter can be
displayed on the Options page on WebHost.
### ItemDict
Like OptionDict, except this will verify that every key in the dictionary is a valid name for an item for your world.
An OptionCounter that will verify that every key in the dictionary is a valid name for an item for your world.
### OptionList
This option defines a List, where the user can add any number of strings to said list, allowing duplicate values. You

View File

@@ -11,8 +11,13 @@ found in the [general test directory](/test/general).
## Defining World Tests
In order to run tests from your world, you will need to create a `test` package within your world package. This can be
done by creating a `test` directory with a file named `__init__.py` inside it inside your world. By convention, a base
for your world tests can be created in this file that you can then import into other modules.
done by creating a `test` directory inside your world with an (empty) `__init__.py` inside it. By convention, a base
for your world tests can be created in `bases.py` or any file that does not start with `test`, that you can then import
into other modules. All tests should be defined in files named `test_*.py` (all lower case) and be member functions
(named `test_*`) of classes (named `Test*` or `*Test`) that inherit from `unittest.TestCase` or a test base.
Defining anything inside `test/__init__.py` is deprecated. Defining TestBase there was previously the norm; however,
it complicates test discovery because some worlds also put actual tests into `__init__.py`.
### WorldTestBase
@@ -21,7 +26,7 @@ interactions in the world interact as expected, you will want to use the [WorldT
comes with the basics for test setup as well as a few preloaded tests that most worlds might want to check on varying
options combinations.
Example `/worlds/<my_game>/test/__init__.py`:
Example `/worlds/<my_game>/test/bases.py`:
```python
from test.bases import WorldTestBase
@@ -49,7 +54,7 @@ with `test_`.
Example `/worlds/<my_game>/test/test_chest_access.py`:
```python
from . import MyGameTestBase
from .bases import MyGameTestBase
class TestChestAccess(MyGameTestBase):
@@ -73,22 +78,58 @@ When tests are run, this class will create a multiworld with a single player hav
generic tests, as well as the new custom test. Each test method definition will create its own separate solo multiworld
that will be cleaned up after. If you don't want to run the generic tests on a base, `run_default_tests` can be
overridden. For more information on what methods are available to your class, check the
[WorldTestBase definition](/test/bases.py#L104).
[WorldTestBase definition](/test/bases.py#L106).
#### Alternatives to WorldTestBase
Unit tests can also be created using [TestBase](/test/bases.py#L14) or
Unit tests can also be created using [TestBase](/test/bases.py#L16) or
[unittest.TestCase](https://docs.python.org/3/library/unittest.html#unittest.TestCase) depending on your use case. These
may be useful for generating a multiworld under very specific constraints without using the generic world setup, or for
testing portions of your code that can be tested without relying on a multiworld to be created first.
#### Parametrization
When defining a test that needs to cover a range of inputs it is useful to parameterize (to run the same test
for multiple inputs) the base test. Some important things to consider when attempting to parametrize your test are:
* [Subtests](https://docs.python.org/3/library/unittest.html#distinguishing-test-iterations-using-subtests)
can be used to have parametrized assertions that show up similar to individual tests but without the overhead
of needing to instantiate multiple tests; however, subtests can not be multithreaded and do not have individual
timing data, so they are not suitable for slow tests.
* Archipelago's tests are test-runner-agnostic. That means tests are not allowed to use e.g. `@pytest.mark.parametrize`.
Instead, we define our own parametrization helpers in [test.param](/test/param.py).
* Classes inheriting from `WorldTestBase`, including those created by the helpers in `test.param`, will run all
base tests by default, make sure the produced tests actually do what you aim for and do not waste a lot of
extra CPU time. Consider using `TestBase` or `unittest.TestCase` directly
or setting `WorldTestBase.run_default_tests` to False.
#### Performance Considerations
Archipelago is big enough that the runtime of unittests can have an impact on productivity.
Individual tests should take less than a second, so they can be properly multithreaded.
Ideally, thorough tests are directed at actual code/functionality. Do not just create and/or fill a ton of individual
Multiworlds that spend most of the test time outside what you actually want to test.
Consider generating/validating "random" games as part of your APWorld release workflow rather than having that be part
of continuous integration, and add minimal reproducers to the "normal" tests for problems that were found.
You can use [@unittest.skipIf](https://docs.python.org/3/library/unittest.html#unittest.skipIf) with an environment
variable to keep all the benefits of the test framework while not running the marked tests by default.
## Running Tests
#### Using Pycharm
In PyCharm, running all tests can be done by right-clicking the root test directory and selecting Run 'Archipelago Unittests'.
Unless you configured PyCharm to use pytest as a test runner, you may get import failures. To solve this, edit the run configuration,
and set the working directory to the Archipelago directory which contains all the project files.
If you have never previously run ModuleUpdate.py, then you will need to do this once before the tests will run.
You can run ModuleUpdate.py by right-clicking ModuleUpdate.py and selecting `Run 'ModuleUpdate'`.
After running ModuleUpdate.py you may still get a `ModuleNotFoundError: No module named 'flask'` for the webhost tests.
If this happens, run WebHost.py by right-clicking it and selecting `Run 'WebHost'`. Make sure to press enter when prompted.
Unless you configured PyCharm to use pytest as a test runner, you may get import failures. To solve this,
edit the run configuration, and set the working directory to the Archipelago directory which contains all the project files.
If you only want to run your world's defined tests, repeat the steps for the test directory within your world.
Your working directory should be the directory of your world in the worlds directory and the script should be the
@@ -100,3 +141,11 @@ next to the run and debug buttons.
#### Running Tests without Pycharm
Run `pip install pytest pytest-subtests`, then use your IDE to run tests or run `pytest` from the source folder.
#### Running Tests Multithreaded
pytest can run multiple test runners in parallel with the pytest-xdist extension.
Install with `pip install pytest-xdist`.
Run with `pytest -n12` to spawn 12 process that each run 1/12th of the tests.

View File

@@ -291,7 +291,7 @@ 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 (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L295-L296)),
There must be one special region (Called "Menu" by default, but configurable using [origin_region_name](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L298-L299)),
from which the logic unfolds. AP assumes that a player will always be able to return to this starting region by resetting the game ("Save and quit").
### Entrances
@@ -331,7 +331,7 @@ 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),
Alternatively, you can set [world.explicit_indirect_conditions = False](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/AutoWorld.py#L301-L304),
avoiding the need for indirect conditions at the expense of performance.
### Item Rules
@@ -561,7 +561,7 @@ from .items import is_progression # this is just a dummy
def create_item(self, item: str) -> MyGameItem:
# this is called when AP wants to create an item by name (for plando) or when you call it from your own code
# this is called when AP wants to create an item by name (for plando, start inventory, item links) or when you call it from your own code
classification = ItemClassification.progression if is_progression(item) else ItemClassification.filler
return MyGameItem(item, classification, self.item_name_to_id[item], self.player)
@@ -606,8 +606,8 @@ from .items import get_item_type
def set_rules(self) -> None:
# For some worlds this step can be omitted if either a Logic mixin
# (see below) is used, it's easier to apply the rules from data during
# location generation or everything is in generate_basic
# (see below) is used or it's easier to apply the rules from data during
# location generation
# set a simple rule for an region
set_rule(self.multiworld.get_entrance("Boss Door", self.player),

View File

@@ -50,13 +50,15 @@ class EntranceLookup:
_random: random.Random
_expands_graph_cache: dict[Entrance, bool]
_coupled: bool
_usable_exits: set[Entrance]
def __init__(self, rng: random.Random, coupled: bool):
def __init__(self, rng: random.Random, coupled: bool, usable_exits: set[Entrance]):
self.dead_ends = EntranceLookup.GroupLookup()
self.others = EntranceLookup.GroupLookup()
self._random = rng
self._expands_graph_cache = {}
self._coupled = coupled
self._usable_exits = usable_exits
def _can_expand_graph(self, entrance: Entrance) -> bool:
"""
@@ -95,7 +97,8 @@ class EntranceLookup:
# randomizable exits which are not reverse of the incoming entrance.
# uncoupled mode is an exception because in this case going back in the door you just came in could
# actually lead somewhere new
if not exit_.connected_region and (not self._coupled or exit_.name != entrance.name):
if (not exit_.connected_region and (not self._coupled or exit_.name != entrance.name)
and exit_ in self._usable_exits):
self._expands_graph_cache[entrance] = True
return True
elif exit_.connected_region and exit_.connected_region not in visited:
@@ -157,17 +160,16 @@ class ERPlacementState:
def placed_regions(self) -> set[Region]:
return self.collection_state.reachable_regions[self.world.player]
def find_placeable_exits(self, check_validity: bool) -> list[Entrance]:
def find_placeable_exits(self, check_validity: bool, usable_exits: list[Entrance]) -> list[Entrance]:
if check_validity:
blocked_connections = self.collection_state.blocked_connections[self.world.player]
blocked_connections = sorted(blocked_connections, key=lambda x: x.name)
placeable_randomized_exits = [connection for connection in blocked_connections
if not connection.connected_region
and connection.is_valid_source_transition(self)]
placeable_randomized_exits = [ex for ex in usable_exits
if not ex.connected_region
and ex in blocked_connections
and ex.is_valid_source_transition(self)]
else:
# this is on a beaten minimal attempt, so any exit anywhere is fair game
placeable_randomized_exits = [ex for region in self.world.multiworld.get_regions(self.world.player)
for ex in region.exits if not ex.connected_region]
placeable_randomized_exits = [ex for ex in usable_exits if not ex.connected_region]
self.world.random.shuffle(placeable_randomized_exits)
return placeable_randomized_exits
@@ -181,7 +183,8 @@ class ERPlacementState:
self.placements.append(source_exit)
self.pairings.append((source_exit.name, target_entrance.name))
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance) -> bool:
def test_speculative_connection(self, source_exit: Entrance, target_entrance: Entrance,
usable_exits: set[Entrance]) -> bool:
copied_state = self.collection_state.copy()
# simulated connection. A real connection is unsafe because the region graph is shallow-copied and would
# propagate back to the real multiworld.
@@ -198,6 +201,9 @@ class ERPlacementState:
# ignore the source exit, and, if coupled, the reverse exit. They're not actually new
if _exit.name == source_exit.name or (self.coupled and _exit.name == target_entrance.name):
continue
# make sure we are only paying attention to usable exits
if _exit not in usable_exits:
continue
# technically this should be is_valid_source_transition, but that may rely on side effects from
# on_connect, which have not happened here (because we didn't do a real connection, and if we did, we would
# not want them to persist). can_reach is a close enough approximation most of the time.
@@ -262,14 +268,19 @@ def bake_target_group_lookup(world: World, get_target_groups: Callable[[int], li
return { group: get_target_groups(group) for group in unique_groups }
def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None) -> None:
def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int | None = None,
one_way_target_name: str | None = None) -> None:
"""
Given an entrance in a "vanilla" region graph, splits that entrance to prepare it for randomization
in randomize_entrances. This should be done after setting the type and group of the entrance.
in randomize_entrances. This should be done after setting the type and group of the entrance. Because it attempts
to meet strict entrance naming requirements for coupled mode, this function may produce unintuitive results when
called only on a single entrance; it produces eventually-correct outputs only after calling it on all entrances.
:param entrance: The entrance which will be disconnected in preparation for randomization.
:param target_group: The group to assign to the created ER target. If not specified, the group from
the original entrance will be copied.
:param one_way_target_name: The name of the created ER target if `entrance` is one-way. This argument
is required for one-way entrances and is ignored otherwise.
"""
child_region = entrance.connected_region
parent_region = entrance.parent_region
@@ -284,8 +295,11 @@ def disconnect_entrance_for_randomization(entrance: Entrance, target_group: int
# targets in the child region will be created when the other direction edge is disconnected
target = parent_region.create_er_target(entrance.name)
else:
# for 1-ways, the child region needs a target and coupling/naming is not a concern
target = child_region.create_er_target(child_region.name)
# for 1-ways, the child region needs a target. naming is not a concern for coupling so we
# allow it to be user provided (and require it, to prevent an unhelpful assumed name in pairings)
if not one_way_target_name:
raise ValueError("Cannot disconnect a one-way entrance without a target name specified")
target = child_region.create_er_target(one_way_target_name)
target.randomization_type = entrance.randomization_type
target.randomization_group = target_group or entrance.randomization_group
@@ -322,10 +336,28 @@ def randomize_entrances(
start_time = time.perf_counter()
er_state = ERPlacementState(world, coupled)
entrance_lookup = EntranceLookup(world.random, coupled)
# similar to fill, skip validity checks on entrances if the game is beatable on minimal accessibility
perform_validity_check = True
if not er_targets:
er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player)
for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name)
if not exits:
exits = sorted([ex for region in world.multiworld.get_regions(world.player)
for ex in region.exits if not ex.connected_region], key=lambda x: x.name)
if len(er_targets) != len(exits):
raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of "
f"entrances ({len(er_targets)}) and exits ({len(exits)}.")
# used when membership checks are needed on the exit list, e.g. speculative sweep
exits_set = set(exits)
entrance_lookup = EntranceLookup(world.random, coupled, exits_set)
for entrance in er_targets:
entrance_lookup.add(entrance)
# place the menu region and connected start region(s)
er_state.collection_state.update_reachable_regions(world.player)
def do_placement(source_exit: Entrance, target_entrance: Entrance) -> None:
placed_exits, removed_entrances = er_state.connect(source_exit, target_entrance)
# remove the placed targets from consideration
@@ -337,9 +369,37 @@ def randomize_entrances(
if on_connect:
on_connect(er_state, placed_exits)
def needs_speculative_sweep(dead_end: bool, require_new_exits: bool, placeable_exits: list[Entrance]) -> bool:
# speculative sweep is expensive. We currently only do it as a last resort, if we might cap off the graph
# entirely
if len(placeable_exits) > 1:
return False
# in certain stages of randomization we either expect or don't care if the search space shrinks.
# we should never speculative sweep here.
if dead_end or not require_new_exits or not perform_validity_check:
return False
# edge case - if all dead ends have pre-placed progression or indirect connections, they are pulled forward
# into the non dead end stage. In this case, and only this case, it's possible that the last connection may
# actually be placeable in stage 1. We need to skip speculative sweep in this case because we expect the graph
# to get capped off.
# check to see if we are proposing the last placement
if not coupled:
# in uncoupled, this check is easy as there will only be one target.
is_last_placement = len(entrance_lookup) == 1
else:
# a bit harder, there may be 1 or 2 targets depending on if the exit to place is one way or two way.
# if it is two way, we can safely assume that one of the targets is the logical pair of the exit.
desired_target_count = 2 if placeable_exits[0].randomization_type == EntranceType.TWO_WAY else 1
is_last_placement = len(entrance_lookup) == desired_target_count
# if it's not the last placement, we need a sweep
return not is_last_placement
def find_pairing(dead_end: bool, require_new_exits: bool) -> bool:
nonlocal perform_validity_check
placeable_exits = er_state.find_placeable_exits(perform_validity_check)
placeable_exits = er_state.find_placeable_exits(perform_validity_check, exits)
for source_exit in placeable_exits:
target_groups = target_group_lookup[source_exit.randomization_group]
for target_entrance in entrance_lookup.get_targets(target_groups, dead_end, preserve_group_order):
@@ -350,12 +410,10 @@ def randomize_entrances(
# very last exit and check whatever exits we open up are functionally accessible.
# this requirement can be ignored on a beaten minimal, islands are no issue there.
exit_requirement_satisfied = (not perform_validity_check or not require_new_exits
or target_entrance.connected_region not in er_state.placed_regions)
needs_speculative_sweep = (not dead_end and require_new_exits and perform_validity_check
and len(placeable_exits) == 1)
or target_entrance.connected_region not in er_state.placed_regions)
if exit_requirement_satisfied and source_exit.can_connect_to(target_entrance, dead_end, er_state):
if (needs_speculative_sweep
and not er_state.test_speculative_connection(source_exit, target_entrance)):
if (needs_speculative_sweep(dead_end, require_new_exits, placeable_exits)
and not er_state.test_speculative_connection(source_exit, target_entrance, exits_set)):
continue
do_placement(source_exit, target_entrance)
return True
@@ -407,21 +465,6 @@ def randomize_entrances(
f"All unplaced entrances: {unplaced_entrances}\n"
f"All unplaced exits: {unplaced_exits}")
if not er_targets:
er_targets = sorted([entrance for region in world.multiworld.get_regions(world.player)
for entrance in region.entrances if not entrance.parent_region], key=lambda x: x.name)
if not exits:
exits = sorted([ex for region in world.multiworld.get_regions(world.player)
for ex in region.exits if not ex.connected_region], key=lambda x: x.name)
if len(er_targets) != len(exits):
raise EntranceRandomizationError(f"Unable to randomize entrances due to a mismatched count of "
f"entrances ({len(er_targets)}) and exits ({len(exits)}.")
for entrance in er_targets:
entrance_lookup.add(entrance)
# place the menu region and connected start region(s)
er_state.collection_state.update_reachable_regions(world.player)
# stage 1 - try to place all the non-dead-end entrances
while entrance_lookup.others:
if not find_pairing(dead_end=False, require_new_exits=True):

View File

@@ -45,7 +45,8 @@ MinVersion={#min_windows}
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}";
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}";
Name: "deletelib"; Description: "Clean existing /lib folder and subfolders including /worlds (leave checked if unsure)"; Check: ShouldShowDeleteLibTask
[Types]
Name: "full"; Description: "Full installation"
@@ -83,18 +84,8 @@ Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringC
Type: dirifempty; Name: "{app}"
[InstallDelete]
Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld"
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
Type: files; Name: "{app}\*.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy"
Type: dirifempty; Name: "{app}\lib\worlds\rogue-legacy"
Type: files; Name: "{app}\lib\worlds\sc2wol.apworld"
Type: filesandordirs; Name: "{app}\lib\worlds\sc2wol"
Type: dirifempty; Name: "{app}\lib\worlds\sc2wol"
Type: filesandordirs; Name: "{app}\lib\worlds\bk_sudoku"
Type: dirifempty; Name: "{app}\lib\worlds\bk_sudoku"
Type: files; Name: "{app}\ArchipelagoLauncher(DEBUG).exe"
Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
#include "installdelete.iss"
@@ -221,6 +212,11 @@ Root: HKCR; Subkey: "{#MyAppName}ygo06patch"; ValueData: "Ar
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\DefaultIcon"; ValueData: "{app}\ArchipelagoBizHawkClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}ygo06patch\shell\open\command"; ValueData: """{app}\ArchipelagoBizHawkClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apcivvi"; ValueData: "{#MyAppName}apcivvipatch"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch"; ValueData: "Archipelago Civilization 6 Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}apcivvipatch\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".archipelago"; ValueData: "{#MyAppName}multidata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Archipelago Server Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
@@ -256,3 +252,17 @@ begin
Result := True;
end;
end;
function ShouldShowDeleteLibTask: Boolean;
begin
Result := DirExists(ExpandConstant('{app}\lib'));
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssInstall then
begin
if WizardIsTaskSelected('deletelib') then
DelTree(ExpandConstant('{app}\lib'), True, True, True);
end;
end;

624
kvui.py
View File

@@ -35,8 +35,7 @@ from kivy.config import Config
Config.set("input", "mouse", "mouse,disable_multitouch")
Config.set("kivy", "exit_on_escape", "0")
Config.set("graphics", "multisamples", "0") # multisamples crash old intel drivers
from kivy.app import App
from kivymd.uix.divider import MDDivider
from kivy.core.window import Window
from kivy.core.clipboard import Clipboard
from kivy.core.text.markup import MarkupLabel
@@ -44,32 +43,34 @@ from kivy.core.image import ImageLoader, ImageLoaderBase, ImageData
from kivy.base import ExceptionHandler, ExceptionManager
from kivy.clock import Clock
from kivy.factory import Factory
from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty
from kivy.metrics import dp
from kivy.effects.scroll import ScrollEffect
from kivy.properties import BooleanProperty, ObjectProperty, NumericProperty, StringProperty
from kivy.metrics import dp, sp
from kivy.uix.widget import Widget
from kivy.uix.button import Button
from kivy.uix.gridlayout import GridLayout
from kivy.uix.layout import Layout
from kivy.uix.textinput import TextInput
from kivy.uix.scrollview import ScrollView
from kivy.uix.recycleview import RecycleView
from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem
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
from kivy.uix.behaviors import FocusBehavior
from kivy.uix.behaviors import FocusBehavior, ToggleButtonBehavior
from kivy.uix.recycleboxlayout import RecycleBoxLayout
from kivy.uix.recycleview.layout import LayoutSelectionBehavior
from kivy.animation import Animation
from kivy.uix.popup import Popup
from kivy.uix.dropdown import DropDown
from kivy.uix.image import AsyncImage
from kivymd.app import MDApp
from kivymd.uix.gridlayout import MDGridLayout
from kivymd.uix.floatlayout import MDFloatLayout
from kivymd.uix.boxlayout import MDBoxLayout
from kivymd.uix.tab.tab import MDTabsSecondary, MDTabsItem, MDTabsItemText, MDTabsCarousel
from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.menu.menu import MDDropdownTextItem
from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText
from kivymd.uix.button import MDButton, MDButtonText, MDButtonIcon, MDIconButton
from kivymd.uix.label import MDLabel, MDIcon
from kivymd.uix.recycleview import MDRecycleView
from kivymd.uix.textfield.textfield import MDTextField
from kivymd.uix.progressindicator import MDLinearProgressIndicator
from kivymd.uix.scrollview import MDScrollView
from kivymd.uix.tooltip import MDTooltip, MDTooltipPlain
fade_in_animation = Animation(opacity=0, duration=0) + Animation(opacity=1, duration=0.25)
@@ -86,6 +87,113 @@ else:
remove_between_brackets = re.compile(r"\[.*?]")
class ThemedApp(MDApp):
def set_colors(self):
text_colors = KivyJSONtoTextParser.TextColors()
self.theme_cls.theme_style = text_colors.theme_style
self.theme_cls.primary_palette = text_colors.primary_palette
self.theme_cls.dynamic_scheme_name = text_colors.dynamic_scheme_name
self.theme_cls.dynamic_scheme_contrast = text_colors.dynamic_scheme_contrast
class ImageIcon(MDButtonIcon, AsyncImage):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.image = ApAsyncImage(**kwargs)
self.add_widget(self.image)
def add_widget(self, widget, index=0, canvas=None):
return super(MDIcon, self).add_widget(widget)
class ImageButton(MDIconButton):
def __init__(self, **kwargs):
image_args = dict()
for kwarg in ("fit_mode", "image_size", "color", "source", "texture"):
val = kwargs.pop(kwarg, "None")
if val != "None":
image_args[kwarg.replace("image_", "")] = val
super().__init__()
self.image = ApAsyncImage(**image_args)
def set_center(button, center):
self.image.center_x = self.center_x
self.image.center_y = self.center_y
self.bind(center=set_center)
self.add_widget(self.image)
def add_widget(self, widget, index=0, canvas=None):
return super(MDIcon, self).add_widget(widget)
class ScrollBox(MDScrollView):
layout: MDBoxLayout = ObjectProperty(None)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# thanks kivymd
class ToggleButton(MDButton, ToggleButtonBehavior):
def __init__(self, *args, **kwargs):
super(ToggleButton, self).__init__(*args, **kwargs)
self.bind(state=self._update_bg)
def _update_bg(self, _, state: str):
if self.disabled:
return
if self.theme_bg_color == "Primary":
self.theme_bg_color = "Custom"
if state == "down":
self.md_bg_color = self.theme_cls.primaryColor
for child in self.children:
if child.theme_text_color == "Primary":
child.theme_text_color = "Custom"
if child.theme_icon_color == "Primary":
child.theme_icon_color = "Custom"
child.text_color = self.theme_cls.onPrimaryColor
child.icon_color = self.theme_cls.onPrimaryColor
else:
self.md_bg_color = self.theme_cls.surfaceContainerLowestColor
for child in self.children:
if child.theme_text_color == "Primary":
child.theme_text_color = "Custom"
if child.theme_icon_color == "Primary":
child.theme_icon_color = "Custom"
child.text_color = self.theme_cls.primaryColor
child.icon_color = self.theme_cls.primaryColor
# thanks kivymd
class ResizableTextField(MDTextField):
"""
Resizable MDTextField that manually overrides the builtin sizing.
Note that in order to use this, the sizing must be specified from within a .kv rule.
"""
def __init__(self, *args, **kwargs):
# cursed rules override
rules = Builder.match(self)
textfield = next((rule for rule in rules if rule.name == f"<MDTextField>"), None)
if textfield:
subclasses = rules[rules.index(textfield) + 1:]
for subclass in subclasses:
height_rule = subclass.properties.get("height", None)
if height_rule:
height_rule.ignore_prev = True
super().__init__(*args, **kwargs)
def on_release(self: MDButton, *args):
super(MDButton, self).on_release(args)
self.on_leave()
MDButton.on_release = on_release
# I was surprised to find this didn't already exist in kivy :(
class HoverBehavior(object):
"""originally from https://stackoverflow.com/a/605348110"""
@@ -125,7 +233,7 @@ class HoverBehavior(object):
Factory.register("HoverBehavior", HoverBehavior)
class ToolTip(Label):
class ToolTip(MDTooltipPlain):
pass
@@ -133,49 +241,30 @@ class ServerToolTip(ToolTip):
pass
class ScrollBox(ScrollView):
layout: BoxLayout
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.layout = BoxLayout(size_hint_y=None)
self.layout.bind(minimum_height=self.layout.setter("height"))
self.add_widget(self.layout)
self.effect_cls = ScrollEffect
self.bar_width = dp(12)
self.scroll_type = ["content", "bars"]
class HovererableLabel(HoverBehavior, Label):
class HovererableLabel(HoverBehavior, MDLabel):
pass
class TooltipLabel(HovererableLabel):
tooltip = None
class TooltipLabel(HovererableLabel, MDTooltip):
tooltip_display_delay = 0.1
def create_tooltip(self, text, x, y):
text = text.replace("<br>", "\n").replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]")
if self.tooltip:
# update
self.tooltip.children[0].text = text
else:
self.tooltip = FloatLayout()
tooltip_label = ToolTip(text=text)
self.tooltip.add_widget(tooltip_label)
fade_in_animation.start(self.tooltip)
App.get_running_app().root.add_widget(self.tooltip)
# handle left-side boundary to not render off-screen
x = max(x, 3 + self.tooltip.children[0].texture_size[0] / 2)
# position float layout
self.tooltip.x = x - self.tooltip.width / 2
self.tooltip.y = y - self.tooltip.height / 2 + 48
center_x, center_y = self.to_window(self.center_x, self.center_y)
self.shift_y = y - center_y
shift_x = center_x - x
if shift_x > 0:
self.shift_left = shift_x
else:
self.shift_right = shift_x
def remove_tooltip(self):
if self.tooltip:
App.get_running_app().root.remove_widget(self.tooltip)
self.tooltip = None
if self._tooltip:
# update
self._tooltip.text = text
else:
self._tooltip = ToolTip(text=text, pos_hint={})
self.display_tooltip()
def on_mouse_pos(self, window, pos):
if not self.get_root_window():
@@ -202,26 +291,30 @@ class TooltipLabel(HovererableLabel):
def on_leave(self):
self.remove_tooltip()
self._tooltip = None
class ServerLabel(HovererableLabel):
class ServerLabel(HoverBehavior, MDTooltip, MDBoxLayout):
tooltip_display_delay = 0.1
text: str = StringProperty("Server:")
def __init__(self, *args, **kwargs):
super(HovererableLabel, self).__init__(*args, **kwargs)
self.layout = FloatLayout()
self.popuplabel = ServerToolTip(text="Test")
self.layout.add_widget(self.popuplabel)
super().__init__(*args, **kwargs)
self.add_widget(MDIcon(icon="information", font_size=sp(15)))
self.add_widget(TooltipLabel(text=self.text, pos_hint={"center_x": 0.5, "center_y": 0.5},
font_size=sp(15)))
self._tooltip = ServerToolTip(text="Test")
def on_enter(self):
self.popuplabel.text = self.get_text()
App.get_running_app().root.add_widget(self.layout)
fade_in_animation.start(self.layout)
self._tooltip.text = self.get_text()
self.display_tooltip()
def on_leave(self):
App.get_running_app().root.remove_widget(self.layout)
self.animation_tooltip_dismiss()
@property
def ctx(self) -> context_type:
return App.get_running_app().ctx
return MDApp.get_running_app().ctx
def get_text(self):
if self.ctx.server:
@@ -262,11 +355,11 @@ class ServerLabel(HovererableLabel):
return "No current server connection. \nPlease connect to an Archipelago server."
class MainLayout(GridLayout):
class MainLayout(MDGridLayout):
pass
class ContainerLayout(FloatLayout):
class ContainerLayout(MDFloatLayout):
pass
@@ -286,6 +379,11 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
return super(SelectableLabel, self).refresh_view_attrs(
rv, index, data)
def on_size(self, instance_label, size: list) -> None:
super().on_size(instance_label, size)
if self.parent:
self.width = self.parent.width
def on_touch_down(self, touch):
""" Add selection on touch down """
if super(SelectableLabel, self).on_touch_down(touch):
@@ -296,10 +394,10 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
else:
# Not a fan of the following few lines, but they work.
temp = MarkupLabel(text=self.text).markup
text = "".join(part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
cmdinput = App.get_running_app().textinput
text = "".join(part for part in temp if not part.startswith("["))
cmdinput = MDApp.get_running_app().textinput
if not cmdinput.text:
input_text = get_input_text_from_response(text, App.get_running_app().last_autofillable_command)
input_text = get_input_text_from_response(text, MDApp.get_running_app().last_autofillable_command)
if input_text is not None:
cmdinput.text = input_text
@@ -310,32 +408,118 @@ class SelectableLabel(RecycleDataViewBehavior, TooltipLabel):
""" Respond to the selection of items in the view. """
self.selected = is_selected
class AutocompleteHintInput(TextInput):
class MarkupDropdownTextItem(MDDropdownTextItem):
def __init__(self):
super().__init__()
for child in self.children:
if child.__class__ == MDLabel:
child.markup = True
# Currently, this only lets us do markup on text that does not have any icons
# Create new TextItems as needed
class MarkupDropdown(MDDropdownMenu):
def on_items(self, instance, value: list) -> None:
"""
The method sets the class that will be used to create the menu item.
"""
items = []
viewclass = "MarkupDropdownTextItem"
for data in value:
if "viewclass" not in data:
if (
"leading_icon" not in data
and "trailing_icon" not in data
and "trailing_text" not in data
):
viewclass = "MarkupDropdownTextItem"
elif (
"leading_icon" in data
and "trailing_icon" not in data
and "trailing_text" not in data
):
viewclass = "MDDropdownLeadingIconItem"
elif (
"leading_icon" not in data
and "trailing_icon" in data
and "trailing_text" not in data
):
viewclass = "MDDropdownTrailingIconItem"
elif (
"leading_icon" not in data
and "trailing_icon" in data
and "trailing_text" in data
):
viewclass = "MDDropdownTrailingIconTextItem"
elif (
"leading_icon" in data
and "trailing_icon" in data
and "trailing_text" in data
):
viewclass = "MDDropdownLeadingTrailingIconTextItem"
elif (
"leading_icon" in data
and "trailing_icon" in data
and "trailing_text" not in data
):
viewclass = "MDDropdownLeadingTrailingIconItem"
elif (
"leading_icon" not in data
and "trailing_icon" not in data
and "trailing_text" in data
):
viewclass = "MDDropdownTrailingTextItem"
elif (
"leading_icon" in data
and "trailing_icon" not in data
and "trailing_text" in data
):
viewclass = "MDDropdownLeadingIconTrailingTextItem"
data["viewclass"] = viewclass
if "height" not in data:
data["height"] = dp(48)
items.append(data)
self._items = items
# Update items in view
if hasattr(self, "menu"):
self.menu.data = self._items
class AutocompleteHintInput(ResizableTextField):
min_chars = NumericProperty(3)
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.dropdown = DropDown()
self.dropdown.bind(on_select=lambda instance, x: setattr(self, 'text', x))
self.dropdown = MarkupDropdown(caller=self, position="bottom", border_margin=dp(2), width=self.width)
self.bind(on_text_validate=self.on_message)
self.bind(width=lambda instance, x: setattr(self.dropdown, "width", x))
def on_message(self, instance):
App.get_running_app().commandprocessor("!hint "+instance.text)
MDApp.get_running_app().commandprocessor("!hint "+instance.text)
def on_text(self, instance, value):
if len(value) >= self.min_chars:
self.dropdown.clear_widgets()
ctx: context_type = App.get_running_app().ctx
self.dropdown.items.clear()
ctx: context_type = MDApp.get_running_app().ctx
if not ctx.game:
return
item_names = ctx.item_names._game_store[ctx.game].values()
def on_press(button: Button):
split_text = MarkupLabel(text=button.text).markup
return self.dropdown.select("".join(text_frag for text_frag in split_text
if not text_frag.startswith("[")))
def on_press(text):
split_text = MarkupLabel(text=text).markup
self.set_text(self, "".join(text_frag for text_frag in split_text
if not text_frag.startswith("[")))
self.dropdown.dismiss()
self.focus = True
lowered = value.lower()
for item_name in item_names:
try:
@@ -345,20 +529,29 @@ class AutocompleteHintInput(TextInput):
else:
text = escape_markup(item_name)
text = text[:index] + "[b]" + text[index:index+len(value)]+"[/b]"+text[index+len(value):]
btn = Button(text=text, size_hint_y=None, height=dp(30), markup=True)
btn.bind(on_release=on_press)
self.dropdown.add_widget(btn)
if not self.dropdown.attach_to:
self.dropdown.open(self)
self.dropdown.items.append({
"text": text,
"on_release": lambda txt=text: on_press(txt),
"markup": True
})
if not self.dropdown.parent:
self.dropdown.open()
else:
self.dropdown.dismiss()
class HintLabel(RecycleDataViewBehavior, BoxLayout):
status_icons = {
HintStatus.HINT_NO_PRIORITY: "information",
HintStatus.HINT_PRIORITY: "exclamation-thick",
HintStatus.HINT_AVOID: "alert"
}
class HintLabel(RecycleDataViewBehavior, MDBoxLayout):
selected = BooleanProperty(False)
striped = BooleanProperty(False)
index = None
dropdown: DropDown
dropdown: MDDropdownMenu
def __init__(self):
super(HintLabel, self).__init__()
@@ -369,29 +562,28 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
self.entrance_text = ""
self.status_text = ""
self.hint = {}
for child in self.children:
child.bind(texture_size=self.set_height)
ctx = MDApp.get_running_app().ctx
menu_items = []
ctx = App.get_running_app().ctx
self.dropdown = DropDown()
for status in (HintStatus.HINT_NO_PRIORITY, HintStatus.HINT_PRIORITY, HintStatus.HINT_AVOID):
name = status_names[status]
status_button = MDDropDownItem(MDDropDownItemText(text=name), size_hint_y=None, height=dp(50))
status_button.status = status
menu_items.append({
"text": name,
"leading_icon": status_icons[status],
"on_release": lambda x=status: select(self, x)
})
def set_value(button):
self.dropdown.select(button.status)
self.dropdown = MDDropdownMenu(caller=self.ids["status"], items=menu_items)
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)
self.dropdown.bind(on_release=self.dropdown.dismiss)
def set_height(self, instance, value):
self.height = max([child.texture_size[1] for child in self.children])
@@ -406,7 +598,6 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
self.entrance_text = data["entrance"]["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)
def on_touch_down(self, touch):
@@ -419,10 +610,10 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
if status_label.collide_point(*touch.pos):
if self.hint["status"] == HintStatus.HINT_FOUND:
return
ctx = App.get_running_app().ctx
ctx = MDApp.get_running_app().ctx
if ctx.slot_concerns_self(self.hint["receiving_player"]): # If this player owns this hint
# open a dropdown
self.dropdown.open(self.ids["status"])
self.dropdown.open()
elif self.selected:
self.parent.clear_selection()
else:
@@ -431,8 +622,7 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
if self.entrance_text != "Vanilla"
else "", ". (", self.status_text.lower(), ")"))
temp = MarkupLabel(text).markup
text = "".join(
part for part in temp if not part.startswith(("[color", "[/color]", "[ref=", "[/ref]")))
text = "".join(part for part in temp if not part.startswith("["))
Clipboard.copy(escape_markup(text).replace("&amp;", "&").replace("&bl;", "[").replace("&br;", "]"))
return self.parent.select_with_touch(self.index, touch)
else:
@@ -455,7 +645,7 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
else:
parent.sort_key = key
parent.reversed = False
App.get_running_app().update_hints()
MDApp.get_running_app().update_hints()
def apply_selection(self, rv, index, is_selected):
""" Respond to the selection of items in the view. """
@@ -463,7 +653,7 @@ class HintLabel(RecycleDataViewBehavior, BoxLayout):
self.selected = is_selected
class ConnectBarTextInput(TextInput):
class ConnectBarTextInput(ResizableTextField):
def insert_text(self, substring, from_undo=False):
s = substring.replace("\n", "").replace("\r", "")
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
@@ -473,14 +663,14 @@ def is_command_input(string: str) -> bool:
return len(string) > 0 and string[0] in "/!"
class CommandPromptTextInput(TextInput):
class CommandPromptTextInput(ResizableTextField):
MAXIMUM_HISTORY_MESSAGES = 50
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._command_history_index = -1
self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES)
def update_history(self, new_entry: str) -> None:
self._command_history_index = -1
if is_command_input(new_entry):
@@ -507,7 +697,7 @@ class CommandPromptTextInput(TextInput):
self._change_to_history_text_if_available(self._command_history_index - 1)
return True
return super().keyboard_on_key_down(window, keycode, text, modifiers)
def _change_to_history_text_if_available(self, new_index: int) -> None:
if new_index < -1:
return
@@ -521,32 +711,96 @@ class CommandPromptTextInput(TextInput):
class MessageBox(Popup):
class MessageBoxLabel(Label):
class MessageBoxLabel(MDLabel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._label.refresh()
self.size = self._label.texture.size
if self.width + 50 > Window.width:
self.text_size[0] = Window.width - 50
self._label.refresh()
self.size = self._label.texture.size
def __init__(self, title, text, error=False, **kwargs):
label = MessageBox.MessageBoxLabel(text=text)
separator_color = [217 / 255, 129 / 255, 122 / 255, 1.] if error else [47 / 255., 167 / 255., 212 / 255, 1.]
super().__init__(title=title, content=label, size_hint=(None, None), width=max(100, int(label.width) + 40),
super().__init__(title=title, content=label, size_hint=(0.5, None), width=max(100, int(label.width) + 40),
separator_color=separator_color, **kwargs)
self.height += max(0, label.height - 18)
class GameManager(App):
class ClientTabs(MDTabsSecondary):
carousel: MDTabsCarousel
lock_swiping = True
def __init__(self, *args, **kwargs):
self.carousel = MDTabsCarousel(lock_swiping=True, anim_move_duration=0.2)
super().__init__(*args, MDDivider(size_hint_y=None, height=dp(1)), self.carousel, **kwargs)
self.size_hint_y = 1
def _check_panel_height(self, *args):
self.ids.tab_scroll.height = dp(38)
def update_indicator(
self, x: float = 0.0, w: float = 0.0, instance: MDTabsItem = None
) -> None:
def update_indicator(*args):
indicator_pos = (0, 0)
indicator_size = (0, 0)
item_text_object = self._get_tab_item_text_icon_object()
if item_text_object:
indicator_pos = (
instance.x + dp(12),
self.indicator.pos[1]
if not self._tabs_carousel
else self._tabs_carousel.height,
)
indicator_size = (
instance.width - dp(24),
self.indicator_height,
)
Animation(
pos=indicator_pos,
size=indicator_size,
d=0 if not self.indicator_anim else self.indicator_duration,
t=self.indicator_transition,
).start(self.indicator)
if not instance:
self.indicator.pos = (x, self.indicator.pos[1])
self.indicator.size = (w, self.indicator_height)
else:
Clock.schedule_once(update_indicator)
def remove_tab(self, tab, content=None):
if content is None:
content = tab.content
self.ids.container.remove_widget(tab)
self.carousel.remove_widget(content)
self.on_size(self, self.size)
class CommandButton(MDButton, MDTooltip):
def __init__(self, *args, manager: "GameManager", **kwargs):
super().__init__(*args, **kwargs)
self.manager = manager
self._tooltip = ToolTip(text="Test")
def on_enter(self):
self._tooltip.text = self.manager.commandprocessor.get_help_text()
self._tooltip.font_size = dp(20 - (len(self._tooltip.text) // 400)) # mostly guessing on the numbers here
self.display_tooltip()
def on_leave(self):
self.animation_tooltip_dismiss()
class GameManager(ThemedApp):
logging_pairs = [
("Client", "Archipelago"),
]
base_title: str = "Archipelago Client"
last_autofillable_command: str
main_area_container: GridLayout
main_area_container: MDGridLayout
""" subclasses can add more columns beside the tabs """
def __init__(self, ctx: context_type):
@@ -581,45 +835,58 @@ class GameManager(App):
return max(1, len(self.tabs.tab_list))
return 1
def on_start(self):
def on_start(*args):
self.root.md_bg_color = self.theme_cls.backgroundColor
super().on_start()
Clock.schedule_once(on_start)
def build(self) -> Layout:
self.set_colors()
self.container = ContainerLayout()
self.grid = MainLayout()
self.grid.cols = 1
self.connect_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30))
self.connect_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40),
spacing=5, padding=(5, 10))
# top part
server_label = ServerLabel()
server_label = ServerLabel(width=dp(75))
self.connect_layout.add_widget(server_label)
self.server_connect_bar = ConnectBarTextInput(text=self.ctx.suggested_address or "archipelago.gg:",
size_hint_y=None,
height=dp(30), multiline=False, write_tab=False)
pos_hint={"center_x": 0.5, "center_y": 0.5})
def connect_bar_validate(sender):
if not self.ctx.server:
self.connect_button_action(sender)
self.server_connect_bar.height = dp(30)
self.server_connect_bar.bind(on_text_validate=connect_bar_validate)
self.connect_layout.add_widget(self.server_connect_bar)
self.server_connect_button = Button(text="Connect", size=(dp(100), dp(30)), size_hint_y=None, size_hint_x=None)
self.server_connect_button = MDButton(MDButtonText(text="Connect"), style="filled", size=(dp(100), dp(70)),
size_hint_x=None, size_hint_y=None, radius=5, pos_hint={"center_y": 0.55})
self.server_connect_button.bind(on_press=self.connect_button_action)
self.server_connect_button.height = self.server_connect_bar.height
self.connect_layout.add_widget(self.server_connect_button)
self.grid.add_widget(self.connect_layout)
self.progressbar = ProgressBar(size_hint_y=None, height=3)
self.progressbar = MDLinearProgressIndicator(size_hint_y=None, height=3)
self.grid.add_widget(self.progressbar)
# middle part
self.tabs = TabbedPanel(size_hint_y=1)
self.tabs.default_tab_text = "All"
self.tabs = ClientTabs(pos_hint={"center_x": 0.5, "center_y": 0.5})
self.tabs.add_widget(MDTabsItem(MDTabsItemText(text="All" if len(self.logging_pairs) > 1 else "Archipelago")))
self.log_panels["All"] = self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name)
for logger_name, name in
self.logging_pairs))
for logger_name, name in
self.logging_pairs))
self.tabs.carousel.add_widget(self.tabs.default_tab_content)
for logger_name, display_name in self.logging_pairs:
bridge_logger = logging.getLogger(logger_name)
panel = TabbedPanelItem(text=display_name)
self.log_panels[display_name] = panel.content = UILog(bridge_logger)
self.log_panels[display_name] = UILog(bridge_logger)
if len(self.logging_pairs) > 1:
panel = MDTabsItem(MDTabsItemText(text=display_name))
panel.content = self.log_panels[display_name]
# show Archipelago tab if other logging is present
self.tabs.carousel.add_widget(panel.content)
self.tabs.add_widget(panel)
hint_panel = self.add_client_tab("Hints", HintLayout())
@@ -627,21 +894,21 @@ class GameManager(App):
self.log_panels["Hints"] = hint_panel.content
hint_panel.content.add_widget(self.hint_log)
if len(self.logging_pairs) == 1:
self.tabs.default_tab_text = "Archipelago"
self.main_area_container = GridLayout(size_hint_y=1, rows=1)
self.main_area_container = MDGridLayout(size_hint_y=1, rows=1)
self.main_area_container.add_widget(self.tabs)
self.grid.add_widget(self.main_area_container)
# bottom part
bottom_layout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30))
info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None)
bottom_layout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40), spacing=5, padding=(5, 10))
info_button = CommandButton(MDButtonText(text="Command:", halign="left"), manager=self, radius=5,
style="filled", size=(dp(100), dp(70)), size_hint_x=None, size_hint_y=None,
pos_hint={"center_y": 0.575})
info_button.bind(on_release=self.command_button_action)
bottom_layout.add_widget(info_button)
self.textinput = CommandPromptTextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
self.textinput = CommandPromptTextInput(size_hint_y=None, multiline=False, write_tab=False)
self.textinput.bind(on_text_validate=self.on_message)
info_button.height = self.textinput.height
self.textinput.text_validate_unfocus = False
bottom_layout.add_widget(self.textinput)
self.grid.add_widget(bottom_layout)
@@ -657,29 +924,43 @@ class GameManager(App):
self.server_connect_bar.focus = True
self.server_connect_bar.select_text(port_start if port_start > 0 else host_start, len(s))
# Uncomment to enable the kivy live editor console
# Press Ctrl-E (with numlock/capslock) disabled to open
# from kivy.core.window import Window
# from kivy.modules import console
# console.create_console(Window, self.container)
return self.container
def add_client_tab(self, title: str, content: Widget) -> Widget:
def add_client_tab(self, title: str, content: Widget, index: int = -1) -> Widget:
"""Adds a new tab to the client window with a given title, and provides a given Widget as its content.
Returns the new tab widget, with the provided content being placed on the tab as content."""
new_tab = TabbedPanelItem(text=title)
new_tab = MDTabsItem(MDTabsItemText(text=title))
new_tab.content = content
self.tabs.add_widget(new_tab)
if -1 < index <= len(self.tabs.carousel.slides):
new_tab.bind(on_release=self.tabs.set_active_item)
new_tab._tabs = self.tabs
self.tabs.ids.container.add_widget(new_tab, index=index)
self.tabs.carousel.add_widget(new_tab.content, index=len(self.tabs.carousel.slides) - index)
else:
self.tabs.add_widget(new_tab)
self.tabs.carousel.add_widget(new_tab.content)
return new_tab
def update_texts(self, dt):
if hasattr(self.tabs.content.children[0], "fix_heights"):
self.tabs.content.children[0].fix_heights() # TODO: remove this when Kivy fixes this upstream
for slide in self.tabs.carousel.slides:
if hasattr(slide, "fix_heights"):
slide.fix_heights() # TODO: remove this when Kivy fixes this upstream
if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \
f" | Connected to: {self.ctx.server_address} " \
f"{'.'.join(str(e) for e in self.ctx.server_version)}"
self.server_connect_button.text = "Disconnect"
self.server_connect_button._button_text.text = "Disconnect"
self.server_connect_bar.readonly = True
self.progressbar.max = len(self.ctx.checked_locations) + len(self.ctx.missing_locations)
self.progressbar.value = len(self.ctx.checked_locations)
else:
self.server_connect_button.text = "Connect"
self.server_connect_button._button_text.text = "Connect"
self.server_connect_bar.readonly = False
self.title = self.base_title + " " + Utils.__version__
self.progressbar.value = 0
@@ -742,8 +1023,8 @@ class GameManager(App):
def enable_energy_link(self):
if not hasattr(self, "energy_link_label"):
self.energy_link_label = Label(text="Energy Link: Standby",
size_hint_x=None, width=150)
self.energy_link_label = MDLabel(text="Energy Link: Standby",
size_hint_x=None, width=150, halign="center")
self.connect_layout.add_widget(self.energy_link_label)
def set_new_energy_link_value(self):
@@ -779,8 +1060,9 @@ class LogtoUI(logging.Handler):
self.on_log(self.format(record))
class UILog(RecycleView):
class UILog(MDRecycleView):
messages: typing.ClassVar[int] # comes from kv file
adaptive_height = True
def __init__(self, *loggers_to_handle, **kwargs):
super(UILog, self).__init__(**kwargs)
@@ -807,17 +1089,24 @@ class UILog(RecycleView):
element.height = element.texture_size[1]
class HintLayout(BoxLayout):
class HintLayout(MDBoxLayout):
orientation = "vertical"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
boxlayout = BoxLayout(orientation="horizontal", size_hint_y=None, height=dp(30))
boxlayout.add_widget(Label(text="New Hint:", size_hint_x=None, size_hint_y=None, height=dp(30)))
boxlayout = MDBoxLayout(orientation="horizontal", size_hint_y=None, height=dp(40))
boxlayout.add_widget(MDLabel(text="New Hint:", size_hint_x=None, size_hint_y=None,
height=dp(40), width=dp(75), halign="center", valign="center"))
boxlayout.add_widget(AutocompleteHintInput())
self.add_widget(boxlayout)
def fix_heights(self):
for child in self.children:
fix_func = getattr(child, "fix_heights", None)
if fix_func:
fix_func()
status_names: typing.Dict[HintStatus, str] = {
HintStatus.HINT_FOUND: "Found",
HintStatus.HINT_UNSPECIFIED: "Unspecified",
@@ -840,8 +1129,7 @@ status_sort_weights: dict[HintStatus, int] = {
HintStatus.HINT_PRIORITY: 4,
}
class HintLog(RecycleView):
class HintLog(MDRecycleView):
header = {
"receiving": {"text": "[u]Receiving Player[/u]"},
"item": {"text": "[u]Item[/u]"},
@@ -852,7 +1140,7 @@ class HintLog(RecycleView):
"hint": {"receiving_player": -1, "location": -1, "finding_player": -1, "status": ""}},
"striped": True,
}
data: list[typing.Any]
sort_key: str = ""
reversed: bool = True
@@ -865,7 +1153,7 @@ class HintLog(RecycleView):
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
ctx = MDApp.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
@@ -915,6 +1203,7 @@ class HintLog(RecycleView):
class ApAsyncImage(AsyncImage):
def is_uri(self, filename: str) -> bool:
if filename.startswith("ap:"):
return True
@@ -929,7 +1218,8 @@ class ImageLoaderPkgutil(ImageLoaderBase):
data = pkgutil.get_data(module, path)
return self._bytes_to_data(data)
def _bytes_to_data(self, data: typing.Union[bytes, bytearray]) -> typing.List[ImageData]:
@staticmethod
def _bytes_to_data(data: typing.Union[bytes, bytearray]) -> typing.List[ImageData]:
loader = next(loader for loader in ImageLoader.loaders if loader.can_load_memory())
return loader.load(loader, io.BytesIO(data))
@@ -959,7 +1249,23 @@ class E(ExceptionHandler):
class KivyJSONtoTextParser(JSONtoTextParser):
# dummy class to absorb kvlang definitions
class TextColors(Widget):
pass
white: str = StringProperty("FFFFFF")
black: str = StringProperty("000000")
red: str = StringProperty("EE0000")
green: str = StringProperty("00FF7F")
yellow: str = StringProperty("FAFAD2")
blue: str = StringProperty("6495ED")
magenta: str = StringProperty("EE00EE")
cyan: str = StringProperty("00EEEE")
slateblue: str = StringProperty("6D8BE8")
plum: str = StringProperty("AF99EF")
salmon: str = StringProperty("FA8072")
orange: str = StringProperty("FF7700")
# KivyMD parameters
theme_style: str = StringProperty("Dark")
primary_palette: str = StringProperty("Lightsteelblue")
dynamic_scheme_name: str = StringProperty("VIBRANT")
dynamic_scheme_contrast: int = NumericProperty(0)
def __init__(self, *args, **kwargs):
# we grab the color definitions from the .kv file, then overwrite the JSONtoTextParser default entries

View File

@@ -1,5 +1,5 @@
[pytest]
python_files = test_*.py Test*.py # TODO: remove Test* once all worlds have been ported
python_files = test_*.py Test*.py __init__.py # TODO: remove Test* once all worlds have been ported
python_classes = Test
python_functions = test
testpaths =

View File

@@ -1,14 +1,18 @@
colorama>=0.4.6
websockets>=13.0.1,<14
PyYAML>=6.0.2
jellyfish>=1.1.0
jinja2>=3.1.4
jellyfish>=1.1.3
jinja2>=3.1.6
schema>=0.7.7
kivy>=2.3.0
bsdiff4>=1.2.4
platformdirs>=4.2.2
certifi>=2024.12.14
cython>=3.0.11
cymem>=2.0.8
orjson>=3.10.7
kivy>=2.3.1
bsdiff4>=1.2.6
platformdirs>=4.3.6
certifi>=2025.4.26
cython>=3.0.12
cymem>=2.0.11
orjson>=3.10.15
discord-webhook>=1.3.0
typing_extensions>=4.12.2
pyshortcuts>=1.9.1
kivymd @ git+https://github.com/kivymd/KivyMD@5ff9d0d
kivymd>=2.0.1.dev0

View File

@@ -10,9 +10,10 @@ import sys
import types
import typing
import warnings
from collections.abc import Iterator, Sequence
from enum import IntEnum
from threading import Lock
from typing import cast, Any, BinaryIO, ClassVar, Dict, Iterator, List, Optional, TextIO, Tuple, Union, TypeVar
from typing import cast, Any, BinaryIO, ClassVar, TextIO, TypeVar, Union
__all__ = [
"get_settings", "fmt_doc", "no_gui",
@@ -23,7 +24,7 @@ __all__ = [
no_gui = False
skip_autosave = False
_world_settings_name_cache: Dict[str, str] = {} # TODO: cache on disk and update when worlds change
_world_settings_name_cache: dict[str, str] = {} # TODO: cache on disk and update when worlds change
_world_settings_name_cache_updated = False
_lock = Lock()
@@ -53,7 +54,7 @@ def fmt_doc(cls: type, level: int) -> str:
class Group:
_type_cache: ClassVar[Optional[Dict[str, Any]]] = None
_type_cache: ClassVar[dict[str, Any] | None] = None
_dumping: bool = False
_has_attr: bool = False
_changed: bool = False
@@ -106,7 +107,7 @@ class Group:
self.__dict__.values()))
@classmethod
def get_type_hints(cls) -> Dict[str, Any]:
def get_type_hints(cls) -> dict[str, Any]:
"""Returns resolved type hints for the class"""
if cls._type_cache is None:
if not cls.__annotations__ or not isinstance(next(iter(cls.__annotations__.values())), str):
@@ -124,10 +125,10 @@ class Group:
return self[key]
return default
def items(self) -> List[Tuple[str, Any]]:
def items(self) -> list[tuple[str, Any]]:
return [(key, getattr(self, key)) for key in self]
def update(self, dct: Dict[str, Any]) -> None:
def update(self, dct: dict[str, Any]) -> None:
assert isinstance(dct, dict), f"{self.__class__.__name__}.update called with " \
f"{dct.__class__.__name__} instead of dict."
@@ -196,7 +197,7 @@ class Group:
warnings.warn(f"{self.__class__.__name__}.{k} "
f"assigned from incompatible type {type(v).__name__}")
def as_dict(self, *args: str, downcast: bool = True) -> Dict[str, Any]:
def as_dict(self, *args: str, downcast: bool = True) -> dict[str, Any]:
return {
name: _to_builtin(cast(object, getattr(self, name))) if downcast else getattr(self, name)
for name in self if not args or name in args
@@ -211,7 +212,7 @@ class Group:
f.write(f"{indent}{yaml_line}")
@classmethod
def _dump_item(cls, name: Optional[str], attr: object, f: TextIO, level: int) -> None:
def _dump_item(cls, name: str | None, attr: object, f: TextIO, level: int) -> None:
"""Write a group, dict or sequence item to f, where attr can be a scalar or a collection"""
# lazy construction of yaml Dumper to avoid loading Utils early
@@ -223,7 +224,7 @@ class Group:
def represent_mapping(self, tag: str, mapping: Any, flow_style: Any = None) -> MappingNode:
from yaml import ScalarNode
res: MappingNode = super().represent_mapping(tag, mapping, flow_style)
pairs = cast(List[Tuple[ScalarNode, Any]], res.value)
pairs = cast(list[tuple[ScalarNode, Any]], res.value)
for k, v in pairs:
k.style = None # remove quotes from keys
return res
@@ -329,9 +330,9 @@ class Path(str):
"""Marks the file as required and opens a file browser when missing"""
is_exe: bool = False
"""Special cross-platform handling for executables"""
description: Optional[str] = None
description: str | None = None
"""Title to display when browsing for the file"""
copy_to: Optional[str] = None
copy_to: str | None = None
"""If not None, copy to AP folder instead of linking it"""
@classmethod
@@ -339,7 +340,7 @@ class Path(str):
"""Overload and raise to validate input files from browse"""
pass
def browse(self: T, **kwargs: Any) -> Optional[T]:
def browse(self: T, **kwargs: Any) -> T | None:
"""Opens a file browser to search for the file"""
raise NotImplementedError(f"Please use a subclass of Path for {self.__class__.__name__}")
@@ -369,12 +370,12 @@ class _LocalPath(str):
class FilePath(Path):
# path to a file
md5s: ClassVar[List[Union[str, bytes]]] = []
md5s: ClassVar[list[str | bytes]] = []
"""MD5 hashes for default validator."""
def browse(self: T,
filetypes: Optional[typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]] = None, **kwargs: Any)\
-> Optional[T]:
filetypes: Sequence[tuple[str, Sequence[str]]] | None = None, **kwargs: Any)\
-> T | None:
from Utils import open_filename, is_windows
if not filetypes:
if self.is_exe:
@@ -439,7 +440,7 @@ class FilePath(Path):
class FolderPath(Path):
# path to a folder
def browse(self: T, **kwargs: Any) -> Optional[T]:
def browse(self: T, **kwargs: Any) -> T | None:
from Utils import open_directory
res = open_directory(f"Select {self.description or self.__class__.__name__}", self)
if res:
@@ -597,16 +598,16 @@ class ServerOptions(Group):
OFF = 0
ON = 1
host: Optional[str] = None
host: str | None = None
port: int = 38281
password: Optional[str] = None
multidata: Optional[str] = None
savefile: Optional[str] = None
password: str | None = None
multidata: str | None = None
savefile: str | None = None
disable_save: bool = False
loglevel: str = "info"
logtime: bool = False
server_password: Optional[ServerPassword] = None
disable_item_cheat: Union[DisableItemCheat, bool] = False
server_password: ServerPassword | None = None
disable_item_cheat: DisableItemCheat | bool = False
location_check_points: LocationCheckPoints = LocationCheckPoints(1)
hint_cost: HintCost = HintCost(10)
release_mode: ReleaseMode = ReleaseMode("auto")
@@ -702,7 +703,7 @@ does nothing if not found
"""
sni_path: SNIPath = SNIPath("SNI")
snes_rom_start: Union[SnesRomStart, bool] = True
snes_rom_start: SnesRomStart | bool = True
class BizHawkClientOptions(Group):
@@ -721,7 +722,7 @@ class BizHawkClientOptions(Group):
"""
emuhawk_path: EmuHawkPath = EmuHawkPath(None)
rom_start: Union[RomStart, bool] = True
rom_start: RomStart | bool = True
# Top-level group with lazy loading of worlds
@@ -733,7 +734,7 @@ class Settings(Group):
sni_options: SNIOptions = SNIOptions()
bizhawkclient_options: BizHawkClientOptions = BizHawkClientOptions()
_filename: Optional[str] = None
_filename: str | None = None
def __getattribute__(self, key: str) -> Any:
if key.startswith("_") or key in self.__class__.__dict__:
@@ -787,7 +788,7 @@ class Settings(Group):
return super().__getattribute__(key)
def __init__(self, location: Optional[str]): # change to PathLike[str] once we drop 3.8?
def __init__(self, location: str | None): # change to PathLike[str] once we drop 3.8?
super().__init__()
if location:
from Utils import parse_yaml
@@ -821,7 +822,7 @@ class Settings(Group):
import atexit
atexit.register(autosave)
def save(self, location: Optional[str] = None) -> None: # as above
def save(self, location: str | None = None) -> None: # as above
from Utils import parse_yaml
location = location or self._filename
assert location, "No file specified"
@@ -854,7 +855,7 @@ class Settings(Group):
super().dump(f, level)
@property
def filename(self) -> Optional[str]:
def filename(self) -> str | None:
return self._filename
@@ -867,7 +868,7 @@ def get_settings() -> Settings:
if not res:
from Utils import user_path, local_path
filenames = ("options.yaml", "host.yaml")
locations: List[str] = []
locations: list[str] = []
if os.path.join(os.getcwd()) != local_path():
locations += filenames # use files from cwd only if it's not the local_path
locations += [user_path(filename) for filename in filenames]

View File

@@ -1,25 +1,23 @@
import base64
import datetime
import io
import json
import os
import platform
import shutil
import subprocess
import sys
import sysconfig
import threading
import urllib.request
import warnings
import zipfile
import urllib.request
import io
import json
import threading
import subprocess
from collections.abc import Iterable, Sequence
from hashlib import sha3_512
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple, Union
# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it
requirement = 'cx-Freeze==7.2.0'
requirement = 'cx-Freeze==8.0.0'
try:
import pkg_resources
try:
@@ -60,7 +58,7 @@ from Cython.Build import cythonize
# On Python < 3.10 LogicMixin is not currently supported.
non_apworlds: Set[str] = {
non_apworlds: set[str] = {
"A Link to the Past",
"Adventure",
"ArchipIDLE",
@@ -72,7 +70,6 @@ non_apworlds: Set[str] = {
"Ocarina of Time",
"Overcooked! 2",
"Raft",
"Slay the Spire",
"Sudoku",
"Super Mario 64",
"VVVVVV",
@@ -148,13 +145,13 @@ def download_SNI() -> None:
print(f"No SNI found for system spec {platform_name} {machine_name}")
signtool: Optional[str]
signtool: str | None
if os.path.exists("X:/pw.txt"):
print("Using signtool")
with open("X:/pw.txt", encoding="utf-8-sig") as f:
pw = f.read()
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + \
r'" /fd sha256 /tr http://timestamp.digicert.com/ '
r'" /fd sha256 /td sha256 /tr http://timestamp.digicert.com/ '
else:
signtool = None
@@ -206,7 +203,7 @@ def remove_sprites_from_folder(folder: Path) -> None:
os.remove(folder / file)
def _threaded_hash(filepath: Union[str, Path]) -> str:
def _threaded_hash(filepath: str | Path) -> str:
hasher = sha3_512()
hasher.update(open(filepath, "rb").read())
return base64.b85encode(hasher.digest()).decode()
@@ -256,7 +253,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
self.libfolder = Path(self.buildfolder, "lib")
self.library = Path(self.libfolder, "library.zip")
def installfile(self, path: Path, subpath: Optional[Union[str, Path]] = None, keep_content: bool = False) -> None:
def installfile(self, path: Path, subpath: str | Path | None = None, keep_content: bool = False) -> None:
folder = self.buildfolder
if subpath:
folder /= subpath
@@ -375,7 +372,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
from worlds.AutoWorld import AutoWorldRegister
assert not non_apworlds - set(AutoWorldRegister.world_types), \
f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld"
folders_to_remove: List[str] = []
folders_to_remove: list[str] = []
disabled_worlds_folder = "worlds_disabled"
for entry in os.listdir(disabled_worlds_folder):
if os.path.isdir(os.path.join(disabled_worlds_folder, entry)):
@@ -447,12 +444,12 @@ class AppImageCommand(setuptools.Command):
("app-exec=", None, "The application to run inside the image."),
("yes", "y", 'Answer "yes" to all questions.'),
]
build_folder: Optional[Path]
dist_file: Optional[Path]
app_dir: Optional[Path]
build_folder: Path | None
dist_file: Path | None
app_dir: Path | None
app_name: str
app_exec: Optional[Path]
app_icon: Optional[Path] # source file
app_exec: Path | None
app_icon: Path | None # source file
app_id: str # lower case name, used for icon and .desktop
yes: bool
@@ -494,7 +491,7 @@ $APPDIR/$exe "$@"
""")
launcher_filename.chmod(0o755)
def install_icon(self, src: Path, name: Optional[str] = None, symlink: Optional[Path] = None) -> None:
def install_icon(self, src: Path, name: str | None = None, symlink: Path | None = None) -> None:
assert self.app_dir, "Invalid app_dir"
try:
from PIL import Image
@@ -557,7 +554,7 @@ $APPDIR/$exe "$@"
subprocess.call(f'ARCH={build_arch} ./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True)
def find_libs(*args: str) -> Sequence[Tuple[str, str]]:
def find_libs(*args: str) -> Sequence[tuple[str, str]]:
"""Try to find system libraries to be included."""
if not args:
return []
@@ -565,7 +562,7 @@ def find_libs(*args: str) -> Sequence[Tuple[str, str]]:
arch = build_arch.replace('_', '-')
libc = 'libc6' # we currently don't support musl
def parse(line: str) -> Tuple[Tuple[str, str, str], str]:
def parse(line: str) -> tuple[tuple[str, str, str], str]:
lib, path = line.strip().split(' => ')
lib, typ = lib.split(' ', 1)
for test_arch in ('x86-64', 'i386', 'aarch64'):
@@ -590,8 +587,8 @@ def find_libs(*args: str) -> Sequence[Tuple[str, str]]:
k: v for k, v in (parse(line) for line in data if "=>" in line)
}
def find_lib(lib: str, arch: str, libc: str) -> Optional[str]:
cache: Dict[Tuple[str, str, str], str] = getattr(find_libs, "cache")
def find_lib(lib: str, arch: str, libc: str) -> str | None:
cache: dict[tuple[str, str, str], str] = getattr(find_libs, "cache")
for k, v in cache.items():
if k == (lib, arch, libc):
return v
@@ -600,7 +597,7 @@ def find_libs(*args: str) -> Sequence[Tuple[str, str]]:
return v
return None
res: List[Tuple[str, str]] = []
res: list[tuple[str, str]] = []
for arg in args:
# try exact match, empty libc, empty arch, empty arch and libc
file = find_lib(arg, arch, libc)
@@ -629,12 +626,13 @@ cx_Freeze.setup(
ext_modules=cythonize("_speedups.pyx"),
options={
"build_exe": {
"packages": ["worlds", "kivy", "cymem", "websockets"],
"packages": ["worlds", "kivy", "cymem", "websockets", "kivymd"],
"includes": [],
"excludes": ["numpy", "Cython", "PySide2", "PIL",
"pandas", "zstandard"],
"pandas"],
"zip_includes": [],
"zip_include_packages": ["*"],
"zip_exclude_packages": ["worlds", "sc2"],
"zip_exclude_packages": ["worlds", "sc2", "kivymd"],
"include_files": [], # broken in cx 6.14.0, we use more special sauce now
"include_msvcr": False,
"replace_paths": ["*."],

View File

@@ -1,3 +1,4 @@
from typing import Callable
import unittest
from enum import IntEnum
@@ -34,7 +35,7 @@ def generate_entrance_pair(region: Region, name_suffix: str, group: int):
def generate_disconnected_region_grid(multiworld: MultiWorld, grid_side_length: int, region_size: int = 0,
region_type: type[Region] = Region):
region_creator: Callable[[str, int, MultiWorld], Region] = Region):
"""
Generates a grid-like region structure for ER testing, where menu is connected to the top-left region, and each
region "in vanilla" has 2 2-way exits going either down or to the right, until reaching the goal region in the
@@ -44,7 +45,7 @@ def generate_disconnected_region_grid(multiworld: MultiWorld, grid_side_length:
for col in range(grid_side_length):
index = row * grid_side_length + col
name = f"region{index}"
region = region_type(name, 1, multiworld)
region = region_creator(name, 1, multiworld)
multiworld.regions.append(region)
generate_locations(region_size, 1, region=region, tag=f"_{name}")
@@ -65,8 +66,10 @@ class TestEntranceLookup(unittest.TestCase):
"""tests that get_targets shuffles targets between groups when requested"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True)
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
for entrance in er_targets:
@@ -86,8 +89,10 @@ class TestEntranceLookup(unittest.TestCase):
"""tests that get_targets does not shuffle targets between groups when requested"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True)
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region]
for entrance in er_targets:
@@ -99,6 +104,30 @@ class TestEntranceLookup(unittest.TestCase):
group_order = [prev := group.randomization_group for group in retrieved_targets if prev != group.randomization_group]
self.assertEqual([ERTestGroups.TOP, ERTestGroups.BOTTOM], group_order)
def test_selective_dead_ends(self):
"""test that entrances that EntranceLookup has not been told to consider are ignored when finding dead-ends"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
exits_set = set([ex for region in multiworld.get_regions(1)
for ex in region.exits if not ex.connected_region
and ex.name != "region20_right" and ex.name != "region21_left"])
lookup = EntranceLookup(multiworld.worlds[1].random, coupled=True, usable_exits=exits_set)
er_targets = [entrance for region in multiworld.get_regions(1)
for entrance in region.entrances if not entrance.parent_region and
entrance.name != "region20_right" and entrance.name != "region21_left"]
for entrance in er_targets:
lookup.add(entrance)
# region 20 is the bottom left corner of the grid, and therefore only has a right entrance from region 21
# and a top entrance from region 15; since we've told lookup to ignore the right entrance from region 21,
# the top entrance from region 15 should be considered a dead-end
dead_end_region = multiworld.get_region("region20", 1)
for dead_end in dead_end_region.entrances:
if dead_end.name == "region20_top":
break
# there should be only this one dead-end
self.assertTrue(dead_end in lookup.dead_ends)
self.assertEqual(len(lookup.dead_ends), 1)
class TestBakeTargetGroupLookup(unittest.TestCase):
def test_lookup_generation(self):
@@ -148,7 +177,7 @@ class TestDisconnectForRandomization(unittest.TestCase):
e.randomization_group = 1
e.connect(r2)
disconnect_entrance_for_randomization(e)
disconnect_entrance_for_randomization(e, one_way_target_name="foo")
self.assertIsNone(e.connected_region)
self.assertEqual([], r1.entrances)
@@ -158,10 +187,22 @@ class TestDisconnectForRandomization(unittest.TestCase):
self.assertEqual(1, len(r2.entrances))
self.assertIsNone(r2.entrances[0].parent_region)
self.assertEqual("r2", r2.entrances[0].name)
self.assertEqual("foo", r2.entrances[0].name)
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
self.assertEqual(1, r2.entrances[0].randomization_group)
def test_disconnect_default_1way_no_vanilla_target_raises(self):
multiworld = generate_test_multiworld()
r1 = Region("r1", 1, multiworld)
r2 = Region("r2", 1, multiworld)
e = r1.create_exit("e")
e.randomization_type = EntranceType.ONE_WAY
e.randomization_group = 1
e.connect(r2)
with self.assertRaises(ValueError):
disconnect_entrance_for_randomization(e)
def test_disconnect_uses_alternate_group(self):
multiworld = generate_test_multiworld()
r1 = Region("r1", 1, multiworld)
@@ -171,7 +212,7 @@ class TestDisconnectForRandomization(unittest.TestCase):
e.randomization_group = 1
e.connect(r2)
disconnect_entrance_for_randomization(e, 2)
disconnect_entrance_for_randomization(e, 2, "foo")
self.assertIsNone(e.connected_region)
self.assertEqual([], r1.entrances)
@@ -181,7 +222,7 @@ class TestDisconnectForRandomization(unittest.TestCase):
self.assertEqual(1, len(r2.entrances))
self.assertIsNone(r2.entrances[0].parent_region)
self.assertEqual("r2", r2.entrances[0].name)
self.assertEqual("foo", r2.entrances[0].name)
self.assertEqual(EntranceType.ONE_WAY, r2.entrances[0].randomization_type)
self.assertEqual(2, r2.entrances[0].randomization_group)
@@ -218,7 +259,7 @@ class TestRandomizeEntrances(unittest.TestCase):
self.assertEqual(80, len(result.pairings))
self.assertEqual(80, len(result.placements))
def test_coupling(self):
def test_coupled(self):
"""tests that in coupled mode, all 2 way transitions have an inverse"""
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5)
@@ -236,6 +277,36 @@ class TestRandomizeEntrances(unittest.TestCase):
# if we didn't visit every placement the verification on_connect doesn't really mean much
self.assertEqual(len(result.placements), seen_placement_count)
def test_uncoupled_succeeds_stage1_indirect_condition(self):
multiworld = generate_test_multiworld()
menu = multiworld.get_region("Menu", 1)
generate_entrance_pair(menu, "_right", ERTestGroups.RIGHT)
end = Region("End", 1, multiworld)
multiworld.regions.append(end)
generate_entrance_pair(end, "_left", ERTestGroups.LEFT)
multiworld.register_indirect_condition(end, None)
result = randomize_entrances(multiworld.worlds[1], False, directionally_matched_group_lookup)
self.assertSetEqual({
("Menu_right", "End_left"),
("End_left", "Menu_right")
}, set(result.pairings))
def test_coupled_succeeds_stage1_indirect_condition(self):
multiworld = generate_test_multiworld()
menu = multiworld.get_region("Menu", 1)
generate_entrance_pair(menu, "_right", ERTestGroups.RIGHT)
end = Region("End", 1, multiworld)
multiworld.regions.append(end)
generate_entrance_pair(end, "_left", ERTestGroups.LEFT)
multiworld.register_indirect_condition(end, None)
result = randomize_entrances(multiworld.worlds[1], True, directionally_matched_group_lookup)
self.assertSetEqual({
("Menu_right", "End_left"),
("End_left", "Menu_right")
}, set(result.pairings))
def test_uncoupled(self):
"""tests that in uncoupled mode, no transitions have an (intentional) inverse"""
multiworld = generate_test_multiworld()
@@ -395,7 +466,7 @@ class TestRandomizeEntrances(unittest.TestCase):
entrance_type = CustomEntrance
multiworld = generate_test_multiworld()
generate_disconnected_region_grid(multiworld, 5, region_type=CustomRegion)
generate_disconnected_region_grid(multiworld, 5, region_creator=CustomRegion)
self.assertRaises(EntranceRandomizationError, randomize_entrances, multiworld.worlds[1], False,
directionally_matched_group_lookup)

View File

@@ -47,13 +47,39 @@ class TestIDs(unittest.TestCase):
"""Test that a game doesn't have item id overlap within its own datapackage"""
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):
self.assertEqual(len(world_type.item_id_to_name), len(world_type.item_name_to_id))
len_item_id_to_name = len(world_type.item_id_to_name)
len_item_name_to_id = len(world_type.item_name_to_id)
if len_item_id_to_name != len_item_name_to_id:
self.assertCountEqual(
world_type.item_id_to_name.values(),
world_type.item_name_to_id.keys(),
"\nThese items have overlapping ids with other items in its own world")
self.assertCountEqual(
world_type.item_id_to_name.keys(),
world_type.item_name_to_id.values(),
"\nThese items have overlapping names with other items in its own world")
self.assertEqual(len_item_id_to_name, len_item_name_to_id)
def test_duplicate_location_ids(self):
"""Test that a game doesn't have location id overlap within its own datapackage"""
for gamename, world_type in AutoWorldRegister.world_types.items():
with self.subTest(game=gamename):
self.assertEqual(len(world_type.location_id_to_name), len(world_type.location_name_to_id))
len_location_id_to_name = len(world_type.location_id_to_name)
len_location_name_to_id = len(world_type.location_name_to_id)
if len_location_id_to_name != len_location_name_to_id:
self.assertCountEqual(
world_type.location_id_to_name.values(),
world_type.location_name_to_id.keys(),
"\nThese locations have overlapping ids with other locations in its own world")
self.assertCountEqual(
world_type.location_id_to_name.keys(),
world_type.location_name_to_id.values(),
"\nThese locations have overlapping names with other locations in its own world")
self.assertEqual(len_location_id_to_name, len_location_name_to_id)
def test_postgen_datapackage(self):
"""Generates a solo multiworld and checks that the datapackage is still valid"""

View File

@@ -53,6 +53,22 @@ class TestImplemented(unittest.TestCase):
if failed_world_loads:
self.fail(f"The following worlds failed to load: {failed_world_loads}")
def test_prefill_items(self):
"""Test that every world can reach every location from allstate before pre_fill."""
for gamename, world_type in AutoWorldRegister.world_types.items():
if gamename not in ("Archipelago", "Sudoku", "Final Fantasy", "Test Game"):
with self.subTest(gamename):
multiworld = setup_solo_multiworld(world_type, ("generate_early", "create_regions", "create_items",
"set_rules", "connect_entrances", "generate_basic"))
allstate = multiworld.get_all_state(False)
locations = multiworld.get_locations()
reachable = multiworld.get_reachable_locations(allstate)
unreachable = [location for location in locations if location not in reachable]
self.assertTrue(not unreachable,
f"Locations were not reachable with all state before prefill: "
f"{unreachable}. Seed: {multiworld.seed}")
def test_explicit_indirect_conditions_spheres(self):
"""Tests that worlds using explicit indirect conditions produce identical spheres as when using implicit
indirect conditions"""

View File

@@ -1,6 +1,11 @@
import unittest
from argparse import Namespace
from typing import Type
from worlds.AutoWorld import AutoWorldRegister, call_all
from BaseClasses import CollectionState, MultiWorld
from Fill import distribute_items_restrictive
from Options import ItemLinks
from worlds.AutoWorld import AutoWorldRegister, World, call_all
from . import setup_solo_multiworld
@@ -8,12 +13,31 @@ class TestBase(unittest.TestCase):
def test_create_item(self):
"""Test that a world can successfully create all items in its datapackage"""
for game_name, world_type in AutoWorldRegister.world_types.items():
proxy_world = setup_solo_multiworld(world_type, ()).worlds[1]
multiworld = setup_solo_multiworld(world_type, steps=("generate_early", "create_regions", "create_items"))
proxy_world = multiworld.worlds[1]
for item_name in world_type.item_name_to_id:
test_state = CollectionState(multiworld)
with self.subTest("Create Item", item_name=item_name, game_name=game_name):
item = proxy_world.create_item(item_name)
with self.subTest("Item Name", item_name=item_name, game_name=game_name):
self.assertEqual(item.name, item_name)
if item.advancement:
with self.subTest("Item State Collect", item_name=item_name, game_name=game_name):
test_state.collect(item, True)
with self.subTest("Item State Remove", item_name=item_name, game_name=game_name):
test_state.remove(item)
self.assertEqual(test_state.prog_items, multiworld.state.prog_items,
"Item Collect -> Remove should restore empty state.")
else:
with self.subTest("Item State Collect No Change", item_name=item_name, game_name=game_name):
# Non-Advancement should not modify state.
test_state.collect(item)
self.assertEqual(test_state.prog_items, multiworld.state.prog_items)
def test_item_name_group_has_valid_item(self):
"""Test that all item name groups contain valid items. """
# This cannot test for Event names that you may have declared for logic, only sendable Items.
@@ -63,6 +87,47 @@ class TestBase(unittest.TestCase):
multiworld = setup_solo_multiworld(world_type)
for item in multiworld.itempool:
self.assertIn(item.name, world_type.item_name_to_id)
def test_item_links(self) -> None:
"""
Tests item link creation by creating a multiworld of 2 worlds for every game and linking their items together.
"""
def setup_link_multiworld(world: Type[World], link_replace: bool) -> None:
multiworld = MultiWorld(2)
multiworld.game = {1: world.game, 2: world.game}
multiworld.player_name = {1: "Linker 1", 2: "Linker 2"}
multiworld.set_seed()
item_link_group = [{
"name": "ItemLinkTest",
"item_pool": ["Everything"],
"link_replacement": link_replace,
"replacement_item": None,
}]
args = Namespace()
for name, option in world.options_dataclass.type_hints.items():
setattr(args, name, {1: option.from_any(option.default), 2: option.from_any(option.default)})
setattr(args, "item_links",
{1: ItemLinks.from_any(item_link_group), 2: ItemLinks.from_any(item_link_group)})
multiworld.set_options(args)
multiworld.set_item_links()
# groups get added to state during its constructor so this has to be after item links are set
multiworld.state = CollectionState(multiworld)
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "connect_entrances", "generate_basic")
for step in gen_steps:
call_all(multiworld, step)
# link the items together and attempt to fill
multiworld.link_items()
multiworld._all_state = None
call_all(multiworld, "pre_fill")
distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill")
self.assertTrue(multiworld.can_beat_game(CollectionState(multiworld)), f"seed = {multiworld.seed}")
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Can generate with link replacement", game=game_name):
setup_link_multiworld(world_type, True)
with self.subTest("Can generate without link replacement", game=game_name):
setup_link_multiworld(world_type, False)
def test_itempool_not_modified(self):
"""Test that worlds don't modify the itempool after `create_items`"""

View File

@@ -0,0 +1,14 @@
import unittest
import os
class TestPackages(unittest.TestCase):
def test_packages_have_init(self):
"""Test that all world folders containing .py files also have a __init__.py file,
to indicate full package rather than namespace package."""
import Utils
worlds_path = Utils.local_path("worlds")
for dirpath, dirnames, filenames in os.walk(worlds_path):
with self.subTest(directory=dirpath):
self.assertEqual("__init__.py" in filenames, any(file.endswith(".py") for file in filenames))

View File

@@ -0,0 +1,11 @@
import unittest
from worlds.AutoWorld import AutoWorldRegister
from worlds.Files import AutoPatchRegister
class TestPatches(unittest.TestCase):
def test_patch_name_matches_game(self) -> None:
for game_name in AutoPatchRegister.patch_types:
with self.subTest(game=game_name):
self.assertIn(game_name, AutoWorldRegister.world_types.keys(),
f"Patch '{game_name}' does not match the name of any world.")

View File

@@ -0,0 +1,19 @@
import unittest
import os
class TestBase(unittest.TestCase):
def test_requirements_file_ends_on_newline(self):
"""Test that all requirements files end on a newline"""
import Utils
requirements_files = [Utils.local_path("requirements.txt"),
Utils.local_path("WebHostLib", "requirements.txt")]
worlds_path = Utils.local_path("worlds")
for entry in os.listdir(worlds_path):
requirements_path = os.path.join(worlds_path, entry, "requirements.txt")
if os.path.isfile(requirements_path):
requirements_files.append(requirements_path)
for requirements_file in requirements_files:
with self.subTest(path=requirements_file):
with open(requirements_file) as f:
self.assertEqual(f.read()[-1], "\n")

View File

@@ -26,4 +26,4 @@ class TestBase(unittest.TestCase):
for step in self.test_steps:
with self.subTest("Step", step=step):
call_all(multiworld, step)
self.assertTrue(multiworld.get_all_state(False, True))
self.assertTrue(multiworld.get_all_state(False, allow_partial_entrances=True))

View File

@@ -80,8 +80,8 @@ class Client:
"version": {
"class": "Version",
"major": 0,
"minor": 4,
"build": 6,
"minor": 6,
"build": 0,
},
"items_handling": 0,
"tags": [],

View File

@@ -1,13 +1,12 @@
import re
import shutil
from pathlib import Path
from typing import Dict
__all__ = ["copy", "delete"]
_new_worlds: Dict[str, str] = {}
_new_worlds: dict[str, str] = {}
def copy(src: str, dst: str) -> None:

View File

@@ -1,5 +1,5 @@
import unittest
from typing import List, Tuple
from typing import ClassVar, List, Tuple
from unittest import TestCase
from BaseClasses import CollectionState, Location, MultiWorld
@@ -7,6 +7,7 @@ from Fill import distribute_items_restrictive
from Options import Accessibility
from worlds.AutoWorld import AutoWorldRegister, call_all, call_single
from ..general import gen_steps, setup_multiworld
from ..param import classvar_matrix
class MultiworldTestBase(TestCase):
@@ -63,15 +64,18 @@ class TestAllGamesMultiworld(MultiworldTestBase):
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
@classvar_matrix(game=AutoWorldRegister.world_types.keys())
class TestTwoPlayerMulti(MultiworldTestBase):
game: ClassVar[str]
def test_two_player_single_game_fills(self) -> None:
"""Tests that a multiworld of two players for each registered game world can generate."""
for world_type in AutoWorldRegister.world_types.values():
self.multiworld = setup_multiworld([world_type, world_type], ())
for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_full
self.assertSteps(gen_steps)
with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")
world_type = AutoWorldRegister.world_types[self.game]
self.multiworld = setup_multiworld([world_type, world_type], ())
for world in self.multiworld.worlds.values():
world.options.accessibility.value = Accessibility.option_full
self.assertSteps(gen_steps)
with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed):
distribute_items_restrictive(self.multiworld)
call_all(self.multiworld, "post_fill")
self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game")

46
test/param.py Normal file
View File

@@ -0,0 +1,46 @@
import itertools
import sys
from typing import Any, Callable, Iterable
def classvar_matrix(**kwargs: Iterable[Any]) -> Callable[[type], None]:
"""
Create a new class for each variation of input, allowing to generate a TestCase matrix / parametrization that
supports multi-threading and has better reporting for ``unittest --durations=...`` and ``pytest --durations=...``
than subtests.
The kwargs will be set as ClassVars in the newly created classes. Use as ::
@classvar_matrix(var_name=[value1, value2])
class MyTestCase(unittest.TestCase):
var_name: typing.ClassVar[...]
:param kwargs: A dict of ClassVars to set, where key is the variable name and value is a list of all values.
:return: A decorator to be applied to a class.
"""
keys: tuple[str]
values: Iterable[Iterable[Any]]
keys, values = zip(*kwargs.items())
values = map(lambda v: sorted(v) if isinstance(v, (set, frozenset)) else v, values)
permutations_dicts = [dict(zip(keys, v)) for v in itertools.product(*values)]
def decorator(cls: type) -> None:
mod = sys.modules[cls.__module__]
for permutation in permutations_dicts:
class Unrolled(cls): # type: ignore
pass
for k, v in permutation.items():
setattr(Unrolled, k, v)
params = ", ".join([f"{k}={repr(v)}" for k, v in permutation.items()])
params = f"{{{params}}}"
Unrolled.__module__ = cls.__module__
Unrolled.__qualname__ = f"{cls.__qualname__}{params}"
setattr(mod, f"{cls.__name__}{params}", Unrolled)
return None
return decorator

View File

@@ -47,17 +47,6 @@ class TestCommonContext(unittest.IsolatedAsyncioTestCase):
assert "Archipelago" in self.ctx.item_names, "Archipelago item names entry does not exist"
assert "Archipelago" in self.ctx.location_names, "Archipelago location names entry does not exist"
async def test_implicit_name_lookups(self):
# Items
assert self.ctx.item_names[2**54 + 1] == "Test Item 1 - Safe"
assert self.ctx.item_names[2**54 + 3] == f"Unknown item (ID: {2**54+3})"
assert self.ctx.item_names[-1] == "Nothing"
# Locations
assert self.ctx.location_names[2**54 + 1] == "Test Location 1 - Safe"
assert self.ctx.location_names[2**54 + 3] == f"Unknown location (ID: {2**54+3})"
assert self.ctx.location_names[-1] == "Cheat Console"
async def test_explicit_name_lookups(self):
# Items
assert self.ctx.item_names["__TestGame1"][2**54+1] == "Test Item 1 - Safe"

View File

@@ -35,6 +35,19 @@ class TestCacheSelf1(unittest.TestCase):
self.assertFalse(o1.func(1) is o1.func(2))
self.assertFalse(o1.func(1) is o2.func(1))
def test_cache_default(self) -> None:
class Cls:
@cache_self1
def func(self, _: Any = 1) -> object:
return object()
o1 = Cls()
o2 = Cls()
self.assertIs(o1.func(), o1.func())
self.assertIs(o1.func(1), o1.func())
self.assertIsNot(o1.func(2), o1.func())
self.assertIsNot(o1.func(), o2.func())
def test_gc(self) -> None:
# verify that we don't keep a global reference
import gc

View File

@@ -2,7 +2,7 @@ import unittest
from BaseClasses import PlandoOptions
from worlds import AutoWorldRegister
from Options import ItemDict, NamedRange, NumericOption, OptionList, OptionSet
from Options import OptionCounter, NamedRange, NumericOption, OptionList, OptionSet
class TestOptionPresets(unittest.TestCase):
@@ -19,7 +19,7 @@ class TestOptionPresets(unittest.TestCase):
# 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]
supported_types = [NumericOption, OptionSet, OptionList, OptionCounter]
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}' "
f"is not a supported type for webhost. "

View File

@@ -12,7 +12,7 @@ def load_tests(loader, standard_tests, pattern):
all_tests = [
test_case for folder in folders if os.path.exists(folder)
for test_collection in loader.discover(folder, top_level_dir=file_path)
for test_suite in test_collection
for test_suite in test_collection if isinstance(test_suite, unittest.suite.TestSuite)
for test_case in test_suite
]

View File

@@ -9,7 +9,8 @@ from worlds.LauncherComponents import Component, SuffixIdentifier, Type, compone
if TYPE_CHECKING:
from SNIClient import SNIContext
component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe"))
component = Component('SNI Client', 'SNIClient', component_type=Type.CLIENT, file_identifier=SuffixIdentifier(".apsoe"),
description="A client for connecting to SNES consoles via Super Nintendo Interface.")
components.append(component)

View File

@@ -12,6 +12,7 @@ from typing import (Any, Callable, ClassVar, Dict, FrozenSet, Iterable, List, Ma
from Options import item_and_loc_options, ItemsAccessibility, OptionGroup, PerGameCommonOptions
from BaseClasses import CollectionState
from Utils import deprecate
if TYPE_CHECKING:
from BaseClasses import MultiWorld, Item, Location, Tutorial, Region, Entrance
@@ -75,19 +76,20 @@ class AutoWorldRegister(type):
# TODO - remove this once all worlds use options dataclasses
if "options_dataclass" not in dct and "option_definitions" in dct:
# TODO - switch to deprecate after a version
if __debug__:
logging.warning(f"{name} Assigned options through option_definitions which is now deprecated. "
"Please use options_dataclass instead.")
deprecate(f"{name} Assigned options through option_definitions which is now deprecated. "
"Please use options_dataclass instead.")
dct["options_dataclass"] = make_dataclass(f"{name}Options", dct["option_definitions"].items(),
bases=(PerGameCommonOptions,))
# construct class
new_class = super().__new__(mcs, name, bases, dct)
new_class.__file__ = sys.modules[new_class.__module__].__file__
if "game" in dct:
if dct["game"] in AutoWorldRegister.world_types:
raise RuntimeError(f"""Game {dct["game"]} already registered.""")
raise RuntimeError(f"""Game {dct["game"]} already registered in
{AutoWorldRegister.world_types[dct["game"]].__file__} when attempting to register from
{new_class.__file__}.""")
AutoWorldRegister.world_types[dct["game"]] = new_class
new_class.__file__ = sys.modules[new_class.__module__].__file__
if ".apworld" in new_class.__file__:
new_class.zip_path = pathlib.Path(new_class.__file__).parents[1]
if "settings_key" not in dct:
@@ -110,6 +112,16 @@ class AutoLogicRegister(type):
elif not item_name.startswith("__"):
if hasattr(CollectionState, item_name):
raise Exception(f"Name conflict on Logic Mixin {name} trying to overwrite {item_name}")
assert callable(function) or "init_mixin" in dct, (
f"{name} defined class variable {item_name} without also having init_mixin.\n\n"
"Explanation:\n"
"Class variables that will be mutated need to be inintialized as instance variables in init_mixin.\n"
"If your LogicMixin variables aren't actually mutable / you don't intend to mutate them, "
"there is no point in using LogixMixin.\n"
"LogicMixin exists to track custom state variables that change when items are collected/removed."
)
setattr(CollectionState, item_name, function)
return new_class
@@ -473,7 +485,7 @@ class World(metaclass=AutoWorldRegister):
def get_filler_item_name(self) -> str:
"""Called when the item pool needs to be filled with additional items to match location count."""
logging.warning(f"World {self} is generating a filler item without custom filler pool.")
return self.multiworld.random.choice(tuple(self.item_name_to_id.keys()))
return self.random.choice(tuple(self.item_name_to_id.keys()))
@classmethod
def create_group(cls, multiworld: "MultiWorld", new_player_id: int, players: Set[int]) -> World:

View File

@@ -27,6 +27,8 @@ class Component:
"""
display_name: str
"""Used as the GUI button label and the component name in the CLI args"""
description: str
"""Optional description displayed on the GUI underneath the display name"""
type: Type
"""
Enum "Type" classification of component intent, for filtering in the Launcher GUI
@@ -58,8 +60,9 @@ class Component:
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
cli: bool = False, icon: str = 'icon', component_type: Optional[Type] = None,
func: Optional[Callable] = None, file_identifier: Optional[Callable[[str], bool]] = None,
game_name: Optional[str] = None, supports_uri: Optional[bool] = False):
game_name: Optional[str] = None, supports_uri: Optional[bool] = False, description: str = "") -> None:
self.display_name = display_name
self.description = description
self.script_name = script_name
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
self.icon = icon
@@ -88,7 +91,6 @@ processes = weakref.WeakSet()
def launch_subprocess(func: Callable, name: str | None = None, args: Tuple[str, ...] = ()) -> None:
global processes
import multiprocessing
process = multiprocessing.Process(target=func, name=name, args=args)
process.start()

View File

@@ -41,6 +41,7 @@ class BizHawkClientCommandProcessor(ClientCommandProcessor):
class BizHawkClientContext(CommonContext):
command_processor = BizHawkClientCommandProcessor
server_seed_name: str | None = None
auth_status: AuthStatus
password_requested: bool
client_handler: BizHawkClient | None
@@ -68,6 +69,8 @@ class BizHawkClientContext(CommonContext):
if cmd == "Connected":
self.slot_data = args.get("slot_data", None)
self.auth_status = AuthStatus.AUTHENTICATED
elif cmd == "RoomInfo":
self.server_seed_name = args.get("seed_name", None)
if self.client_handler is not None:
self.client_handler.on_package(self, cmd, args)
@@ -100,6 +103,7 @@ class BizHawkClientContext(CommonContext):
async def disconnect(self, allow_autoreconnect: bool=False):
self.auth_status = AuthStatus.NOT_AUTHENTICATED
self.server_seed_name = None
await super().disconnect(allow_autoreconnect)
@@ -272,6 +276,6 @@ def launch(*launch_args: str) -> None:
Utils.init_logging("BizHawkClient", exception_logger="Client")
import colorama
colorama.init()
colorama.just_fix_windows_console()
asyncio.run(main())
colorama.deinit()

View File

@@ -3,4 +3,4 @@ mpyq>=0.2.5
portpicker>=1.5.2
aiohttp>=3.8.4
loguru>=0.7.0
protobuf==3.20.3
protobuf==3.20.3

View File

@@ -238,14 +238,12 @@ class AdventureWorld(World):
def create_regions(self) -> None:
create_regions(self.options, self.multiworld, self.player, self.dragon_rooms)
set_rules = set_rules
def generate_basic(self) -> None:
self.multiworld.get_location("Chalice Home", self.player).place_locked_item(
self.create_event("Victory", ItemClassification.progression))
self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
set_rules = set_rules
def pre_fill(self):
# Place empty items in filler locations here, to limit
# the number of exported empty items and the density of stuff in overworld.

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