Compare commits

...

154 Commits

Author SHA1 Message Date
Justus Lind
a9b35de7ee Muse Dash: Update song list to Rotaeno Update/7th Anniversary (#5066) 2025-07-12 09:02:49 -04:00
Scipio Wright
125d053b61 TUNIC: Fix missing line for UT stuff #5185 2025-07-12 13:52:02 +02:00
Scipio Wright
585cbf95a6 TUNIC: Add UT Support for Breakables (#5182) 2025-07-12 07:14:34 -04:00
Jérémie Bolduc
909565e5d9 Stardew Valley: Remove Rarecrow Locations from Night Market when Museumsanity is Disabled (#5146) 2025-07-12 07:12:04 -04:00
Exempt-Medic
a79423534c LADX: Update marin.txt (#5178) 2025-07-11 18:44:26 -04:00
NewSoupVi
7a6fb5e35b Revert "Core: Take Counter back out of RestrictedUnpickler" (#5184)
* Revert "Core: Take Counter back out of RestrictedUnpickler #5169"

This reverts commit 95e09c8e2a.

* Update Utils.py
2025-07-11 23:28:18 +02:00
Zach “Phar” Parks
6af34b66fb Various: Remove Rogue Legacy and Clique (#5177)
* Various: Remove Rogue Legacy and Clique

* Remove Clique from setup.py and revert network diagram.md change.

* Try again.

* Update network diagram.md

---------

Co-authored-by: Zach “Phar” Parks <phar@pharware.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-07-11 19:34:46 +02:00
NewSoupVi
2974f7d11f Core: Replace Clique with V6 in unit tests (#5181)
* replace Clique with V6 in unit tests

* no hard mode in V6

* modify regex in copy_world to allow : str

* oops

* I see now

* work around all typing

* there actually needs to be something
2025-07-11 19:27:28 +02:00
Carter Hesterman
edc0c89753 CIV 6: Remove Erroneous Boost Prereqs for Computers Boost (#5134) 2025-07-10 09:10:56 -04:00
axe-y
b1ff55dd06 DLCQ: Fix/Refactor LFoD Start Inventory (#5176) 2025-07-10 08:33:52 -04:00
Remy Jette
f4b5422f66 Factorio: Fix link to world_gen documentation (#5171) 2025-07-07 22:57:55 +02:00
massimilianodelliubaldini
d4ebace99f [Jak and Daxter] Auto Detect Install Path after Game Launcher Update #5152 2025-07-07 19:15:37 +02:00
NewSoupVi
95e09c8e2a Core: Take Counter back out of RestrictedUnpickler #5169 2025-07-07 16:24:35 +02:00
Fabian Dill
4623d59206 Core: ensure slot_data and er_hint_info are only base data types (#5144)
---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-07-07 15:51:39 +02:00
Doug Hoskisson
e68b1ad428 CommonClient: fix extra panels added to main_area_container (#5151) 2025-07-06 19:22:02 +02:00
Ixrec
072e2ece15 Docs: 'get_prefill_items' -> 'get_pre_fill_items' (#5167) 2025-07-05 17:01:08 -04:00
agilbert1412
11130037fe Stardew Valley: Fixed luck level requirements for slot machines #5160
# Conflicts:
#	worlds/stardew_valley/data/craftable_data.py
2025-07-03 21:08:36 +02:00
Scipio Wright
ba66ef14cc Update world api.md (#5149) 2025-07-02 14:14:35 +02:00
Jérémie Bolduc
8aacc23882 SDV: Add "Desert Transportation" and "Island Transportation" Item Groups (#5143) 2025-06-28 11:36:09 -04:00
Jonathan Tan
03e5fd3dae TWW: Fix Swords in Swordless Mode (#5137)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-06-28 10:46:37 -04:00
Fly Hyping
da52598c08 Wargroove: Fix Communication Thread (#5125) 2025-06-27 19:42:35 -04:00
Jonathan Tan
52389731eb TWW: Update Preset S7 to S8 (#5138) 2025-06-27 18:46:00 -04:00
LiquidCat64
21864f6f95 CVCotM: Fix Advance Collection ROM (#5132) 2025-06-27 18:25:45 -04:00
DJ-lennart
00f8625280 Civilization VI: Updated setup and info pages (#5123)
* Update setup_en.md

Updated setup instructions for Civilization VI in Archipelago

* Update en_Civilization VI.md

Updated info page for Civilization VI in Archipelago

* Update setup_en.md
2025-06-21 16:31:12 +02:00
James White
c34e29c712 Pokemon RB: Client: Send bounce messages with current map ID (#5121) 2025-06-20 22:52:54 +02:00
palex00
e0ae3359f1 Pokémon RB: Use new link for a new tracker (#5122)
* Update setup_en.md

* Update setup_es.md
2025-06-20 20:55:49 +02:00
Katelyn Gigante
c2666bacd7 core: Don't attempt to write to the inside of an OSX App Bundle (#4380)
* core: Frozen OSX should also use Home Directory

* Use Application Support instead of homedir

* Suggested changes
2025-06-19 18:05:52 +02:00
Aaron Wagener
4eefd9c3ce Kivy: swap from the tab carousel to navigation bar (#4930)
* implement tabs as NavigationBar

* update the underline bar with the screen manager

* remove some unneeded kv

* remove the underline in favor of a full tab highlight

* fix insert transitions

* use on_release instead of on_press

* minor cleanup

* add remove_client_tab and add a caller to the NavigationBar for back compat

* unused imports

* Update kvui.py

---------

Co-authored-by: Silvris <58583688+Silvris@users.noreply.github.com>
Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-06-19 13:39:26 +02:00
Silvris
211456242e KDL3: update to gifting protocol 3 and update settings usage (#4814)
* gift version 3

* update settings usage

* that really has just been broken this entire time

* remove unnecessary print

* Update client.py

* fix random flavor handling

* fix incorrect sender/receiver

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-06-16 13:00:47 -04:00
massimilianodelliubaldini
6f244c4661 Docs: Update Plando Guide and Make it More User Friendly (#4858)
* Make plando guide more user friendly.

* Apply suggestions from code review

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

* Further updates for review.

* Clear search box when filtering by type.

* Forget previous commit name - more code review updates to doc.

* Move link to yaml tutorial.

* Replace STS example with Pokemon RB.

* Use non-key item examples in RB.

* Rooby's code review updates.

* Update worlds/generic/docs/plando_en.md

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

* Update worlds/generic/docs/plando_en.md

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

* Address some more feedback.

* Make Factorio example more accurate.

* Exempt's code review updates (round 4)

* Exempt's code review updates (round 4 + 1)

* Update worlds/generic/docs/plando_en.md

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

* Update worlds/generic/docs/plando_en.md

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

* Update worlds/generic/docs/plando_en.md

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

* Update worlds/generic/docs/plando_en.md

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

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-06-16 12:54:08 -04:00
Exempt-Medic
47bf6d724b Minecraft Removal Cleanup (#5118) 2025-06-16 10:56:47 -04:00
Ixrec
5c710ad032 Docs: Rework the "Events" Section of world api.md (#5012)
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-06-16 08:36:12 -04:00
BlastSlimey
dda5a05cbb shapez: Change Links to Shapesanity Cheat Sheet (#5047) 2025-06-16 08:07:27 -04:00
Natalie Weizenbaum
e0a63e0290 DS3: Link to the Appropriate .NET Runtime for Proton (#5093) 2025-06-16 08:02:06 -04:00
NewSoupVi
9246659589 Make sure ladx removes the same copy of the starting item from the itempool that it's placing (#5110) 2025-06-16 13:49:30 +02:00
digiholic
377cdb84b4 MMBN3: Fixes Generation Errors and General UX Smoothing (#5077)
Co-authored-by: qwint <qwint.42@gmail.com>
2025-06-16 07:47:55 -04:00
KonoTyran
0e759f25fd Remove Minecraft (#4672)
* Remove Minecraft

* remove minecraft

* remove minecraft

* elif -> if

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-06-16 12:31:16 +02:00
qwint
b408bb4f6e Core: Docstring typo on Region.add_exits (#5089)
* doc typo

* Update BaseClasses.py
2025-06-16 02:31:12 +02:00
JusticePS
1356479415 AdventureClient: Replace Utils.get_settings with settings.get_settings #5043 2025-06-16 01:30:45 +02:00
Exempt-Medic
ec5b4e704f Plando Items: Better Warning for Nonexisting Worlds (#5112) 2025-06-14 09:28:02 -04:00
Exempt-Medic
aa9e617510 DS3: Apply Rules to Non-Randomized Locations (#5106) 2025-06-14 09:27:22 -04:00
Exempt-Medic
ecb739ce96 Plando Items: Fix Location Groups Unfolding (#5099) 2025-06-14 09:26:58 -04:00
Exempt-Medic
3b72140435 Shivers: Fix get_pre_fill_items (#5113) 2025-06-14 09:26:22 -04:00
Louis M
27a6770569 Aquaria: Fixing open waters urns not breakable with nature forms logic bug (#5072)
* Fixing open waters urns not breakable with nature forms logic bug

* Using list in comprehension only when useful

* Replacing damaging items by a constant

* Removing comprehension list creating from lambda
2025-06-14 13:17:33 +02:00
NewSoupVi
2ff611167a ALTTP: Fix take_any leaving a placed item in the multiworld itempool #5108 2025-06-14 12:21:25 +02:00
agilbert1412
e83e178b63 Stardew Valley: Fix 3 Logic Issues (#5094)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-06-13 20:29:23 -04:00
Exempt-Medic
068a757373 Item Plando: Fix count value (#5101) 2025-06-13 20:29:06 -04:00
PoryGone
0ad4527719 SA2B: Logic Fixes (#5095)
- Fixed King Boom Boo being able to appear in multiple boss gates
- `Final Rush - 16 Animals (Expert)` no longer requires `Sonic - Bounce Bracelet`
- `Dry Lagoon - 5 (Standard)` now requires `Rouge - Pick Nails`
- `Sand Ocean - Extra Life Box 2 (Standard/Hard/Expert)` no longer requires `Eggman - Jet Engine`
- `Security Hall - 8 Animals (Expert)` no longer requires `Rouge - Pick Nails`
- `Sky Rail - Item Box 8 (Standard)` now requires `Shadow - Air Shoes` and `Shadow - Mystic Melody`
- `Cosmic Wall - Chao Key 1 (Standard/Hard/Expert)` no longer requires `Eggman - Mystic Melody`
- `Cannon's Core - Pipe 2 (Expert)` no longer requires `Tails - Booster`
- `Cannon's Core - Gold Beetle` no longer requires `Tails - Booster` nor `Knuckles - Hammer Gloves`
2025-06-13 22:01:19 +02:00
qwint
8c6327d024 LTTP/SDV: use .name when appropriate in subtests (#5107) 2025-06-13 21:56:09 +02:00
qwint
aecbb2ab02 fix saving princess's use of subprocess helpers (#5103) 2025-06-13 12:28:58 +02:00
JaredWeakStrike
52b11083fe KH2: Raise Exception for Misusing DonaldGoofyStatsanity Option (#4710)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-06-11 15:52:47 -04:00
BadMagic100
a8c87ce54b CI: Add GH_REPO environment variable to labeler (#5081) 2025-06-10 05:55:40 +02:00
JaredWeakStrike
ddb3240591 KH2: Give warning when client has cached locations (#5000)
* a

* disconnect when connect to wrong slot

* connection to the wrong seed fix

* seed_name is always none
2025-06-09 14:58:08 +02:00
qwint
f25ef639f2 Launcher: Fix Cli Components when installed to a directory with a space (#5091) 2025-06-09 00:43:23 +02:00
BlastSlimey
ab7d3ce4aa shapez: Remove preset unittests #5086 2025-06-06 00:05:53 +02:00
Jarno
50db922cef Timespinner: Fixed generation error because of timezone locking (#5084)
* Fixed generation error because of timezone locking

* Refactored logic + prevent excluding warps when unchained keys in on
2025-06-05 15:05:00 +02:00
Ehseezed
a2708edc37 Timespinner: Fix Castle Ramparts Region Connection #5082
Co-authored-by: ehseezed <Ehseezed@users.noreply.github.com>
2025-06-04 19:51:08 +02:00
Exempt-Medic
603a5005e2 DS3: Fix Non-Crow Itemlinking and Mark Aldrich Ruby and Twin Dragon Greatshield As Missable (#4510)
* Fix Branch (Not Crow)

* Oops

* Mark Aldrich Ruby as missable

* Expand comment

* Short circuit

* Mark Twin Dragon Greatshield as missable

* Add missable cause
2025-06-03 08:49:10 -04:00
Fabian Dill
b4f68bce76 Factorio: revamp args parsing and passing (#5036) 2025-06-03 13:49:44 +02:00
Scipio Wright
a76cec1539 TUNIC: Fix decoupled ER + ladder storage making invalid entrances #5075 2025-06-03 12:51:06 +02:00
black-sliver
694e6bcae3 Launcher/Utils: reset LD_LIBRARY_PATH for system EXEs (#5022) 2025-06-03 10:42:37 +00:00
black-sliver
b85b18cf5f SoE: remove outdated info from guide (#5064)
The client does not depend on Animation Frame anymore, so it can be backgrounded.
2025-06-02 16:39:42 +00:00
Mysteryem
04c707f874 DKC3: Add missing indirect conditions (#5073)
A couple of Entrance access rules were checking for being able to reach
a Location, but a Location first checks for being able to reach its
parent Region, so it needs to be registered that access to that parent
Region can give access to the Entrance.
2025-06-02 18:06:54 +02:00
Exempt-Medic
99142fd662 Plando Items: Fix count with empty locations/location #5040 2025-06-02 18:01:21 +02:00
Mysteryem
0c5cb17d96 DLCQuest: Add missing indirect conditions (#5074)
The `Behind Rocks` and `Pickaxe Hard Cave` Entrances require being able
to reach the `Cut Content` region, but no indirect conditions were being
registered for this region.

The `set_lfod_self_obtained_items_rules` function was also using a
`world` parameter that was actually expecting a `MultiWorld` instance,
so I have renamed it for clarity and updated the function to use
`world.get_entrance()` rather than `multiworld.get_entrance()`.

Much of the rest of the file passes `MultiWorld` instances to `world`
parameters, but fixing all of these is out of the scope of the changes
in this patch, so has not been included.
2025-06-02 17:56:11 +02:00
qwint
cabde313b5 WebHost: Use expected APPlayerContainer manifest location directly when ingesting them #4754 2025-06-02 17:53:57 +02:00
qwint
8f68bb342d Core and Various Worlds: define patch_file_ending to APPlayerContainer (#5058)
* move to playercontainer

* moves patch_file_ending handling to APPlayerContainer and updates the worlds using it to define their extensions

* give oot a patch_file_ending as well
2025-06-02 17:53:18 +02:00
Jérémie Bolduc
fab75d3a32 Stardew Valley: Fix Wizard Tower and Entrance Randomizer Softlocks (#4631)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-31 07:57:42 -04:00
massimilianodelliubaldini
d19bf98dc4 Jak and Daxter: Post-merge Polish (#5031)
- Cleans up a few missed references in the setup guide.
- Refactors Options class to use metaclass and decorators to enforce friendly limits on multiple levels.    
  - Templates generated from the website, even ones with `random` should not fail generation because the website will only allow values inside the friendly limits. 
  - _Uploaded_ yamls to the website with `random`, should also now respect friendly limits without the need for `random-range` shenanigans.
  - _Uploaded_ yamls to the website, or yamls that are used to generate locally, that have hard-defined values outside the friendly limits, will be clamped/dragged/massaged into those limits (with logged warnings).
- Removed an early completion goal that was playing havoc with fill. Not enough people seem to use this goal, so its loss will not be mourned.
2025-05-30 16:31:00 +02:00
sgrunt
b0f41c0360 Timespinner: Fix Connection Logic from Maw Cave Entrance to Maw (#4831)
Co-authored-by: sgrunt <sgrunt1987@gmail.com>
2025-05-28 20:40:24 -04:00
sgrunt
6ebd60feaa Timespinner: Fix Logic Error with Risky Warp to Emperor's Tower and Lab Access (#4784)
Co-authored-by: sgrunt <sgrunt1987@gmail.com>
2025-05-28 20:37:39 -04:00
Jonathan Tan
dd6007b309 TWW: Remove unnecessary items from slot data (#5045) 2025-05-29 00:27:03 +02:00
Ehseezed
fde203379d Timespinner: Fix Logic (#4803)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-28 15:04:57 -04:00
LiquidCat64
fcb3efee01 CVCotM: Add Nerf Roc Wing to Slot Data and HoD Max Ups to other_game_item_appearances (#5051) 2025-05-28 10:47:24 -04:00
black-sliver
19a21099ed Webhost: update Flask to 3.1.1 (#5052) 2025-05-27 16:21:43 +00:00
Jonathan Tan
20ca7e71c7 TWW: Update patch class (#5046) 2025-05-27 07:57:20 +02:00
ScootyPuffJr1
002202ff5f Update OOT Guides (#5041)
* Update OOT Guides

* Minor update per review
2025-05-26 07:25:39 +00:00
FlitPix
32487137e8 Core: Add descriptions to Components (#4849)
* Add descriptions to components

* Adhere to style guide

* Tweak BHC wording

* Trim Open Patch description

* Update text client description for consistency

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

* Remove newlines

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-25 17:17:30 -04:00
LiquidCat64
f327ab30a6 CV64: Allow Holding Z to Use the Regular Shimmy Speed (#4730)
* Add the shimmy modifier hack.

* Update the Increase Shimmy Speed option description.

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-25 05:20:25 -04:00
agilbert1412
e7545cbc28 SDV: Fixed Region for two Parrot Locations (#5042) 2025-05-24 17:59:55 -04:00
NewSoupVi
eba757d2cd Raft: Implement get_filler_item_name and refactor filler item code a bit (#4782)
* refactor filler item creation for Raft, implement get_filler_item_name

* wrong indent

* Update worlds/raft/__init__.py

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

---------

Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-24 23:02:27 +02:00
Star Rauchenberger
4119763e23 Lingo: Fix The Bearer's Pilgrimage Logic (#5005) 2025-05-24 09:35:06 -04:00
Jonathan Tan
e830a6d6f5 TWW: Only add Filler for Excluded Locations Which are Progress Locations (#4993)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-24 09:17:54 -04:00
Bryce Wilson
704cd97f21 BizHawkClient: Fix script to list all cores instead of explicit mapping (#5033) 2025-05-24 07:33:01 +02:00
agilbert1412
47a0dd696f Stardew Valley: Added moss to statue of blessings recipe (#5038) 2025-05-24 07:28:25 +02:00
Jérémie Bolduc
c64791e3a8 Stardew Valley: Replace current naive entrance rando with GER (#4624) 2025-05-24 07:15:41 +02:00
Aaron Wagener
e82d50a3c5 The Messenger: more generous portal validation (#5011)
* The Messenger: more generous portal validation

* remove the while and just go for 20 attempts. hopefully that's enough
2025-05-24 00:13:34 +02:00
qwint
0a7aa9e3e2 Launcher: skip launcher gui when opening webhost list with no game handlers (#4888)
* calc relevant components before opening the launcher app so it can be skipped for text client only uri launches

* generically passthrough the url arg

* Apply suggestions from code review

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

* flip if not else

* Update Launcher.py

* pluralize

---------

Co-authored-by: Aaron Wagener <mmmcheese158@gmail.com>
2025-05-24 00:02:50 +02:00
NewSoupVi
13ca134d12 Core: Fix a playthrough crash when a world uses "placement based logic" (#3915)
* Fix playthrough

* oops

* oops 2

* I don't like this

* that should do it

* Update BaseClasses.py

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

* Update BaseClasses.py

---------

Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-05-23 23:47:21 +02:00
Jérémie Bolduc
8671e9a391 Stardew Valley: Make animal catalog logically year 2 (#5032) 2025-05-23 19:52:47 +00:00
BlastSlimey
a7de89f45c shapez: Add game to README and CODEOWNERS (#5034)
* Aktualisieren von README.md

* Aktualisieren von CODEOWNERS
2025-05-23 19:41:27 +00:00
black-sliver
e9f51e3302 Linux: avoid adding cwd to LD_LIBRARY_PATH (#5029)
When LD_LIBRARY_PATH is not set, the old code would also add
the current working directory to LD_LIBRARY_PATH, which is bad.
2025-05-23 19:26:37 +00:00
Aaron Wagener
5491f8c459 Core: Make get_all_state Sweeping Optional (#4828) 2025-05-22 22:28:56 -04:00
Fabian Dill
de71677208 Core: only raise min_client_version for new gens (#4896) 2025-05-22 21:30:30 +02:00
Nicholas Saylor
653ee2b625 Docs: Update Snippets to Modern Type Hints (#4987) 2025-05-22 15:00:30 -04:00
qwint
62694b1ce7 Launcher: Fix on File Drop Error Message (#5026) 2025-05-22 11:37:23 -04:00
Rosalie
9c0ad2b825 FF1: Bizhawk Client and APWorld Support (#4448)
Co-authored-by: beauxq <beauxq@yahoo.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-22 11:35:38 -04:00
qwint
88b529593f CommonClient: Add docs for Attributes (#5003)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-22 11:08:15 -04:00
agilbert1412
0351698ef7 SDV: Fixed Import bases (#5025) 2025-05-22 11:07:57 -04:00
Jérémie Bolduc
984df75f83 Stardew Valley: Move and Rework Monstersanity Tests (#4911) 2025-05-22 10:24:04 -04:00
Mysteryem
402a8fb967 AHiT: Add Dweller Mask Requirement to Normal Logic Rush Hour (#4499) 2025-05-22 10:16:16 -04:00
Aaron Wagener
45e3027f81 The Messenger: Add a Component Icon and Description (#4850)
Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-22 10:06:44 -04:00
Aaron Wagener
1d655a07cd Core: Add State add/remove/set Helpers (#4845) 2025-05-22 09:46:33 -04:00
FlitPix
c5e768ffe3 Minecraft: Stop Using Utils.get_options (#4879) 2025-05-22 09:42:54 -04:00
Aaron Wagener
8cc6f10634 The Messenger: Swap Options Docstrings to use rst, Add Option Groups (#4913)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-22 09:40:50 -04:00
Aaron Wagener
aeac83d643 Generate: Don't Force Player Name for Weights Files (#4943) 2025-05-22 09:29:24 -04:00
qwint
95efcf6803 Tests: Create CollectionState after MultiWorld.worlds (#4949) 2025-05-22 09:27:18 -04:00
josephwhite
44a78cc821 OoT: Stop Using Utils.get_options (#4957) 2025-05-22 09:26:28 -04:00
Scipio Wright
e0918a7a89 TUNIC: Move some UT stuff out of init, put in UT poptracker integration support (#4967) 2025-05-22 09:24:50 -04:00
qwint
b52310f641 Wargroove: Cleanup script_name Component in LauncherComponents (#5021) 2025-05-22 09:12:28 -04:00
Silvris
e3219ba452 WebHost: allow APPlayerContainers from "custom" worlds to be displayed in rooms (#4981)
Gives WebHost the ability to verify that a patch file is an APPlayerContainer (defined by #4331 as a APContainer containing the "player" field), and allowed it to display any patch file that it can verify is an APPlayerContainer.
2025-05-22 09:47:48 +02:00
Fly Hyping
7079c17a0f Wargroove: apworld doc fixes (#5023) 2025-05-22 09:11:34 +02:00
black-sliver
3b8450036a core: don't reconfigure stdout if it's fake (#5020) 2025-05-22 01:22:55 +02:00
Fly Hyping
defdf34e60 Wargroove: apworld (#4764)
- Players and AI can sacrifice their own units and upload them to the multiworld.
- Players and AI can summon random units from the multiworld.
- Has 4 new separate options for how many sacrifices and summons either the player or the AI can make per level attempt.
- New /sacrifice_summon command to toggle sacrifices and summons on/off. Useful if the AI makes a level impossible with their summons.
- Linux Support.
- Is an apworld now.


---------

Co-authored-by: Raspberry Floof <raspberry@rosenthalcastle.org>
Co-authored-by: KScl <ks@rosenthalcastle.org>
Co-authored-by: Abigail Fox <Raspberryfloof@users.noreply.github.com>
Co-authored-by: qwint <qwint.42@gmail.com>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2025-05-22 01:00:45 +02:00
Fabian Dill
6827368e60 Core: generate templates faster and "cleaner" (#5019) 2025-05-22 00:45:49 +02:00
Katelyn Gigante
a409167f64 core: Reconfigure stdout to utf8 (#5017) 2025-05-21 20:27:03 +02:00
Natalie Weizenbaum
a076b9257d DS3: Don't make unrandomized items into events (#5018)
The DS3 static randomizer uses the relative ordering of location names
to map between Archipelago's notion of location IDs and the static
randomizer's. Treating unrandomized locations as excluded can break this
behavior by removing some locations from the list, causing further
locations to be incorrectly assigned.

The only reason this wasn't a bigger problem up to this point was that
location order only matters on a per-region and per-item basis. That
means this only causes problems in practice when a single region has
multiple locations with the same default item, and some of those
locations are randomized while others are not. Since exclusions (and
thus randomization) are usually done based on item types, we managed to
dodge this bullet for a long time.
2025-05-21 18:59:04 +02:00
Sunny Bat
7e772b4ee9 Raft: Small Raft doc update, bugfix (#5008)
* Small doc touchups

* Advanced Scarecrow progressive

* Add period to doc

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>

---------

Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
2025-05-21 18:12:37 +02:00
Alchav
955a86803f Super Mario Land 2: Implement New Game (#2730)
Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
Co-authored-by: alchav <alchav@jalchavware.com>
2025-05-21 11:02:30 -04:00
BlastSlimey
d5bacaba63 shapez: Implement New Game (#3960)
Adds shapez as a supported game in AP.
2025-05-21 14:30:39 +02:00
massimilianodelliubaldini
3069deb019 Jak and Daxter: Implement New Game (#3291)
* Jak 1: Initial commit: Cell Locations, Items, and Regions modeled.

* Jak 1: Wrote Regions, Rules, init. Untested.

* Jak 1: Fixed mistakes, need better understanding of Entrances.

* Jak 1: Fixed bugs, refactored Regions, added missing Special Checks. First spoiler log generated.

* Jak 1: Add Scout Fly Locations, code and style cleanup.

* Jak 1: Add Scout Flies to Regions.

* Jak 1: Add version info.

* Jak 1: Reduced code smell.

* Jak 1: Fixed UT bugs, added Free The Sages as Locations.

* Jak 1: Refactor ID scheme to better fit game's scheme. Add more subregions and rules, but still missing one-way Entrances.

* Jak 1: Add some one-ways, adjust scout fly offset.

* Jak 1: Found Scout Fly ID's for first 4 maps.

* Jak 1: Add more scout fly ID's, refactor game/AP ID translation for easier reading and code reuse.

* Jak 1: Fixed a few things. Four maps to go.

* Jak 1: Last of the scout flies mapped!

* Jak 1: simplify citadel sages logic.

* Jak 1: WebWorld setup, some documentation.

* Jak 1: Initial checkin of Client. Removed the colon from the game name.

* Jak 1: Refactored client into components, working on async communication between the client and the game.

* Jak 1: In tandem with new ArchipelaGOAL memory structure, define read_memory.

* Jak 1: There's magic in the air...

* Jak 1: Fixed bug translating scout fly ID's.

* Jak 1: Make the REPL a little more verbose, easier to debug.

* Jak 1: Did you know Snowy Mountain had such specific unlock requirements? I didn't.

* Jak 1: Update Documentation.

* Jak 1: Simplify user interaction with agents, make process more robust/less dependent on order of ops.

* Jak 1: Simplified startup process, updated docs, prayed.

* Jak 1: quick fix to settings.

* Jak and Daxter: Implement New Game (#1)

* Jak 1: Initial commit: Cell Locations, Items, and Regions modeled.

* Jak 1: Wrote Regions, Rules, init. Untested.

* Jak 1: Fixed mistakes, need better understanding of Entrances.

* Jak 1: Fixed bugs, refactored Regions, added missing Special Checks. First spoiler log generated.

* Jak 1: Add Scout Fly Locations, code and style cleanup.

* Jak 1: Add Scout Flies to Regions.

* Jak 1: Add version info.

* Jak 1: Reduced code smell.

* Jak 1: Fixed UT bugs, added Free The Sages as Locations.

* Jak 1: Refactor ID scheme to better fit game's scheme. Add more subregions and rules, but still missing one-way Entrances.

* Jak 1: Add some one-ways, adjust scout fly offset.

* Jak 1: Found Scout Fly ID's for first 4 maps.

* Jak 1: Add more scout fly ID's, refactor game/AP ID translation for easier reading and code reuse.

* Jak 1: Fixed a few things. Four maps to go.

* Jak 1: Last of the scout flies mapped!

* Jak 1: simplify citadel sages logic.

* Jak 1: WebWorld setup, some documentation.

* Jak 1: Initial checkin of Client. Removed the colon from the game name.

* Jak 1: Refactored client into components, working on async communication between the client and the game.

* Jak 1: In tandem with new ArchipelaGOAL memory structure, define read_memory.

* Jak 1: There's magic in the air...

* Jak 1: Fixed bug translating scout fly ID's.

* Jak 1: Make the REPL a little more verbose, easier to debug.

* Jak 1: Did you know Snowy Mountain had such specific unlock requirements? I didn't.

* Jak 1: Update Documentation.

* Jak 1: Simplify user interaction with agents, make process more robust/less dependent on order of ops.

* Jak 1: Simplified startup process, updated docs, prayed.

* Jak 1: quick fix to settings.

* Jak and Daxter: Genericize Items, Update Scout Fly logic, Add Victory Condition. (#3)

* Jak 1: Update to 0.4.6. Decouple locations from items, support filler items.

* Jak 1: Total revamp of Items. This is where everything broke.

* Jak 1: Decouple 7 scout fly checks from normal checks, update regions/rules for orb counts/traders.

* Jak 1: correct regions/rules, account for sequential oracle/miner locations.

* Jak 1: make nicer strings.

* Jak 1: Add logic for finished game. First full run complete!

* Jak 1: update group names.

* Jak and Daxter - Gondola, Pontoons, Rules, Regions, and Client Update

* Jak 1: Overhaul of regions, rules, and special locations. Updated game info page.

* Jak 1: Preparations for Alpha. Reintroducing automatic startup in client. Updating docs, readme, codeowners.

* Alpha Updates (#15)

* Jak 1: Consolidate client into apworld, create launcher icon, improve setup docs.

* Jak 1: Update setup guide.

* Jak 1: Load title screen, save states of in/outboxes.

* Logging Update (#16)

* Jak 1: Separate info and debug logs.

* Jak 1: Update world info to refer to Archipelago Options menu.

* Deathlink (#18)

* Jak 1: Implement Deathlink. TODO: make it optional...

* Jak 1: Issue a proper send-event for deathlink deaths.

* Jak 1: Added cause of death to deathlink, fixed typo.

* Jak 1: Make Deathlink toggleable.

* Jak 1: Added player name to death text, added zoomer/flut/fishing text, simplified GOAL call for deathlink.

* Jak 1: Fix death text in client logger.

* Move Randomizer (#26)

* Finally remove debug-segment text, update Python imports to relative paths.

* HUGE refactor to Regions/Rules to support move rando, first hub area coded.

* More refactoring.

* Another refactor - may squash.

* Fix some Rules, reuse some code by returning key regions from build_regions.

* More regions added. A couple of TODOs.

* Fixed trade logic, added LPC regions.

* Added Spider, Snowy, Boggy. Fixed Misty's orbs.

* Fix circular import, assert orb counts per level, fix a few naming errors.

* Citadel added, missing locs and connections fixed. First move rando seed generated.

* Add Move Rando to Options class.

* Fixed rules for prerequisite moves.

* Implement client functionality for move rando, add blurbs to game info page.

* Fix wrong address for cache checks.

* Fix byte alignment of offsets, refactor read_memory for better code reuse.

* Refactor memory offsets and add some unit tests.

* Make green eco the filler item, also define a maximum ID. Fix Boggy tether locations.

* Move rando fixes (#29)

* Fix virtual regions in Snowy. Fix some GMC problems.

* Fix Deathlink on sunken slides.

* Removed unncessary code causing build failure.

* Orbsanity (#32)

* My big dumb shortcut: a 2000 item array.

* A better idea: bundle orbs as a numerical option and make array variable size.

* Have Item/Region generation respect the chosen Orbsanity bundle size. Fix trade logic.

* Separate Global/Local Orbsanity options. TODO - re-introduce orb factory for per-level option.

* Per-level Orbsanity implemented w/ orb bundle factory.

* Implement Orbsanity for client, fix some things up for regions.

* Fix location name/id mappings.

* Fix client orb collection on connection.

* Fix minor Deathlink bug, add Update instructions.

* Finishing Touches (#36)

* Set up connector level thresholds, completion goal choices.

* Send AP sender/recipient info to game via client.

* Slight refactors.

* Refactor option checking, add DataStorage handling of traded orbs.

* Update instructions to change order of load/connect.

* Add Option check to ensure enough Locations exist for Cell Count thresholds. Fix Final Door region.

* Need some height move to get LPC sunken chamber cell.

* Rename completion_condition to jak_completion_condition (#41)

* The Afterparty (#42)

* Fixes to Jak client, rules, options, and more.

* Post-rebase fixes.

* Remove orbsanity reset code, optimize game text in client.

* More game text optimization.

* Added more specific troubleshooting/setup instructions.

* Add known issue about large releases taking time. (Dodge 6,666th commit.)

* Remove "Bundle of", Add location name groups, set better default RootDirectory for new players.

* Make orb trade amounts configurable, make orbsanity defaults more reasonable.

* Add HUD info to doc.

* Exempt's Code Review Updates (#43)

* Round 1 of code review updates, the easy stuff.

* Factor options checking away from region/rule creation.

* Code review updates round 2, more complex stuff.

* Code review updates round 3: the mental health annihilator

* Code review updates part 4: redemption.

* More code review feedback, simplifying code, etc.

* Added a host.yaml option to override friendly limits, plus a couple of code review updates.

* Added singleplayer limits, player names to enforcement rules.

* Updated friendly limits to be more strict, optimized recalculate logic.

* Today's the big day Jak: updates docs for mod support in OpenGOAL Launcher

* Rearranged and clarified some instructions, ADDED PATH-SPACE FIX TO CLIENT.

* Fix deathlink reset stalls on a busy client. (#47)

* Jak & Daxter Client : queue game text messages to get items faster during release (#48)

* queue game text messages to write them during the main_tick function and empty the message queue faster during release

* wrap comment for code style character limit

Co-authored-by: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com>

* remove useless blank line

Co-authored-by: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com>

* whitespace code style

Co-authored-by: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com>

* Move JsonMessageData dataclass outside of ReplClient class for code clarity

---------

Co-authored-by: massimilianodelliubaldini <8584296+massimilianodelliubaldini@users.noreply.github.com>

* Item Classifications (and REPL fixes) (#49)

* Changes to item classifications

* Bugfixes to power cell thresholds.

* Fix bugs in item_type_helper.

* Refactor 100 cell door to pass unit tests.

* Quick fix to ReplClient.

* Not so quick fix to ReplClient.

* Display friendly limits in options tooltips.

* Use math.ceil like a normal person.

* Missed a space.

* Fix non-accessibility due to bad orb calculation.

* Updated documentation.

* More Options, More Docs, More Tests (#51)

* Reorder cell counts, require punch for Klaww.

* Friendlier friendly friendlies.

* Removed custom_worlds references from docs/setup guide, focused OpenGOAL Launcher language.

* Increased breadth of unit tests.

* Clean imports of unit tests.

* Create OptionGroups.

* Fix region rule bug with Punch for Klaww.

* Include Punch For Klaww in slot data.

* Update worlds/jakanddaxter/__init__.py

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

* Temper and Harden Text Client (#52)

* Provide config path so OpenGOAL can use mod-specific saves and settings.

* Add versioning to MemoryReader. Harden the client against user errors.

* Updated comments.

* Add Deathlink as a "statement of intent" to the YAML. Small updates to client.

* Revert deathlink changes.

* Update error message.

* Added color markup to log messages printed in text client.

* Separate loggers by agent, write markup to GUI and non-markup to disk simultaneously.

* Refactor MemoryReader callbacks from main_tick to constructor.

* Make callback names more... informative.

* Give users explicit instructions in error messages.

* Stellar Messaging (#54)

* Use new ap-messenger functions for text writing.

* Remove Powershell requirement, bump memory version to 3.

* Error message update w/ instructions for game crash.

* Create no console window for gk.

* ISO Data Enhancement (#58)

* Add iso-path as argument to GOAL compiler.

# Conflicts:
#	worlds/jakanddaxter/Client.py

* More resilient handling of iso_path.

* Fixed scout fly ID mismatches.

* Corrected iso_data subpath.

* Update memory version to 4.

* Docs update for iso_data.

* Auto Detect OpenGOAL Install (#63)

* Auto detect OpenGOAL install path. Also fix Deathlink on server connection.

* Updated docs, add instructions to error messages.

* Slight tweak to error text.

* J&D : add per region location groups (#64)

* add per region power cells location group

* add per region scout flies location group

* add per zone orb bundle groups
(I'm not particularly happy about this code, but I figured doing it this way was the point of least friction/duplication)

* guess who forgot 9 very important characters in each line of the last commit

* Rearrange location group names, quick fix to client error handling.

* Fix pycharm warnings.

* Fix more pycharm warnings.

* Light cleanup: fix icons, add bug report page, remove py 3.8 code.

* Update worlds/jakanddaxter/Options.py

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

* Update worlds/jakanddaxter/Options.py

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

* Update worlds/jakanddaxter/Options.py

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

* Update worlds/jakanddaxter/Options.py

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

* Code review updates on comments, tooltips, and type hints.

* Update type hint for lists in regions.

* Missed todo removal.

* More type hint updates.

* Small region updates for location accessibility, small updates to world guide and README.md.

* Add GMC scout fly location group.

* Improved sanitization of game text.

* Traps 2 (#70)

* Add trap items, relevant options, and citadel orb caches.

* Update REPL to send traps to game.

* Fix item counter.

* Allow player to select which traps to use.

* Fix host.yaml doc strings, ap-setup-options typing, bump memory version to 5.

* Alter some trap names.

* Update world doc.

* Add health trap.

* Added 3 more trap types.

* Protect against empty trap list.

* Reword traps paragraph in world doc.

* Another update to trap paragraph.

* Concisify trap option docstring.

* Timestamp on game log file.

* Update client to handle waiting on title screen.

* Send slot name and seed to game.

* Use self.random instead.

* Update setup doc for new title screen.

* Quick clarification of orb caches in world doc.

* Sanitize slot info earlier.

* Added to and improved unit tests.

* Light cleanup on world.

* Optimizations to movement rules, docs: known issues update.

* Quick fixes for beta 0.5.0 release: template options and LPC logic.

* Quick fix to spoiler counts.

* Reorganize world guide for faster navigation.

* Fix links.

* Update HUD section.

* Found a way to render apostrophes in item names.

* March Refactors (#77)

* Reorg imports, small fix to Rock Village movement.

* Fix wait-on-title message never going to ready message.

* Colorama init fix.

* Swap trap list for a dictionary of trap weights.

* The more laws, the less justice.

* Quick readability update.

* Have memory reader provide instructions for slow booting games.

* Revert some things.

* Update setup_en.md

* Update HUD mode lingo for combined msgs.

* Remade launcher icon, sized correctly.

* I don't know why I can't be satisfied with things.

* Apply suggestions from Scipio

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

* Properly use the settings API instead of Utils.

* Newline on requirements.txt.

* Add __init__ files for frozen builds.

* Replace an ap_inform function with a CommonClient built-in.

* Resize icon to match kivymd expected size.

* First round of Treble code reviews.

* Second round of Treble code reviews.

* Third round of Treble code reviews.

* Missed an unncessary if condition.

* Missed unnecessary comments.

* Fourth round of Treble code reviews.

* Switch trap dictionary to OptionCounter.

* Use existing slot name/seed from network protocol.

* Violet code review updates.

* Violet code review updates part 2.

* Refactor to avoid floating imports (Violet part 3).

* Found a few more valid characters for messaging.

* Move tests out of init, add colon to game name (now that it's safe).

* But don't include those chars for file text.

* Implement Vi suggestion on webhost-capable friendly limits.

* Revert "Implement Vi suggestion on webhost-capable friendly limits."

This reverts commit 2d012b7f4a.

* Rename all files for PEP8.

* Refactor how maximums work on webhost.

* Fix rogue UT.

* Don't rush.

* Fix client post-PEP8.

---------

Co-authored-by: Justus Lind <DeamonHunter@users.noreply.github.com>
Co-authored-by: Romain BERNARD <30secondstodraw@gmail.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Nicholas Saylor <79181893+nicholassaylor@users.noreply.github.com>
2025-05-21 14:12:27 +02:00
NewSoupVi
7f4bf71807 Adventure: Update AdventureDeltaPatch.read_contents to return the manifest as required by #4331 (#5016) 2025-05-21 14:12:00 +02:00
Doug Hoskisson
f3e00b6d62 Zillion: fix read_contents to be compatible with base class (#5015) 2025-05-21 01:48:24 +02:00
Fabian Dill
feef0f484d Core: disable worlds_disabled (#5014) 2025-05-21 00:52:00 +02:00
Fabian Dill
9adbd4031f Core: prepare worlds.Files for APWorldContainer (#4331)
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2025-05-20 23:55:16 +02:00
Mysteryem
e0d3101066 Core: Remove redundant reachable location counting in swap (#4990)
`prev_state` starts off as a copy of `swap_state` and then `swap_state`
collects `item_to_place`. Collecting an item must never reduce
accessibility (otherwise generation breaks horribly), so it is
guaranteed that `swap_state` will always be able to reach at least as
many locations as `prev_state`, so `new_loc_count >= prev_loc_count` is
always `True`.

As a sideeffect of this change, this fixes generation of Pokemon Emerald
with locally shuffled Badges/HMs when there are worlds with unconnected
entrances present in the multiworld e.g. KH1. This is because this
location counting did not respect `single_player_placement=True` and
counted reachable locations across the entire multiworld.

Fixes #4834 as a sideeffect of removing the redundant code.
2025-05-20 21:23:44 +02:00
SunCat
485387ebbe ChecksFinder: Update setup guide (#4973)
* Update setup_en.md

* Update worlds/checksfinder/docs/setup_en.md

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

* Update worlds/checksfinder/docs/setup_en.md

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

* Update worlds/checksfinder/docs/setup_en.md

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

---------

Co-authored-by: Scipio Wright <scipiowright@gmail.com>
2025-05-20 20:12:13 +02:00
Seldom
9ac628f020 Terraria: remove 1.4.3-specific docs #5013 2025-05-20 20:11:44 +02:00
PoryGone
07664c4d54 SA2B: Logic Fixes (#5009)
- Fixes Shadow's mission count being set by Sonic's mission count option
- Fixes one small logic error on `Security Hall - 5` on Hard Logic difficulty
- Removes stray character that was probably harmless
2025-05-20 00:48:31 +02:00
Aaron Wagener
d3dbdb4491 Kivy: Add a button prompt box (#3470)
* Kivy: Add a button prompt box

* auto format the buttons to display 2 per row to look nicer

* update to kivymd

* have the uri popup use the new API

* have messenger use the new API

* make the buttonprompt import even more lazy

* messenger needs to be lazy too

* make the buttons take up the full dialog width

---------

Co-authored-by: NewSoupVi <57900059+NewSoupVi@users.noreply.github.com>
2025-05-19 01:08:39 +02:00
Jérémie Bolduc
90ee9ffe36 Stardew Valley: Remove Crab Pot Requirement for Help Wanted Fishing (#4985)
Co-authored-by: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com>
2025-05-17 09:20:53 -04:00
el-u
15e6383aad lufia2ac: rearrange tests to comply with new conventions (#5001) 2025-05-15 17:58:10 +00: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
428 changed files with 95223 additions and 11304 deletions

1
.github/labeler.yml vendored
View File

@@ -21,7 +21,6 @@
- '!data/**'
- '!.run/**'
- '!.github/**'
- '!worlds_disabled/**'
- '!worlds/**'
- '!WebHost.py'
- '!WebHostLib/**'

View File

@@ -98,7 +98,7 @@ jobs:
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
cp Players/Templates/VVVVVV.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store 7z
uses: actions/upload-artifact@v4
@@ -189,7 +189,7 @@ jobs:
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
cp Players/Templates/VVVVVV.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store AppImage
uses: actions/upload-artifact@v4

View File

@@ -6,6 +6,8 @@ on:
permissions:
contents: read
pull-requests: write
env:
GH_REPO: ${{ github.repository }}
jobs:
labeler:

7
.gitignore vendored
View File

@@ -56,7 +56,6 @@ success.txt
output/
Output Logs/
/factorio/
/Minecraft Forge Server/
/WebHostLib/static/generated
/freeze_requirements.txt
/Archipelago.zip
@@ -184,12 +183,6 @@ _speedups.c
_speedups.cpp
_speedups.html
# minecraft server stuff
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv
.python-version

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

@@ -11,6 +11,7 @@ from typing import List
import Utils
from settings import get_settings
from NetUtils import ClientStatus
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
@@ -80,8 +81,8 @@ class AdventureContext(CommonContext):
self.local_item_locations = {}
self.dragon_speed_info = {}
options = Utils.get_settings()
self.display_msgs = options["adventure_options"]["display_msgs"]
options = get_settings().adventure_options
self.display_msgs = options.display_msgs
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -102,7 +103,7 @@ class AdventureContext(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if Utils.get_settings()["adventure_options"].get("death_link", False):
if get_settings().adventure_options.as_dict().get("death_link", False):
self.set_deathlink = True
async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo":
@@ -415,8 +416,9 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile):
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
options = get_settings().adventure_options
auto_start = options.rom_start
rom_args = options.rom_args
if auto_start is True:
import webbrowser
webbrowser.open(romfile)

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 = {}
@@ -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, perform_sweep: bool = True) -> CollectionState:
cached = getattr(self, "_all_state", None)
if use_cache and cached:
return cached.copy()
@@ -436,11 +448,13 @@ 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)
ret.sweep_for_advancements()
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)
if perform_sweep:
ret.sweep_for_advancements()
if use_cache:
self._all_state = ret
@@ -545,7 +559,9 @@ class MultiWorld():
else:
return all((self.has_beaten_game(state, p) for p in range(1, self.players + 1)))
def can_beat_game(self, starting_state: Optional[CollectionState] = None) -> bool:
def can_beat_game(self,
starting_state: Optional[CollectionState] = None,
locations: Optional[Iterable[Location]] = None) -> bool:
if starting_state:
if self.has_beaten_game(starting_state):
return True
@@ -554,7 +570,9 @@ class MultiWorld():
state = CollectionState(self)
if self.has_beaten_game(state):
return True
prog_locations = {location for location in self.get_locations() if location.item
base_locations = self.get_locations() if locations is None else locations
prog_locations = {location for location in base_locations if location.item
and location.item.advancement and location not in state.locations_checked}
while prog_locations:
@@ -723,6 +741,7 @@ class CollectionState():
additional_copy_functions: List[Callable[[CollectionState, CollectionState], CollectionState]] = []
def __init__(self, parent: MultiWorld, allow_partial_entrances: bool = False):
assert parent.worlds, "CollectionState created without worlds initialized in parent"
self.prog_items = {player: Counter() for player in parent.get_all_ids()}
self.multiworld = parent
self.reachable_regions = {player: set() for player in parent.get_all_ids()}
@@ -999,6 +1018,17 @@ class CollectionState():
return changed
def add_item(self, item: str, player: int, count: int = 1) -> None:
"""
Adds the item to state.
:param item: The item to be added.
:param player: The player the item is for.
:param count: How many of the item to add.
"""
assert count > 0
self.prog_items[player][item] += count
def remove(self, item: Item):
changed = self.multiworld.worlds[item.player].remove(self, item)
if changed:
@@ -1007,6 +1037,33 @@ class CollectionState():
self.blocked_connections[item.player] = set()
self.stale[item.player] = True
def remove_item(self, item: str, player: int, count: int = 1) -> None:
"""
Removes the item from state.
:param item: The item to be removed.
:param player: The player the item is for.
:param count: How many of the item to remove.
"""
assert count > 0
self.prog_items[player][item] -= count
if self.prog_items[player][item] < 1:
del (self.prog_items[player][item])
def set_item(self, item: str, player: int, count: int) -> None:
"""
Sets the item in state equal to the provided count.
:param item: The item to modify.
:param player: The player the item is for.
:param count: How many of the item to now have.
"""
assert count >= 0
if count == 0:
del (self.prog_items[player][item])
else:
self.prog_items[player][item] = count
class EntranceType(IntEnum):
ONE_WAY = 1
@@ -1280,8 +1337,8 @@ class Region:
Connects current region to regions in exit dictionary. Passed region names must exist first.
:param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided,
created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region", rule}
created entrances will be named "self.name -> connecting_region"
:param rules: rules for the exits from this region. format is {"connecting_region": rule}
"""
if not isinstance(exits, Dict):
exits = dict.fromkeys(exits)
@@ -1550,21 +1607,19 @@ class Spoiler:
# in the second phase, we cull each sphere such that the game is still beatable,
# reducing each range of influence to the bare minimum required inside it
restore_later: Dict[Location, Item] = {}
required_locations = {location for sphere in collection_spheres for location in sphere}
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
to_delete: Set[Location] = set()
for location in sphere:
# we remove the item at location and check if game is still beatable
# we remove the location from required_locations to sweep from, and check if the game is still beatable
logging.debug('Checking if %s (Player %d) is required to beat the game.', location.item.name,
location.item.player)
old_item = location.item
location.item = None
if multiworld.can_beat_game(state_cache[num]):
required_locations.remove(location)
if multiworld.can_beat_game(state_cache[num], required_locations):
to_delete.add(location)
restore_later[location] = old_item
else:
# still required, got to keep it around
location.item = old_item
required_locations.add(location)
# cull entries in spheres for spoiler walkthrough at end
sphere -= to_delete
@@ -1581,7 +1636,7 @@ class Spoiler:
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
precollected_items.remove(item)
multiworld.state.remove(item)
if not multiworld.can_beat_game():
if not multiworld.can_beat_game(multiworld.state, required_locations):
# Add the item back into `precollected_items` and collect it into `multiworld.state`.
multiworld.push_precollected(item)
else:
@@ -1623,9 +1678,6 @@ class Spoiler:
self.create_paths(state, collection_spheres)
# repair the multiworld again
for location, item in restore_later.items():
location.item = item
for item in removed_precollected:
multiworld.push_precollected(item)

View File

@@ -266,38 +266,71 @@ class CommonContext:
last_death_link: float = time.time() # last send/received death link on AP layer
# remaining type info
slot_info: typing.Dict[int, NetworkSlot]
server_address: typing.Optional[str]
password: typing.Optional[str]
hint_cost: typing.Optional[int]
hint_points: typing.Optional[int]
player_names: typing.Dict[int, str]
slot_info: dict[int, NetworkSlot]
"""Slot Info from the server for the current connection"""
server_address: str | None
"""Autoconnect address provided by the ctx constructor"""
password: str | None
"""Password used for Connecting, expected by server_auth"""
hint_cost: int | None
"""Current Hint Cost per Hint from the server"""
hint_points: int | None
"""Current avaliable Hint Points from the server"""
player_names: dict[int, str]
"""Current lookup of slot number to player display name from server (includes aliases)"""
finished_game: bool
"""
Bool to signal that status should be updated to Goal after reconnecting
to be used to ensure that a StatusUpdate packet does not get lost when disconnected
"""
ready: bool
team: typing.Optional[int]
slot: typing.Optional[int]
auth: typing.Optional[str]
seed_name: typing.Optional[str]
"""Bool to keep track of state for the /ready command"""
team: int | None
"""Team number of currently connected slot"""
slot: int | None
"""Slot number of currently connected slot"""
auth: str | None
"""Name used in Connect packet"""
seed_name: str | None
"""Seed name that will be validated on opening a socket if present"""
# locations
locations_checked: typing.Set[int] # local state
locations_scouted: typing.Set[int]
items_received: typing.List[NetworkItem]
missing_locations: typing.Set[int] # server state
checked_locations: typing.Set[int] # server state
server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations
locations_info: typing.Dict[int, NetworkItem]
locations_checked: set[int]
"""
Local container of location ids checked to signal that LocationChecks should be resent after reconnecting
to be used to ensure that a LocationChecks packet does not get lost when disconnected
"""
locations_scouted: set[int]
"""
Local container of location ids scouted to signal that LocationScouts should be resent after reconnecting
to be used to ensure that a LocationScouts packet does not get lost when disconnected
"""
items_received: list[NetworkItem]
"""List of NetworkItems recieved from the server"""
missing_locations: set[int]
"""Container of Locations that are unchecked per server state"""
checked_locations: set[int]
"""Container of Locations that are checked per server state"""
server_locations: set[int]
"""Container of Locations that exist per server state; a combination between missing and checked locations"""
locations_info: dict[int, NetworkItem]
"""Dict of location id: NetworkItem info from LocationScouts request"""
# data storage
stored_data: typing.Dict[str, typing.Any]
stored_data_notification_keys: typing.Set[str]
stored_data: dict[str, typing.Any]
"""
Data Storage values by key that were retrieved from the server
any keys subscribed to with SetNotify will be kept up to date
"""
stored_data_notification_keys: set[str]
"""Current container of watched Data Storage keys, managed by ctx.set_notify"""
# internals
# current message box through kvui
_messagebox: typing.Optional["kvui.MessageBox"] = None
# message box reporting a loss of connection
"""Current message box through kvui"""
_messagebox_connection_loss: typing.Optional["kvui.MessageBox"] = None
"""Message box reporting a loss of connection"""
def __init__(self, server_address: typing.Optional[str] = None, password: typing.Optional[str] = None) -> None:
# server state

View File

@@ -1,267 +0,0 @@
import asyncio
import copy
import json
import time
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
class FF1CommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_nes(self):
"""Check NES Connection State"""
if isinstance(self.ctx, FF1Context):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in EmuHawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
class FF1Context(CommonContext):
command_processor = FF1CommandProcessor
game = 'Final Fantasy'
items_handling = 0b111 # full remote
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.nes_streams: (StreamReader, StreamWriter) = None
self.nes_sync_task = None
self.messages = {}
self.locations_array = None
self.nes_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(FF1Context, self).server_auth(password_requested)
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to NES to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if DISPLAY_MSGS:
self.messages[time.time(), msg_id] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
async_start(parse_locations(self.locations_array, self, True))
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_print_json(self, args: dict):
if self.ui:
self.ui.print_json(copy.deepcopy(args["data"]))
else:
text = self.jsontotextparser(copy.deepcopy(args["data"]))
logger.info(text)
relevant = args.get("type", None) in {"Hint", "ItemSend"}
if relevant:
item = args["item"]
# goes to this world
if self.slot_concerns_self(args["receiving"]):
relevant = True
# found in this world
elif self.slot_concerns_self(item.player):
relevant = True
# not related
else:
relevant = False
if relevant:
item = args["item"]
msg = self.raw_text_parser(copy.deepcopy(args["data"]))
self._set_message(msg, item.item)
def run_gui(self):
from kvui import GameManager
class FF1Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Final Fantasy 1 Client"
self.ui = FF1Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: FF1Context):
current_time = time.time()
return json.dumps(
{
"items": [item.item for item in ctx.items_received],
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10}
}
)
async def parse_locations(locations_array: List[int], ctx: FF1Context, force: bool):
if locations_array == ctx.locations_array and not force:
return
else:
# print("New values")
ctx.locations_array = locations_array
locations_checked = []
if len(locations_array) > 0xFE and locations_array[0xFE] & 0x02 != 0 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
for location in ctx.missing_locations:
# index will be - 0x100 or 0x200
index = location
if location < 0x200:
# Location is a chest
index -= 0x100
flag = 0x04
else:
# Location is an NPC
index -= 0x200
flag = 0x02
# print(f"Location: {ctx.location_names[location]}")
# print(f"Index: {str(hex(index))}")
# print(f"value: {locations_array[index] & flag != 0}")
if locations_array[index] & flag != 0:
locations_checked.append(location)
if locations_checked:
# print([ctx.location_names[location] for location in locations_checked])
await ctx.send_msgs([
{"cmd": "LocationChecks",
"locations": locations_checked}
])
async def nes_sync_task(ctx: FF1Context):
logger.info("Starting nes connector. Use /nes for status information")
while not ctx.exit_event.is_set():
error_status = None
if ctx.nes_streams:
(reader, writer) = ctx.nes_streams
msg = get_payload(ctx).encode()
writer.write(msg)
writer.write(b'\n')
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to two fields:
# 1. A keepalive response of the Players Name (always)
# 2. An array representing the memory values of the locations area (if in game)
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
# print(data_decoded)
if ctx.game is not None and 'locations' in data_decoded:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx, False))
if not ctx.auth:
ctx.auth = ''.join([chr(i) for i in data_decoded['playerName'] if i != 0])
if ctx.auth == '':
logger.info("Invalid ROM detected. No player name built into the ROM. Please regenerate"
"the ROM using the same link but adding your slot name")
if ctx.awaiting_rom:
await ctx.server_auth(False)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.nes_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.nes_streams = None
if ctx.nes_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to NES")
ctx.nes_status = CONNECTION_CONNECTED_STATUS
else:
ctx.nes_status = f"Was tentatively connected but error occured: {error_status}"
elif error_status:
ctx.nes_status = error_status
logger.info("Lost connection to nes and attempting to reconnect. Use /nes for status updates")
else:
try:
logger.debug("Attempting to connect to NES")
ctx.nes_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 52980), timeout=10)
ctx.nes_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.nes_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.nes_status = CONNECTION_REFUSED_STATUS
continue
if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
Utils.init_logging("FF1Client")
options = Utils.get_options()
DISPLAY_MSGS = options["ffr_options"]["display_msgs"]
async def main(args):
ctx = FF1Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
ctx.nes_sync_task = asyncio.create_task(nes_sync_task(ctx), name="NES Sync")
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.nes_sync_task:
await ctx.nes_sync_task
import colorama
parser = get_base_parser()
args = parser.parse_args()
colorama.just_fix_windows_console()
asyncio.run(main(args))
colorama.deinit()

387
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
@@ -100,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
@@ -138,32 +138,21 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati
# to clean that up later, so there is a chance generation fails.
if (not single_player_placement or location.player == item_to_place.player) \
and location.can_fill(swap_state, item_to_place, perform_access_check):
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
# Verify placing this item won't reduce available locations, which would be a useless swap.
prev_state = swap_state.copy()
prev_loc_count = len(
multiworld.get_reachable_locations(prev_state))
swap_count += 1
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
swap_state.collect(item_to_place, True)
new_loc_count = len(
multiworld.get_reachable_locations(swap_state))
reachable_items[placed_item.player].appendleft(
placed_item)
item_pool.append(placed_item)
if new_loc_count >= prev_loc_count:
# Add this item to the existing placement, and
# add the old item to the back of the queue
spot_to_fill = placements.pop(i)
# cleanup at the end to hopefully get better errors
cleanup_required = True
swap_count += 1
swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count
reachable_items[placed_item.player].appendleft(
placed_item)
item_pool.append(placed_item)
# cleanup at the end to hopefully get better errors
cleanup_required = True
break
break
# Item can't be placed here, restore original item
location.item = placed_item
@@ -242,7 +231,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
@@ -343,8 +332,10 @@ 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
@@ -365,7 +356,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)
@@ -677,9 +668,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()
@@ -777,7 +768,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:
@@ -793,8 +784,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)
@@ -867,52 +858,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
@@ -921,173 +890,201 @@ def distribute_planned(multiworld: MultiWorld) -> None:
worlds = set()
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'])
failed(f"Cannot place item to {listed_world}'s world as that world does not exist.",
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
resolved_locations: list[Location] = []
for target_player in worlds:
locations_from_groups: list[str] = []
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 = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else 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"] = (min(len(new_block.items), len(new_block.resolved_locations))
if new_block.resolved_locations else 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
@@ -77,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.")
@@ -95,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)
@@ -180,7 +180,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
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()}
@@ -212,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():
@@ -224,10 +224,14 @@ def main(args=None) -> Tuple[argparse.Namespace, int]:
except Exception as e:
raise Exception(f"Error setting {k} to {v} for player {player}") from e
if path == args.weights_file_path: # if name came from the weights file, just use base player name
erargs.name[player] = f"Player{player}"
elif player not in erargs.name: # if name was not specified, generate it from filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
# name was not specified
if player not in erargs.name:
if path == args.weights_file_path:
# weights file, so we need to make the name unique
erargs.name[player] = f"Player{player}"
else:
# use the filename
erargs.name[player] = os.path.splitext(os.path.split(path)[-1])[0]
erargs.name[player] = handle_name(erargs.name[player], player, name_counter)
player += 1
@@ -242,7 +246,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")
@@ -334,12 +338,6 @@ def handle_name(name: str, player: int, name_counter: Counter):
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 = {}
@@ -384,7 +382,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:
@@ -405,7 +403,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():
@@ -438,7 +436,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:
@@ -542,10 +540,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

@@ -11,14 +11,16 @@ Additional components can be added to worlds.LauncherComponents.components.
import argparse
import logging
import multiprocessing
import os
import shlex
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, Any
from typing import Any
if __name__ == "__main__":
import ModuleUpdate
@@ -40,13 +42,17 @@ def open_host_yaml():
if is_linux:
exe = which('sensible-editor') or which('gedit') or \
which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, file])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, file])
else:
webbrowser.open(file)
return
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, file], env=env)
def open_patch():
suffixes = []
@@ -91,7 +97,11 @@ def open_folder(folder_path):
return
if exe:
subprocess.Popen([exe, folder_path])
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.Popen([exe, folder_path], env=env)
else:
logging.warning(f"No file browser available to open {folder_path}")
@@ -103,66 +113,51 @@ def update_settings():
components.extend([
# Functions
Component("Open host.yaml", func=open_host_yaml),
Component("Open Patch", func=open_patch),
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("Open host.yaml", func=open_host_yaml,
description="Open the host.yaml file to change settings for generation, games, and more."),
Component("Open Patch", func=open_patch,
description="Open a patch file, downloaded from the room page or provided by the host."),
Component("Generate Template Options", func=generate_yamls,
description="Generate template YAMLs for currently installed games."),
Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/"),
description="Open archipelago.gg in your browser."),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2"),
description="Join the Discord server to play public multiworlds, report issues, or just chat!"),
Component("Unrated/18+ Discord Server", icon="discord",
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("Browse Files", func=browse_files),
func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4"),
description="Find unrated and 18+ games in the After Dark Discord server."),
Component("Browse Files", func=browse_files,
description="Open the Archipelago installation folder in your file browser."),
])
def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None:
def handle_uri(path: str) -> tuple[list[Component], Component]:
url = urllib.parse.urlparse(path)
queries = urllib.parse.parse_qs(url.query)
launch_args = (path, *launch_args)
client_component = []
client_components = []
text_client_component = None
if "game" in queries:
game = queries["game"][0]
else: # TODO around 0.6.0 - this is for pre this change webhost uri's
game = "Archipelago"
game = queries["game"][0]
for component in components:
if component.supports_uri and component.game_name == game:
client_component.append(component)
client_components.append(component)
elif component.display_name == "Text Client":
text_client_component = component
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())
MDDialog(
# Headline
MDDialogHeadlineText(text="Connect to Multiworld"),
# Text
popup_text,
# Content
MDDialogContentContainer(
*component_buttons,
orientation="vertical"
),
).open()
return client_components, text_client_component
def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]:
def build_uri_popup(component_list: list[Component], launch_args: tuple[str, ...]) -> None:
from kvui import ButtonsPrompt
component_options = {
component.display_name: component for component in component_list
}
popup = ButtonsPrompt("Connect to Multiworld",
"Select client to open and connect with.",
lambda component_name: run_component(component_options[component_name], *launch_args),
*component_options.keys())
popup.open()
def identify(path: None | str) -> tuple[None | str, None | Component]:
if path is None:
return None, None
for component in components:
@@ -173,7 +168,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
@@ -201,7 +196,8 @@ def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]:
def launch(exe, in_terminal=False):
if in_terminal:
if is_windows:
subprocess.Popen(['start', *exe], shell=True)
# intentionally using a window title with a space so it gets quoted and treated as a title
subprocess.Popen(["start", "Running Archipelago", *exe], shell=True)
return
elif is_linux:
terminal = which('x-terminal-emulator') or which('gnome-terminal') or which('xterm')
@@ -226,10 +222,10 @@ def create_shortcut(button: Any, component: Component) -> None:
button.menu.dismiss()
refresh_components: Optional[Callable[[], None]] = None
refresh_components: Callable[[], None] | None = None
def run_gui(path: str, args: Any) -> None:
def run_gui(launch_components: list[Component], args: Any) -> None:
from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox)
from kivy.properties import ObjectProperty
from kivy.core.window import Window
@@ -262,12 +258,12 @@ def run_gui(path: str, args: Any) -> None:
cards: list[LauncherCard]
current_filter: Sequence[str | Type] | None
def __init__(self, ctx=None, path=None, args=None):
def __init__(self, ctx=None, components=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_components = components
self.launch_args = args
self.cards = []
self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC)
@@ -389,9 +385,9 @@ def run_gui(path: str, args: Any) -> None:
return self.top_screen
def on_start(self):
if self.launch_uri:
handle_uri(self.launch_uri, self.launch_args)
self.launch_uri = None
if self.launch_components:
build_uri_popup(self.launch_components, self.launch_args)
self.launch_components = None
self.launch_args = None
@staticmethod
@@ -409,7 +405,7 @@ def run_gui(path: str, args: Any) -> None:
if file and component:
run_component(component, file)
else:
logging.warning(f"unable to identify component for {file}")
logging.warning(f"unable to identify component for {filename}")
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.
@@ -432,7 +428,7 @@ def run_gui(path: str, args: Any) -> None:
for filter in self.current_filter))
super().on_stop()
Launcher(path=path, args=args).run()
Launcher(components=launch_components, args=args).run()
# avoiding Launcher reference leak
# and don't try to do something with widgets after window closed
@@ -451,7 +447,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:
@@ -459,7 +455,15 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
path = args.get("Patch|Game|Component|url", None)
if path is not None:
if not path.startswith("archipelago://"):
if path.startswith("archipelago://"):
args["args"] = (path, *args.get("args", ()))
# add the url arg to the passthrough args
components, text_client_component = handle_uri(path)
if not components:
args["component"] = text_client_component
else:
args['launch_components'] = [text_client_component, *components]
else:
file, component = identify(path)
if file:
args['file'] = file
@@ -475,7 +479,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(path, args.get("args", ()))
run_gui(args.get("launch_components", None), args.get("args", ()))
if __name__ == '__main__':

View File

@@ -290,12 +290,9 @@ async def gba_sync_task(ctx: MMBN3Context):
async def run_game(romfile):
options = Utils.get_options().get("mmbn3_options", None)
if options is None:
auto_start = True
else:
auto_start = options.get("rom_start", True)
if auto_start:
from worlds.mmbn3 import MMBN3World
auto_start = MMBN3World.settings.rom_start
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):

39
Main.py
View File

@@ -7,14 +7,14 @@ 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 NetUtils import convert_to_base_types
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 +22,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 +37,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()
@@ -135,13 +132,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
}
@@ -150,7 +149,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:
@@ -179,8 +178,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.')
@@ -233,7 +233,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():
@@ -274,7 +274,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, " \
@@ -303,12 +303,12 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
}
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)
@@ -335,6 +335,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
}
AutoWorld.call_all(multiworld, "modify_multidata", multidata)
for key in ("slot_data", "er_hint_data"):
multidata[key] = convert_to_base_types(multidata[key])
multidata = zlib.compress(pickle.dumps(multidata), 9)
with open(os.path.join(temp_dir, f'{outfilebase}.archipelago'), 'wb') as f:

View File

@@ -1,344 +0,0 @@
import argparse
import json
import os
import sys
import re
import atexit
import shutil
from subprocess import Popen
from shutil import copyfile
from time import strftime
import logging
import requests
import Utils
from Utils import is_windows
atexit.register(input, "Press enter to exit.")
# 1 or more digits followed by m or g, then optional b
max_heap_re = re.compile(r"^\d+[mMgG][bB]?$")
def prompt_yes_no(prompt):
yes_inputs = {'yes', 'ye', 'y'}
no_inputs = {'no', 'n'}
while True:
choice = input(prompt + " [y/n] ").lower()
if choice in yes_inputs:
return True
elif choice in no_inputs:
return False
else:
print('Please respond with "y" or "n".')
def find_ap_randomizer_jar(forge_dir):
"""Create mods folder if needed; find AP randomizer jar; return None if not found."""
mods_dir = os.path.join(forge_dir, 'mods')
if os.path.isdir(mods_dir):
for entry in os.scandir(mods_dir):
if entry.name.startswith("aprandomizer") and entry.name.endswith(".jar"):
logging.info(f"Found AP randomizer mod: {entry.name}")
return entry.name
return None
else:
os.mkdir(mods_dir)
logging.info(f"Created mods folder in {forge_dir}")
return None
def replace_apmc_files(forge_dir, apmc_file):
"""Create APData folder if needed; clean .apmc files from APData; copy given .apmc into directory."""
if apmc_file is None:
return
apdata_dir = os.path.join(forge_dir, 'APData')
copy_apmc = True
if not os.path.isdir(apdata_dir):
os.mkdir(apdata_dir)
logging.info(f"Created APData folder in {forge_dir}")
for entry in os.scandir(apdata_dir):
if entry.name.endswith(".apmc") and entry.is_file():
if not os.path.samefile(apmc_file, entry.path):
os.remove(entry.path)
logging.info(f"Removed {entry.name} in {apdata_dir}")
else: # apmc already in apdata
copy_apmc = False
if copy_apmc:
copyfile(apmc_file, os.path.join(apdata_dir, os.path.basename(apmc_file)))
logging.info(f"Copied {os.path.basename(apmc_file)} to {apdata_dir}")
def read_apmc_file(apmc_file):
from base64 import b64decode
with open(apmc_file, 'r') as f:
return json.loads(b64decode(f.read()))
def update_mod(forge_dir, url: str):
"""Check mod version, download new mod from GitHub releases page if needed. """
ap_randomizer = find_ap_randomizer_jar(forge_dir)
os.path.basename(url)
if ap_randomizer is not None:
logging.info(f"Your current mod is {ap_randomizer}.")
else:
logging.info(f"You do not have the AP randomizer mod installed.")
if ap_randomizer != os.path.basename(url):
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
f"{os.path.basename(url)}")
if prompt_yes_no("Would you like to update?"):
old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None
new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url))
logging.info("Downloading AP randomizer mod. This may take a moment...")
apmod_resp = requests.get(url)
if apmod_resp.status_code == 200:
with open(new_ap_mod, 'wb') as f:
f.write(apmod_resp.content)
logging.info(f"Wrote new mod file to {new_ap_mod}")
if old_ap_mod is not None:
os.remove(old_ap_mod)
logging.info(f"Removed old mod file from {old_ap_mod}")
else:
logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).")
logging.error(f"Please report this issue on the Archipelago Discord server.")
sys.exit(1)
def check_eula(forge_dir):
"""Check if the EULA is agreed to, and prompt the user to read and agree if necessary."""
eula_path = os.path.join(forge_dir, "eula.txt")
if not os.path.isfile(eula_path):
# Create eula.txt
with open(eula_path, 'w') as f:
f.write("#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://account.mojang.com/documents/minecraft_eula).\n")
f.write(f"#{strftime('%a %b %d %X %Z %Y')}\n")
f.write("eula=false\n")
with open(eula_path, 'r+') as f:
text = f.read()
if 'false' in text:
# Prompt user to agree to the EULA
logging.info("You need to agree to the Minecraft EULA in order to run the server.")
logging.info("The EULA can be found at https://account.mojang.com/documents/minecraft_eula")
if prompt_yes_no("Do you agree to the EULA?"):
f.seek(0)
f.write(text.replace('false', 'true'))
f.truncate()
logging.info(f"Set {eula_path} to true")
else:
sys.exit(0)
def find_jdk_dir(version: str) -> str:
"""get the specified versions jdk directory"""
for entry in os.listdir():
if os.path.isdir(entry) and entry.startswith(f"jdk{version}"):
return os.path.abspath(entry)
def find_jdk(version: str) -> str:
"""get the java exe location"""
if is_windows:
jdk = find_jdk_dir(version)
jdk_exe = os.path.join(jdk, "bin", "java.exe")
if os.path.isfile(jdk_exe):
return jdk_exe
else:
jdk_exe = shutil.which(options["minecraft_options"].get("java", "java"))
if not jdk_exe:
raise Exception("Could not find Java. Is Java installed on the system?")
return jdk_exe
def download_java(java: str):
"""Download Corretto (Amazon JDK)"""
jdk = find_jdk_dir(java)
if jdk is not None:
print(f"Removing old JDK...")
from shutil import rmtree
rmtree(jdk)
print(f"Downloading Java...")
jdk_url = f"https://corretto.aws/downloads/latest/amazon-corretto-{java}-x64-windows-jdk.zip"
resp = requests.get(jdk_url)
if resp.status_code == 200: # OK
print(f"Extracting...")
import zipfile
from io import BytesIO
with zipfile.ZipFile(BytesIO(resp.content)) as zf:
zf.extractall()
else:
print(f"Error downloading Java (status code {resp.status_code}).")
print(f"If this was not expected, please report this issue on the Archipelago Discord server.")
if not prompt_yes_no("Continue anyways?"):
sys.exit(0)
def install_forge(directory: str, forge_version: str, java_version: str):
"""download and install forge"""
java_exe = find_jdk(java_version)
if java_exe is not None:
print(f"Downloading Forge {forge_version}...")
forge_url = f"https://maven.minecraftforge.net/net/minecraftforge/forge/{forge_version}/forge-{forge_version}-installer.jar"
resp = requests.get(forge_url)
if resp.status_code == 200: # OK
forge_install_jar = os.path.join(directory, "forge_install.jar")
if not os.path.exists(directory):
os.mkdir(directory)
with open(forge_install_jar, 'wb') as f:
f.write(resp.content)
print(f"Installing Forge...")
install_process = Popen([java_exe, "-jar", forge_install_jar, "--installServer", directory])
install_process.wait()
os.remove(forge_install_jar)
def run_forge_server(forge_dir: str, java_version: str, heap_arg: str) -> Popen:
"""Run the Forge server."""
java_exe = find_jdk(java_version)
if not os.path.isfile(java_exe):
java_exe = "java" # try to fall back on java in the PATH
heap_arg = max_heap_re.match(heap_arg).group()
if heap_arg[-1] in ['b', 'B']:
heap_arg = heap_arg[:-1]
heap_arg = "-Xmx" + heap_arg
os_args = "win_args.txt" if is_windows else "unix_args.txt"
args_file = os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version, os_args)
forge_args = []
with open(args_file) as argfile:
for line in argfile:
forge_args.extend(line.strip().split(" "))
args = [java_exe, heap_arg, *forge_args, "-nogui"]
logging.info(f"Running Forge server: {args}")
os.chdir(forge_dir)
return Popen(args)
def get_minecraft_versions(version, release_channel="release"):
version_file_endpoint = "https://raw.githubusercontent.com/KonoTyran/Minecraft_AP_Randomizer/master/versions/minecraft_versions.json"
resp = requests.get(version_file_endpoint)
local = False
if resp.status_code == 200: # OK
try:
data = resp.json()
except requests.exceptions.JSONDecodeError:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
else:
logging.warning(f"Unable to fetch version update file, using local version. (status code {resp.status_code}).")
local = True
if local:
with open(Utils.user_path("minecraft_versions.json"), 'r') as f:
data = json.load(f)
else:
with open(Utils.user_path("minecraft_versions.json"), 'w') as f:
json.dump(data, f)
try:
if version:
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
else:
return resp.json()[release_channel][0]
except (StopIteration, KeyError):
logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.")
if release_channel != "release":
logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file")
else:
logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.")
sys.exit(0)
def is_correct_forge(forge_dir) -> bool:
if os.path.isdir(os.path.join(forge_dir, "libraries", "net", "minecraftforge", "forge", forge_version)):
return True
return False
if __name__ == '__main__':
Utils.init_logging("MinecraftClient")
parser = argparse.ArgumentParser()
parser.add_argument("apmc_file", default=None, nargs='?', help="Path to an Archipelago Minecraft data file (.apmc)")
parser.add_argument('--install', '-i', dest='install', default=False, action='store_true',
help="Download and install Java and the Forge server. Does not launch the client afterwards.")
parser.add_argument('--release_channel', '-r', dest="channel", type=str, action='store',
help="Specify release channel to use.")
parser.add_argument('--java', '-j', metavar='17', dest='java', type=str, default=False, action='store',
help="specify java version.")
parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store',
help="specify forge version. (Minecraft Version-Forge Version)")
parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store',
help="specify Mod data version to download.")
args = parser.parse_args()
apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None
# Change to executable's working directory
os.chdir(os.path.abspath(os.path.dirname(sys.argv[0])))
options = Utils.get_options()
channel = args.channel or options["minecraft_options"]["release_channel"]
apmc_data = None
data_version = args.data_version or None
if apmc_file is None and not args.install:
apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),))
if apmc_file is not None and data_version is None:
apmc_data = read_apmc_file(apmc_file)
data_version = apmc_data.get('client_version', '')
versions = get_minecraft_versions(data_version, channel)
forge_dir = options["minecraft_options"]["forge_directory"]
max_heap = options["minecraft_options"]["max_heap_size"]
forge_version = args.forge or versions["forge"]
java_version = args.java or versions["java"]
mod_url = versions["url"]
java_dir = find_jdk_dir(java_version)
if args.install:
if is_windows:
print("Installing Java")
download_java(java_version)
if not is_correct_forge(forge_dir):
print("Installing Minecraft Forge")
install_forge(forge_dir, forge_version, java_version)
else:
print("Correct Forge version already found, skipping install.")
sys.exit(0)
if apmc_data is None:
raise FileNotFoundError(f"APMC file does not exist or is inaccessible at the given location ({apmc_file})")
if is_windows:
if java_dir is None or not os.path.isdir(java_dir):
if prompt_yes_no("Did not find java directory. Download and install java now?"):
download_java(java_version)
java_dir = find_jdk_dir(java_version)
if java_dir is None or not os.path.isdir(java_dir):
raise NotADirectoryError(f"Path {java_dir} does not exist or could not be accessed.")
if not is_correct_forge(forge_dir):
if prompt_yes_no(f"Did not find forge version {forge_version} download and install it now?"):
install_forge(forge_dir, forge_version, java_version)
if not os.path.isdir(forge_dir):
raise NotADirectoryError(f"Path {forge_dir} does not exist or could not be accessed.")
if not max_heap_re.match(max_heap):
raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.")
update_mod(forge_dir, mod_url)
replace_apmc_files(forge_dir, apmc_file)
check_eula(forge_dir)
server_process = run_forge_server(forge_dir, java_version, max_heap)
server_process.wait()

View File

@@ -458,8 +458,12 @@ class Context:
self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {}
if self.generator_version < Version(0, 6, 2):
min_version = Version(0, 1, 6)
else:
min_version = min_client_version
for player, version in clients_ver.items():
self.minimum_client_versions[player] = max(Version(*version), min_client_version)
self.minimum_client_versions[player] = max(Version(*version), min_version)
self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
@@ -1826,7 +1830,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 = {
@@ -1900,7 +1904,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)
)
@@ -1990,9 +1994,14 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
ctx.save()
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():

View File

@@ -106,6 +106,27 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
return obj
_base_types = str | int | bool | float | None | tuple["_base_types", ...] | dict["_base_types", "base_types"]
def convert_to_base_types(obj: typing.Any) -> _base_types:
if isinstance(obj, (tuple, list, set, frozenset)):
return tuple(convert_to_base_types(o) for o in obj)
elif isinstance(obj, dict):
return {convert_to_base_types(key): convert_to_base_types(value) for key, value in obj.items()}
elif obj is None or type(obj) in (str, int, float, bool):
return obj
# unwrap simple types to their base, such as StrEnum
elif isinstance(obj, str):
return str(obj)
elif isinstance(obj, int):
return int(obj)
elif isinstance(obj, float):
return float(obj)
else:
raise Exception(f"Cannot handle {type(obj)}")
_encode = JSONEncoder(
ensure_ascii=False,
check_circular=False,

View File

@@ -12,6 +12,7 @@ from CommonClient import CommonContext, server_loop, gui_enabled, \
import Utils
from Utils import async_start
from worlds import network_data_package
from worlds.oot import OOTWorld
from worlds.oot.Rom import Rom, compress_rom_file
from worlds.oot.N64Patch import apply_patch_file
from worlds.oot.Utils import data_path
@@ -280,7 +281,7 @@ async def n64_sync_task(ctx: OoTContext):
async def run_game(romfile):
auto_start = Utils.get_options()["oot_options"].get("rom_start", True)
auto_start = OOTWorld.settings.rom_start
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
@@ -295,7 +296,7 @@ async def patch_and_run_game(apz5_file):
decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
rom_file_name = OOTWorld.settings.rom_file
rom = Rom(rom_file_name)
sub_file = None

View File

@@ -24,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
@@ -1019,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):
@@ -1045,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)}")
@@ -1169,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))
@@ -1187,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)}.")
@@ -1353,6 +1359,7 @@ class StartInventory(ItemDict):
verify_item_name = True
display_name = "Start Inventory"
rich_text_doc = True
max = 10000
class StartInventoryPool(StartInventory):
@@ -1468,6 +1475,133 @@ 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", [])
if locations:
count = 1
else:
locations = ["Everywhere"]
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
@@ -1490,6 +1624,7 @@ class PerGameCommonOptions(CommonOptions):
exclude_locations: ExcludeLocations
priority_locations: PriorityLocations
item_links: ItemLinks
plando_items: PlandoItems
@dataclass
@@ -1543,6 +1678,7 @@ def get_option_groups(world: typing.Type[World], visibility_level: Visibility =
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True) -> None:
import os
from inspect import cleandoc
import yaml
from jinja2 import Template
@@ -1581,19 +1717,21 @@ def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], ge
# yaml dump may add end of document marker and newlines.
return yaml.dump(scalar).replace("...\n", "").strip()
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
template = Template(file_data)
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
option_groups = get_option_groups(world)
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
res = Template(file_data).render(
res = template.render(
option_groups=option_groups,
__version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar,
dictify_range=dictify_range,
cleandoc=cleandoc,
)
del file_data
with open(os.path.join(target_folder, get_file_safe_name(game_name) + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res)

View File

@@ -7,7 +7,6 @@ Currently, the following games are supported:
* The Legend of Zelda: A Link to the Past
* Factorio
* Minecraft
* Subnautica
* Risk of Rain 2
* The Legend of Zelda: Ocarina of Time
@@ -15,7 +14,6 @@ Currently, the following games are supported:
* Super Metroid
* Secret of Evermore
* Final Fantasy
* Rogue Legacy
* VVVVVV
* Raft
* Super Mario 64
@@ -42,7 +40,6 @@ Currently, the following games are supported:
* The Messenger
* Kingdom Hearts 2
* The Legend of Zelda: Link's Awakening DX
* Clique
* Adventure
* DLC Quest
* Noita
@@ -80,6 +77,9 @@ Currently, the following games are supported:
* Inscryption
* Civilization VI
* The Legend of Zelda: The Wind Waker
* Jak and Daxter: The Precursor Legacy
* Super Mario Land 2: 6 Golden Coins
* shapez
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

@@ -139,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
@@ -163,6 +166,10 @@ def home_path(*path: str) -> str:
os.symlink(home_path.cached_path, legacy_home_path)
else:
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
elif sys.platform == 'darwin':
import platformdirs
home_path.cached_path = platformdirs.user_data_dir("Archipelago", False)
os.makedirs(home_path.cached_path, 0o700, exist_ok=True)
else:
# not implemented
home_path.cached_path = local_path() # this will generate the same exceptions we got previously
@@ -174,7 +181,7 @@ def user_path(*path: str) -> str:
"""Returns either local_path or home_path based on write permissions."""
if hasattr(user_path, "cached_path"):
pass
elif os.access(local_path(), os.W_OK):
elif os.access(local_path(), os.W_OK) and not (is_macos and is_frozen()):
user_path.cached_path = local_path()
else:
user_path.cached_path = home_path()
@@ -223,7 +230,12 @@ def open_file(filename: typing.Union[str, "pathlib.Path"]) -> None:
from shutil import which
open_command = which("open") if is_macos else (which("xdg-open") or which("gnome-open") or which("kde-open"))
assert open_command, "Didn't find program for open_file! Please report this together with system details."
subprocess.call([open_command, filename])
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
subprocess.call([open_command, filename], env=env)
# from https://gist.github.com/pypt/94d747fe5180851196eb#gistcomment-4015118 with some changes
@@ -430,6 +442,7 @@ class RestrictedUnpickler(pickle.Unpickler):
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# used by OptionCounter
# necessary because the actual Options class instances are pickled when transfered to WebHost generation pool
if module == "collections" and name == "Counter":
return collections.Counter
# used by MultiServer -> savegame/multidata
@@ -537,6 +550,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
if add_timestamp:
stream_handler.setFormatter(formatter)
root_logger.addHandler(stream_handler)
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
# Relay unhandled exceptions to logger.
if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified
@@ -703,25 +718,30 @@ def _mp_open_filename(res: "multiprocessing.Queue[typing.Optional[str]]", *args:
res.put(open_filename(*args))
def _run_for_stdout(*args: str):
env = os.environ
if "LD_LIBRARY_PATH" in env:
env = env.copy()
del env["LD_LIBRARY_PATH"] # exe is a system binary, so reset LD_LIBRARY_PATH
return subprocess.run(args, capture_output=True, text=True, env=env).stdout.split("\n", 1)[0] or None
def open_filename(title: str, filetypes: typing.Iterable[typing.Tuple[str, typing.Iterable[str]]], suggest: str = "") \
-> typing.Optional[str]:
logging.info(f"Opening file input dialog for {title}.")
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes))
return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
return _run_for_stdout(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters)
zenity = which("zenity")
if zenity:
z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes)
selection = (f"--filename={suggest}",) if suggest else ()
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
try:
@@ -755,21 +775,18 @@ def _mp_open_directory(res: "multiprocessing.Queue[typing.Optional[str]]", *args
def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_linux:
# prefer native dialog
from shutil import which
kdialog = which("kdialog")
if kdialog:
return run(kdialog, f"--title={title}", "--getexistingdirectory",
return _run_for_stdout(kdialog, f"--title={title}", "--getexistingdirectory",
os.path.abspath(suggest) if suggest else ".")
zenity = which("zenity")
if zenity:
z_filters = ("--directory",)
selection = (f"--filename={os.path.abspath(suggest)}/",) if suggest else ()
return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
return _run_for_stdout(zenity, f"--title={title}", "--file-selection", *z_filters, *selection)
# fall back to tk
try:
@@ -796,9 +813,6 @@ def open_directory(title: str, suggest: str = "") -> typing.Optional[str]:
def messagebox(title: str, text: str, error: bool = False) -> None:
def run(*args: str):
return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None
if is_kivy_running():
from kvui import MessageBox
MessageBox(title, text, error).open()
@@ -809,10 +823,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
from shutil import which
kdialog = which("kdialog")
if kdialog:
return run(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
return _run_for_stdout(kdialog, f"--title={title}", "--error" if error else "--msgbox", text)
zenity = which("zenity")
if zenity:
return run(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
return _run_for_stdout(zenity, f"--title={title}", f"--text={text}", "--error" if error else "--info")
elif is_windows:
import ctypes

View File

@@ -80,10 +80,8 @@ def register():
"""Import submodules, triggering their registering on flask routing.
Note: initializes worlds subsystem."""
# has automatic patch integration
import worlds.AutoWorld
import worlds.Files
app.jinja_env.filters['supports_apdeltapatch'] = lambda game_name: \
game_name in worlds.Files.AutoPatchRegister.patch_types
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
from WebHostLib.customserver import run_server_process
# to trigger app routing picking up on it

View File

@@ -61,12 +61,7 @@ def download_slot_file(room_id, player_id: int):
else:
import io
if slot_data.game == "Minecraft":
from worlds.minecraft import mc_update_output
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc"
data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port)
return send_file(io.BytesIO(data), as_attachment=True, download_name=fname)
elif slot_data.game == "Factorio":
if slot_data.game == "Factorio":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
for name in zf.namelist():
if name.endswith("info.json"):

View File

@@ -1,4 +1,4 @@
flask>=3.1.0
flask>=3.1.1
werkzeug>=3.1.3
pony>=0.7.19
waitress>=3.0.2

View File

@@ -1,49 +0,0 @@
window.addEventListener('load', () => {
// Reload tracker every 15 seconds
const url = window.location;
setInterval(() => {
const ajax = new XMLHttpRequest();
ajax.onreadystatechange = () => {
if (ajax.readyState !== 4) { return; }
// Create a fake DOM using the returned HTML
const domParser = new DOMParser();
const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html');
// Update item tracker
document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML;
// Update only counters in the location-table
let counters = document.getElementsByClassName('counter');
const fakeCounters = fakeDOM.getElementsByClassName('counter');
for (let i = 0; i < counters.length; i++) {
counters[i].innerHTML = fakeCounters[i].innerHTML;
}
};
ajax.open('GET', url);
ajax.send();
}, 15000)
// Collapsible advancement sections
const categories = document.getElementsByClassName("location-category");
for (let i = 0; i < categories.length; i++) {
let hide_id = categories[i].id.split('-')[0];
if (hide_id == 'Total') {
continue;
}
categories[i].addEventListener('click', function() {
// Toggle the advancement list
document.getElementById(hide_id).classList.toggle("hide");
// Change text of the header
const tab_header = document.getElementById(hide_id+'-header').children[0];
const orig_text = tab_header.innerHTML;
let new_text;
if (orig_text.includes("▼")) {
new_text = orig_text.replace("▼", "▲");
}
else {
new_text = orig_text.replace("▲", "▼");
}
tab_header.innerHTML = new_text;
});
}
});

View File

@@ -1,102 +0,0 @@
#player-tracker-wrapper{
margin: 0;
}
#inventory-table{
border-top: 2px solid #000000;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
width: 384px;
background-color: #42b149;
}
#inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
#inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
filter: grayscale(100%) contrast(75%) brightness(30%);
}
#inventory-table img.acquired{
filter: none;
}
#inventory-table div.counted-item {
position: relative;
}
#inventory-table div.item-count {
position: absolute;
color: white;
font-family: "Minecraftia", monospace;
font-weight: bold;
bottom: 0;
right: 0;
}
#location-table{
width: 384px;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: #42b149;
padding: 0 3px 3px;
font-family: "Minecraftia", monospace;
font-size: 14px;
cursor: default;
}
#location-table th{
vertical-align: middle;
text-align: left;
padding-right: 10px;
}
#location-table td{
padding-top: 2px;
padding-bottom: 2px;
line-height: 20px;
}
#location-table td.counter {
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
text-align: right;
}
#location-table tr#Total-header {
font-weight: bold;
}
#location-table img{
height: 100%;
max-width: 30px;
max-height: 30px;
}
#location-table tbody.locations {
font-size: 12px;
}
#location-table td.location-name {
padding-left: 16px;
}
.hide {
display: none;
}

View File

@@ -26,30 +26,15 @@
<td>{{ patch.game }}</td>
<td>
{% if patch.data %}
{% if patch.game == "Minecraft" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMC File...</a>
{% elif patch.game == "Factorio" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Factorio Mod...</a>
{% elif patch.game == "Kingdom Hearts 2" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Kingdom Hearts 2 Mod...</a>
{% elif patch.game == "Ocarina of Time" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APZ5 File...</a>
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
{% if patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APV6 File...</a>
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APSM64EX File...</a>
{% elif patch.game | supports_apdeltapatch %}
{% elif patch.game | is_applayercontainer(patch.data, patch.player_id) %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a>
{% elif patch.game == "Final Fantasy Mystic Quest" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMQ File...</a>
{% else %}
No file to download for this game.
{% endif %}

View File

@@ -1,84 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='styles/minecraftTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/minecraftTracker.js') }}"></script>
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/minecraftia" type="text/css"/>
</head>
<body>
{# TODO: Replace this with a proper wrapper for each tracker when developing TrackerAPI. #}
<div style="margin-bottom: 0.5rem">
<a href="{{ url_for("get_generic_game_tracker", tracker=room.tracker, tracked_team=team, tracked_player=player) }}">Switch To Generic Tracker</a>
</div>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ tools_url }}" class="{{ 'acquired' }}" title="Progressive Tools" /></td>
<td><img src="{{ weapons_url }}" class="{{ 'acquired' }}" title="Progressive Weapons" /></td>
<td><img src="{{ armor_url }}" class="{{ 'acquired' }}" title="Progressive Armor" /></td>
<td><img src="{{ resource_crafting_url }}" class="{{ 'acquired' if 'Progressive Resource Crafting' in acquired_items }}"
title="Progressive Resource Crafting" /></td>
<td><img src="{{ icons['Brewing Stand'] }}" class="{{ 'acquired' if 'Brewing' in acquired_items }}" title="Brewing" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Ender Pearl'] }}" class="{{ 'acquired' if '3 Ender Pearls' in acquired_items }}" title="Ender Pearls" />
<div class="item-count">{{ pearls_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Bucket'] }}" class="{{ 'acquired' if 'Bucket' in acquired_items }}" title="Bucket" /></td>
<td><img src="{{ icons['Bow'] }}" class="{{ 'acquired' if 'Archery' in acquired_items }}" title="Archery" /></td>
<td><img src="{{ icons['Shield'] }}" class="{{ 'acquired' if 'Shield' in acquired_items }}" title="Shield" /></td>
<td><img src="{{ icons['Red Bed'] }}" class="{{ 'acquired' if 'Bed' in acquired_items }}" title="Bed" /></td>
<td><img src="{{ icons['Water Bottle'] }}" class="{{ 'acquired' if 'Bottles' in acquired_items }}" title="Bottles" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Netherite Scrap'] }}" class="{{ 'acquired' if '8 Netherite Scrap' in acquired_items }}" title="Netherite Scrap" />
<div class="item-count">{{ scrap_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Flint and Steel'] }}" class="{{ 'acquired' if 'Flint and Steel' in acquired_items }}" title="Flint and Steel" /></td>
<td><img src="{{ icons['Enchanting Table'] }}" class="{{ 'acquired' if 'Enchanting' in acquired_items }}" title="Enchanting" /></td>
<td><img src="{{ icons['Fishing Rod'] }}" class="{{ 'acquired' if 'Fishing Rod' in acquired_items }}" title="Fishing Rod" /></td>
<td><img src="{{ icons['Campfire'] }}" class="{{ 'acquired' if 'Campfire' in acquired_items }}" title="Campfire" /></td>
<td><img src="{{ icons['Spyglass'] }}" class="{{ 'acquired' if 'Spyglass' in acquired_items }}" title="Spyglass" /></td>
<td>
<div class="counted-item">
<img src="{{ icons['Dragon Egg Shard'] }}" class="{{ 'acquired' if 'Dragon Egg Shard' in acquired_items }}" title="Dragon Egg Shard" />
<div class="item-count">{{ shard_count }}</div>
</div>
</td>
</tr>
<tr>
<td><img src="{{ icons['Lead'] }}" class="{{ 'acquired' if 'Lead' in acquired_items }}" title="Lead" /></td>
<td><img src="{{ icons['Saddle'] }}" class="{{ 'acquired' if 'Saddle' in acquired_items }}" title="Saddle" /></td>
<td><img src="{{ icons['Channeling Book'] }}" class="{{ 'acquired' if 'Channeling Book' in acquired_items }}" title="Channeling Book" /></td>
<td><img src="{{ icons['Silk Touch Book'] }}" class="{{ 'acquired' if 'Silk Touch Book' in acquired_items }}" title="Silk Touch Book" /></td>
<td><img src="{{ icons['Piercing IV Book'] }}" class="{{ 'acquired' if 'Piercing IV Book' in acquired_items }}" title="Piercing IV Book" /></td>
</tr>
</table>
<table id="location-table">
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">
<td>{{ area }} {{'▼' if area != 'Total'}}</td>
<td class="counter">{{ checks_done[area] }} / {{ checks_in_area[area] }}</td>
</tr>
<tbody class="locations hide" id="{{area}}">
{% for location in location_info[area] %}
<tr>
<td class="location-name">{{ location }}</td>
<td class="counter">{{ '✔' if location_info[area][location] else '' }}</td>
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -706,127 +706,6 @@ if "A Link to the Past" in network_data_package["games"]:
_multiworld_trackers["A Link to the Past"] = render_ALinkToThePast_multiworld_tracker
_player_trackers["A Link to the Past"] = render_ALinkToThePast_tracker
if "Minecraft" in network_data_package["games"]:
def render_Minecraft_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
icons = {
"Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png",
"Stone Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c4/Stone_Pickaxe_JE2_BE2.png",
"Iron Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d1/Iron_Pickaxe_JE3_BE2.png",
"Diamond Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e7/Diamond_Pickaxe_JE3_BE3.png",
"Wooden Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d5/Wooden_Sword_JE2_BE2.png",
"Stone Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b1/Stone_Sword_JE2_BE2.png",
"Iron Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/8/8e/Iron_Sword_JE2_BE2.png",
"Diamond Sword": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/4/44/Diamond_Sword_JE3_BE3.png",
"Leather Tunic": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b7/Leather_Tunic_JE4_BE2.png",
"Iron Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Iron_Chestplate_JE2_BE2.png",
"Diamond Chestplate": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/e/e0/Diamond_Chestplate_JE3_BE2.png",
"Iron Ingot": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Iron_Ingot_JE3_BE2.png",
"Block of Iron": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7e/Block_of_Iron_JE4_BE3.png",
"Brewing Stand": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/b/b3/Brewing_Stand_%28empty%29_JE10.png",
"Ender Pearl": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/f6/Ender_Pearl_JE3_BE2.png",
"Bucket": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/f/fc/Bucket_JE2_BE2.png",
"Bow": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/a/ab/Bow_%28Pull_2%29_JE1_BE1.png",
"Shield": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c6/Shield_JE2_BE1.png",
"Red Bed": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/6/6a/Red_Bed_%28N%29.png",
"Netherite Scrap": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/33/Netherite_Scrap_JE2_BE1.png",
"Flint and Steel": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/94/Flint_and_Steel_JE4_BE2.png",
"Enchanting Table": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/31/Enchanting_Table.gif",
"Fishing Rod": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/7f/Fishing_Rod_JE2_BE2.png",
"Campfire": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/9/91/Campfire_JE2_BE2.gif",
"Water Bottle": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/7/75/Water_Bottle_JE2_BE2.png",
"Spyglass": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/c/c1/Spyglass_JE2_BE1.png",
"Dragon Egg Shard": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/3/38/Dragon_Egg_JE4.png",
"Lead": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/1/1f/Lead_JE2_BE2.png",
"Saddle": "https://i.imgur.com/2QtDyR0.png",
"Channeling Book": "https://i.imgur.com/J3WsYZw.png",
"Silk Touch Book": "https://i.imgur.com/iqERxHQ.png",
"Piercing IV Book": "https://i.imgur.com/OzJptGz.png",
}
minecraft_location_ids = {
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
42041, 42049, 42004, 42031, 42025, 42029, 42051, 42077],
"Nether": [42017, 42044, 42069, 42058, 42034, 42060, 42066, 42076, 42064, 42071, 42021,
42062, 42008, 42061, 42033, 42011, 42006, 42019, 42000, 42040, 42001, 42015, 42104, 42014],
"The End": [42052, 42005, 42012, 42032, 42030, 42042, 42018, 42038, 42046],
"Adventure": [42047, 42050, 42096, 42097, 42098, 42059, 42055, 42072, 42003, 42109, 42035, 42016, 42020,
42048, 42054, 42068, 42043, 42106, 42074, 42075, 42024, 42026, 42037, 42045, 42056, 42105,
42099, 42103, 42110, 42100],
"Husbandry": [42065, 42067, 42078, 42022, 42113, 42107, 42007, 42079, 42013, 42028, 42036, 42108, 42111,
42112,
42057, 42063, 42053, 42102, 42101, 42092, 42093, 42094, 42095],
"Archipelago": [42080, 42081, 42082, 42083, 42084, 42085, 42086, 42087, 42088, 42089, 42090, 42091],
}
display_data = {}
# Determine display for progressive items
progressive_items = {
"Progressive Tools": 45013,
"Progressive Weapons": 45012,
"Progressive Armor": 45014,
"Progressive Resource Crafting": 45001
}
progressive_names = {
"Progressive Tools": ["Wooden Pickaxe", "Stone Pickaxe", "Iron Pickaxe", "Diamond Pickaxe"],
"Progressive Weapons": ["Wooden Sword", "Stone Sword", "Iron Sword", "Diamond Sword"],
"Progressive Armor": ["Leather Tunic", "Iron Chestplate", "Diamond Chestplate"],
"Progressive Resource Crafting": ["Iron Ingot", "Iron Ingot", "Block of Iron"]
}
inventory = tracker_data.get_player_inventory_counts(team, player)
for item_name, item_id in progressive_items.items():
level = min(inventory[item_id], len(progressive_names[item_name]) - 1)
display_name = progressive_names[item_name][level]
base_name = item_name.split(maxsplit=1)[1].lower().replace(" ", "_")
display_data[base_name + "_url"] = icons[display_name]
# Multi-items
multi_items = {
"3 Ender Pearls": 45029,
"8 Netherite Scrap": 45015,
"Dragon Egg Shard": 45043
}
for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower()
count = inventory[item_id]
if count >= 0:
display_data[base_name + "_count"] = count
# Victory condition
game_state = tracker_data.get_player_client_status(team, player)
display_data["game_finished"] = game_state == 30
# Turn location IDs into advancement tab counts
checked_locations = tracker_data.get_player_checked_locations(team, player)
lookup_name = lambda id: tracker_data.location_id_to_name["Minecraft"][id]
location_info = {tab_name: {lookup_name(id): (id in checked_locations) for id in tab_locations}
for tab_name, tab_locations in minecraft_location_ids.items()}
checks_done = {tab_name: len([id for id in tab_locations if id in checked_locations])
for tab_name, tab_locations in minecraft_location_ids.items()}
checks_done["Total"] = len(checked_locations)
checks_in_area = {tab_name: len(tab_locations) for tab_name, tab_locations in minecraft_location_ids.items()}
checks_in_area["Total"] = sum(checks_in_area.values())
lookup_any_item_id_to_name = tracker_data.item_id_to_name["Minecraft"]
return render_template(
"tracker__Minecraft.html",
inventory=inventory,
icons=icons,
acquired_items={lookup_any_item_id_to_name[id] for id, count in inventory.items() if count > 0},
player=player,
team=team,
room=tracker_data.room,
player_name=tracker_data.get_player_name(team, player),
saving_second=tracker_data.get_room_saving_second(),
checks_done=checks_done,
checks_in_area=checks_in_area,
location_info=location_info,
**display_data,
)
_player_trackers["Minecraft"] = render_Minecraft_tracker
if "Ocarina of Time" in network_data_package["games"]:
def render_OcarinaOfTime_tracker(tracker_data: TrackerData, team: int, player: int) -> str:
icons = {

View File

@@ -119,9 +119,9 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
# AP Container
elif handler:
data = zfile.open(file, "r").read()
patch = handler(BytesIO(data))
patch.read()
files[patch.player] = data
with zipfile.ZipFile(BytesIO(data)) as container:
player = json.loads(container.open("archipelago.json").read())["player"]
files[player] = data
# Spoiler
elif file.filename.endswith(".txt"):
@@ -135,11 +135,6 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
flash("Could not load multidata. File may be corrupted or incompatible.")
multidata = None
# Minecraft
elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read()
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
files[metadata["player_id"]] = data
# Factorio
elif file.filename.endswith(".zip"):

View File

@@ -24,9 +24,20 @@
<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
<MDNavigationItemBase>:
on_release: app.screens.switch_screens(self)
MDNavigationItemLabel:
text: root.text
theme_text_color: "Custom"
text_color_active: self.theme_cls.primaryColor
text_color_normal: 1, 1, 1, 1
# indicator is on icon only for some reason
canvas.before:
Color:
rgba: self.theme_cls.secondaryContainerColor if root.active else self.theme_cls.transparentColor
Rectangle:
size: root.size
<TooltipLabel>:
adaptive_height: True
theme_font_size: "Custom"
@@ -222,3 +233,8 @@
spacing: 10
size_hint_y: None
height: self.minimum_height
<MessageBoxLabel>:
valign: "middle"
halign: "center"
text_size: self.width, None
height: self.texture_size[1]

View File

@@ -365,18 +365,14 @@ request_handlers = {
["PREFERRED_CORES"] = function (req)
local res = {}
local preferred_cores = client.getconfig().PreferredCores
local systems_enumerator = preferred_cores.Keys:GetEnumerator()
res["type"] = "PREFERRED_CORES_RESPONSE"
res["value"] = {}
res["value"]["NES"] = preferred_cores.NES
res["value"]["SNES"] = preferred_cores.SNES
res["value"]["GB"] = preferred_cores.GB
res["value"]["GBC"] = preferred_cores.GBC
res["value"]["DGB"] = preferred_cores.DGB
res["value"]["SGB"] = preferred_cores.SGB
res["value"]["PCE"] = preferred_cores.PCE
res["value"]["PCECD"] = preferred_cores.PCECD
res["value"]["SGX"] = preferred_cores.SGX
while systems_enumerator:MoveNext() do
res["value"][systems_enumerator.Current] = preferred_cores[systems_enumerator.Current]
end
return res
end,

View File

@@ -1,462 +0,0 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
require("common")
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local ITEM_INDEX = 0x03
local WEAPON_INDEX = 0x07
local ARMOR_INDEX = 0x0B
local goldLookup = {
[0x16C] = 10,
[0x16D] = 20,
[0x16E] = 25,
[0x16F] = 30,
[0x170] = 55,
[0x171] = 70,
[0x172] = 85,
[0x173] = 110,
[0x174] = 135,
[0x175] = 155,
[0x176] = 160,
[0x177] = 180,
[0x178] = 240,
[0x179] = 255,
[0x17A] = 260,
[0x17B] = 295,
[0x17C] = 300,
[0x17D] = 315,
[0x17E] = 330,
[0x17F] = 350,
[0x180] = 385,
[0x181] = 400,
[0x182] = 450,
[0x183] = 500,
[0x184] = 530,
[0x185] = 575,
[0x186] = 620,
[0x187] = 680,
[0x188] = 750,
[0x189] = 795,
[0x18A] = 880,
[0x18B] = 1020,
[0x18C] = 1250,
[0x18D] = 1455,
[0x18E] = 1520,
[0x18F] = 1760,
[0x190] = 1975,
[0x191] = 2000,
[0x192] = 2750,
[0x193] = 3400,
[0x194] = 4150,
[0x195] = 5000,
[0x196] = 5450,
[0x197] = 6400,
[0x198] = 6720,
[0x199] = 7340,
[0x19A] = 7690,
[0x19B] = 7900,
[0x19C] = 8135,
[0x19D] = 9000,
[0x19E] = 9300,
[0x19F] = 9500,
[0x1A0] = 9900,
[0x1A1] = 10000,
[0x1A2] = 12350,
[0x1A3] = 13000,
[0x1A4] = 13450,
[0x1A5] = 14050,
[0x1A6] = 14720,
[0x1A7] = 15000,
[0x1A8] = 17490,
[0x1A9] = 18010,
[0x1AA] = 19990,
[0x1AB] = 20000,
[0x1AC] = 20010,
[0x1AD] = 26000,
[0x1AE] = 45000,
[0x1AF] = 65000
}
local extensionConsumableLookup = {
[432] = 0x3C,
[436] = 0x3C,
[440] = 0x3C,
[433] = 0x3D,
[437] = 0x3D,
[441] = 0x3D,
[434] = 0x3E,
[438] = 0x3E,
[442] = 0x3E,
[435] = 0x3F,
[439] = 0x3F,
[443] = 0x3F
}
local noOverworldItemsLookup = {
[499] = 0x2B,
[500] = 0x12,
}
local consumableStacks = nil
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local ff1Socket = nil
local frame = 0
local isNesHawk = false
--Sets correct memory access functions based on whether NesHawk or QuickNES is loaded
local function defineMemoryFunctions()
local memDomain = {}
local domains = memory.getmemorydomainlist()
if domains[1] == "System Bus" then
--NesHawk
isNesHawk = true
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
memDomain["saveram"] = function() memory.usememorydomain("Battery RAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
elseif domains[1] == "WRAM" then
--QuickNES
memDomain["systembus"] = function() memory.usememorydomain("System Bus") end
memDomain["saveram"] = function() memory.usememorydomain("WRAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
end
return memDomain
end
local memDomain = defineMemoryFunctions()
local function StateOKForMainLoop()
memDomain.saveram()
local A = u8(0x102) -- Party Made
local B = u8(0x0FC)
local C = u8(0x0A3)
return A ~= 0x00 and not (A== 0xF2 and B == 0xF2 and C == 0xF2)
end
function generateLocationChecked()
memDomain.saveram()
data = uRange(0x01FF, 0x101)
data[0] = nil
return data
end
function setConsumableStacks()
memDomain.rom()
consumableStacks = {}
-- In order shards, tent, cabin, house, heal, pure, soft, ext1, ext2, ext3, ex4
consumableStacks[0x35] = 1
consumableStacks[0x36] = u8(0x47400) + 1
consumableStacks[0x37] = u8(0x47401) + 1
consumableStacks[0x38] = u8(0x47402) + 1
consumableStacks[0x39] = u8(0x47403) + 1
consumableStacks[0x3A] = u8(0x47404) + 1
consumableStacks[0x3B] = u8(0x47405) + 1
consumableStacks[0x3C] = u8(0x47406) + 1
consumableStacks[0x3D] = u8(0x47407) + 1
consumableStacks[0x3E] = u8(0x47408) + 1
consumableStacks[0x3F] = u8(0x47409) + 1
end
function getEmptyWeaponSlots()
memDomain.saveram()
ret = {}
count = 1
slot1 = uRange(0x118, 0x4)
slot2 = uRange(0x158, 0x4)
slot3 = uRange(0x198, 0x4)
slot4 = uRange(0x1D8, 0x4)
for i,v in pairs(slot1) do
if v == 0 then
ret[count] = 0x118 + i
count = count + 1
end
end
for i,v in pairs(slot2) do
if v == 0 then
ret[count] = 0x158 + i
count = count + 1
end
end
for i,v in pairs(slot3) do
if v == 0 then
ret[count] = 0x198 + i
count = count + 1
end
end
for i,v in pairs(slot4) do
if v == 0 then
ret[count] = 0x1D8 + i
count = count + 1
end
end
return ret
end
function getEmptyArmorSlots()
memDomain.saveram()
ret = {}
count = 1
slot1 = uRange(0x11C, 0x4)
slot2 = uRange(0x15C, 0x4)
slot3 = uRange(0x19C, 0x4)
slot4 = uRange(0x1DC, 0x4)
for i,v in pairs(slot1) do
if v == 0 then
ret[count] = 0x11C + i
count = count + 1
end
end
for i,v in pairs(slot2) do
if v == 0 then
ret[count] = 0x15C + i
count = count + 1
end
end
for i,v in pairs(slot3) do
if v == 0 then
ret[count] = 0x19C + i
count = count + 1
end
end
for i,v in pairs(slot4) do
if v == 0 then
ret[count] = 0x1DC + i
count = count + 1
end
end
return ret
end
local function slice (tbl, s, e)
local pos, new = 1, {}
for i = s + 1, e do
new[pos] = tbl[i]
pos = pos + 1
end
return new
end
function processBlock(block)
local msgBlock = block['messages']
if msgBlock ~= nil then
for i, v in pairs(msgBlock) do
if itemMessages[i] == nil then
local msg = {TTL=450, message=v, color=0xFFFF0000}
itemMessages[i] = msg
end
end
end
local itemsBlock = block["items"]
memDomain.saveram()
isInGame = u8(0x102)
if itemsBlock ~= nil and isInGame ~= 0x00 then
if consumableStacks == nil then
setConsumableStacks()
end
memDomain.saveram()
-- print('ITEMBLOCK: ')
-- print(itemsBlock)
itemIndex = u8(ITEM_INDEX)
-- print('ITEMINDEX: '..itemIndex)
for i, v in pairs(slice(itemsBlock, itemIndex, #itemsBlock)) do
-- Minus the offset and add to the correct domain
local memoryLocation = v
if v >= 0x100 and v <= 0x114 then
-- This is a key item
memoryLocation = memoryLocation - 0x0E0
wU8(memoryLocation, 0x01)
elseif v >= 0x1E0 and v <= 0x1F2 then
-- This is a movement item
-- Minus Offset (0x100) - movement offset (0xE0)
memoryLocation = memoryLocation - 0x1E0
-- Canal is a flipped bit
if memoryLocation == 0x0C then
wU8(memoryLocation, 0x00)
else
wU8(memoryLocation, 0x01)
end
elseif v >= 0x1F3 and v <= 0x1F4 then
-- NoOverworld special items
memoryLocation = noOverworldItemsLookup[v]
wU8(memoryLocation, 0x01)
elseif v >= 0x16C and v <= 0x1AF then
-- This is a gold item
amountToAdd = goldLookup[v]
biggest = u8(0x01E)
medium = u8(0x01D)
smallest = u8(0x01C)
currentValue = 0x10000 * biggest + 0x100 * medium + smallest
newValue = currentValue + amountToAdd
newBiggest = math.floor(newValue / 0x10000)
newMedium = math.floor(math.fmod(newValue, 0x10000) / 0x100)
newSmallest = math.floor(math.fmod(newValue, 0x100))
wU8(0x01E, newBiggest)
wU8(0x01D, newMedium)
wU8(0x01C, newSmallest)
elseif v >= 0x115 and v <= 0x11B then
-- This is a regular consumable OR a shard
-- Minus Offset (0x100) + item offset (0x20)
memoryLocation = memoryLocation - 0x0E0
currentValue = u8(memoryLocation)
amountToAdd = consumableStacks[memoryLocation]
if currentValue < 99 then
wU8(memoryLocation, currentValue + amountToAdd)
end
elseif v >= 0x1B0 and v <= 0x1BB then
-- This is an extension consumable
memoryLocation = extensionConsumableLookup[v]
currentValue = u8(memoryLocation)
amountToAdd = consumableStacks[memoryLocation]
if currentValue < 99 then
value = currentValue + amountToAdd
if value > 99 then
value = 99
end
wU8(memoryLocation, value)
end
end
end
if #itemsBlock > itemIndex then
wU8(ITEM_INDEX, #itemsBlock)
end
memDomain.saveram()
weaponIndex = u8(WEAPON_INDEX)
emptyWeaponSlots = getEmptyWeaponSlots()
lastUsedWeaponIndex = weaponIndex
-- print('WEAPON_INDEX: '.. weaponIndex)
memDomain.saveram()
for i, v in pairs(slice(itemsBlock, weaponIndex, #itemsBlock)) do
if v >= 0x11C and v <= 0x143 then
-- Minus the offset and add to the correct domain
local itemValue = v - 0x11B
if #emptyWeaponSlots > 0 then
slot = table.remove(emptyWeaponSlots, 1)
wU8(slot, itemValue)
lastUsedWeaponIndex = weaponIndex + i
else
break
end
end
end
if lastUsedWeaponIndex ~= weaponIndex then
wU8(WEAPON_INDEX, lastUsedWeaponIndex)
end
memDomain.saveram()
armorIndex = u8(ARMOR_INDEX)
emptyArmorSlots = getEmptyArmorSlots()
lastUsedArmorIndex = armorIndex
-- print('ARMOR_INDEX: '.. armorIndex)
memDomain.saveram()
for i, v in pairs(slice(itemsBlock, armorIndex, #itemsBlock)) do
if v >= 0x144 and v <= 0x16B then
-- Minus the offset and add to the correct domain
local itemValue = v - 0x143
if #emptyArmorSlots > 0 then
slot = table.remove(emptyArmorSlots, 1)
wU8(slot, itemValue)
lastUsedArmorIndex = armorIndex + i
else
break
end
end
end
if lastUsedArmorIndex ~= armorIndex then
wU8(ARMOR_INDEX, lastUsedArmorIndex)
end
end
end
function receive()
l, e = ff1Socket:receive()
if e == 'closed' then
if curstate == STATE_OK then
print("Connection closed")
end
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
print("timeout")
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
processBlock(json.decode(l))
-- Determine Message to send back
memDomain.rom()
local playerName = uRange(0x7BCBF, 0x41)
playerName[0] = nil
local retTable = {}
retTable["playerName"] = playerName
if StateOKForMainLoop() then
retTable["locations"] = generateLocationChecked()
end
msg = json.encode(retTable).."\n"
local ret, error = ff1Socket:send(msg)
if ret == nil then
print(error)
elseif curstate == STATE_INITIAL_CONNECTION_MADE then
curstate = STATE_TENTATIVELY_CONNECTED
elseif curstate == STATE_TENTATIVELY_CONNECTED then
print("Connected!")
itemMessages["(0,0)"] = {TTL=240, message="Connected", color="green"}
curstate = STATE_OK
end
end
function main()
if not checkBizHawkVersion() then
return
end
server, error = socket.bind('localhost', 52980)
while true do
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
frame = frame + 1
drawMessages()
if not (curstate == prevstate) then
-- console.log("Current state: "..curstate)
prevstate = curstate
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Blue")
receive()
else
gui.drawEllipse(248, 9, 6, 6, "Black", "Green")
end
elseif (curstate == STATE_UNINITIALIZED) then
gui.drawEllipse(248, 9, 6, 6, "Black", "White")
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
drawText(5, 8, "Waiting for client", 0xFFFF0000)
drawText(5, 32, "Please start FF1Client.exe", 0xFFFF0000)
-- Advance so the messages are drawn
emu.frameadvance()
server:settimeout(2)
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
-- print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE
ff1Socket = client
ff1Socket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

View File

@@ -477,7 +477,7 @@ function main()
elseif (curstate == STATE_UNINITIALIZED) then
-- If we're uninitialized, attempt to make the connection.
if (frame % 120 == 0) then
server:settimeout(2)
server:settimeout(120)
local client, timeout = server:accept()
if timeout == nil then
print('Initial Connection Made')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -51,10 +51,9 @@ requires:
{%- for option_key, option in group_options.items() %}
{{ option_key }}:
{%- if option.__doc__ %}
# {{ option.__doc__
# {{ cleandoc(option.__doc__)
| trim
| replace('\n\n', '\n \n')
| replace('\n ', '\n# ')
| replace('\n', '\n# ')
| indent(4, first=False)
}}
{%- endif -%}

View File

@@ -48,9 +48,6 @@
# Civilization VI
/worlds/civ6/ @hesto2
# Clique
/worlds/clique/ @ThePhar
# Dark Souls III
/worlds/dark_souls_3/ @Marechal-L @nex3
@@ -87,6 +84,9 @@
# Inscryption
/worlds/inscryption/ @DrBibop @Glowbuzz
# Jak and Daxter: The Precursor Legacy
/worlds/jakanddaxter/ @massimilianodelliubaldini
# Kirby's Dream Land 3
/worlds/kdl3/ @Silvris
@@ -118,9 +118,6 @@
# The Messenger
/worlds/messenger/ @alwaysintreble
# Minecraft
/worlds/minecraft/ @KonoTyran @espeon65536
# Mega Man 2
/worlds/mm2/ @Silvris
@@ -148,15 +145,15 @@
# Raft
/worlds/raft/ @SunnyBat
# Rogue Legacy
/worlds/rogue_legacy/ @ThePhar
# Risk of Rain 2
/worlds/ror2/ @kindasneaki
# Saving Princess
/worlds/saving_princess/ @LeonarthCG
# shapez
/worlds/shapez/ @BlastSlimey
# Shivers
/worlds/shivers/ @GodlFire @korydondzila
@@ -175,6 +172,9 @@
# Super Mario 64
/worlds/sm64ex/ @N00byKing
# Super Mario Land 2: 6 Golden Coins
/worlds/marioland2/ @Alchav
# Super Mario World
/worlds/smw/ @PoryGone
@@ -232,7 +232,7 @@
## Active Unmaintained Worlds
# The following worlds in this repo are currently unmaintained, but currently still work in core. If any update breaks
# compatibility, these worlds may be moved to `worlds_disabled`. If you are interested in stepping up as maintainer for
# compatibility, these worlds may be deleted. If you are interested in stepping up as maintainer for
# any of these worlds, please review `/docs/world maintainer.md` documentation.
# Final Fantasy (1)
@@ -241,15 +241,6 @@
# Ocarina of Time
# /worlds/oot/
## Disabled Unmaintained Worlds
# The following worlds in this repo are currently unmaintained and disabled as they do not work in core. If you are
# interested in stepping up as maintainer for any of these worlds, please review `/docs/world maintainer.md`
# documentation.
# Ori and the Blind Forest
# /worlds_disabled/oribf/
###################
## Documentation ##
###################

View File

@@ -122,3 +122,21 @@ Concrete examples of soft logic include:
- 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,12 +117,6 @@ flowchart LR
%% Java Based Games
subgraph Java
JM[Mod with Archipelago.MultiClient.Java]
subgraph Minecraft
MCS[Minecraft Forge Server]
JMC[Any Java Minecraft Clients]
MCS <-- TCP --> JMC
end
JM <-- Forge Mod Loader --> MCS
end
AS <-- WebSockets --> JM
@@ -131,10 +125,8 @@ flowchart LR
NM[Mod with Archipelago.MultiClient.Net]
subgraph FNA/XNA
TS[Timespinner]
RL[Rogue Legacy]
end
NM <-- TsRandomizer --> TS
NM <-- RogueLegacyRandomizer --> RL
subgraph Unity
ROR[Risk of Rain 2]
SN[Subnautica]
@@ -183,4 +175,4 @@ flowchart LR
FMOD <--> FMAPI
end
CC <-- Integrated --> FC
```
```

View File

@@ -231,11 +231,11 @@ Sent to clients after a client requested this message be sent to them, more info
Sent to clients if the server caught a problem with a packet. This only occurs for errors that are explicitly checked for.
#### Arguments
| Name | Type | Notes |
| ---- | ---- | ----- |
| type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. |
| original_cmd | Optional[str] | The `cmd` argument of the faulty packet, will be `None` if the `cmd` failed to be parsed. |
| text | str | A descriptive message of the problem at hand. |
| Name | Type | Notes |
| ---- |-------------| ----- |
| type | str | The [PacketProblemType](#PacketProblemType) that was detected in the packet. |
| original_cmd | str \| None | The `cmd` argument of the faulty packet, will be `None` if the `cmd` failed to be parsed. |
| text | str | A descriptive message of the problem at hand. |
##### PacketProblemType
`PacketProblemType` indicates the type of problem that was detected in the faulty packet, the known problem types are below but others may be added in the future.
@@ -551,14 +551,14 @@ In JSON this may look like:
Message nodes sent along with [PrintJSON](#PrintJSON) packet to be reconstructed into a legible message. The nodes are intended to be read in the order they are listed in the packet.
```python
from typing import TypedDict, Optional
from typing import TypedDict
class JSONMessagePart(TypedDict):
type: Optional[str]
text: Optional[str]
color: Optional[str] # only available if type is a color
flags: Optional[int] # only available if type is an item_id or item_name
player: Optional[int] # only available if type is either item or location
hint_status: Optional[HintStatus] # only available if type is hint_status
type: str | None
text: str | None
color: str | None # only available if type is a color
flags: int | None # only available if type is an item_id or item_name
player: int | None # only available if type is either item or location
hint_status: HintStatus | None # only available if type is hint_status
```
`type` is used to denote the intent of the message part. This can be used to indicate special information which may be rendered differently depending on client. How these types are displayed in Archipelago's ALttP client is not the end-all be-all. Other clients may choose to interpret and display these messages differently.

View File

@@ -333,7 +333,7 @@ within the world.
### TextChoice
Like choice allows you to predetermine options and has all of the same comparison methods and handling. Also accepts any
user defined string as a valid option, so will either need to be validated by adding a validation step to the option
class or within world, if necessary. Value for this class is `Union[str, int]` so if you need the value at a specified
class or within world, if necessary. Value for this class is `str | int` so if you need the value at a specified
point, `self.options.my_option.current_key` will always return a string.
### PlandoBosses

View File

@@ -102,17 +102,16 @@ In worlds, this should only be used for the top level to avoid issues when upgra
### Bool
Since `bool` can not be subclassed, use the `settings.Bool` helper in a `typing.Union` to get a comment in host.yaml.
Since `bool` can not be subclassed, use the `settings.Bool` helper in a union to get a comment in host.yaml.
```python
import settings
import typing
class MySettings(settings.Group):
class MyBool(settings.Bool):
"""Doc string"""
my_value: typing.Union[MyBool, bool] = True
my_value: MyBool | bool = True
```
### UserFilePath
@@ -134,15 +133,15 @@ Checks the file against [md5s](#md5s) by default.
Resolves to an executable (varying file extension based on platform)
#### description: Optional\[str\]
#### description: str | None
Human-readable name to use in file browser
#### copy_to: Optional\[str\]
#### copy_to: str | None
Instead of storing the path, copy the file.
#### md5s: List[Union[str, bytes]]
#### md5s: list[str | bytes]
Provide md5 hashes as hex digests or raw bytes for automatic validation.

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):
@@ -119,8 +124,12 @@ variable to keep all the benefits of the test framework while not running the ma
#### 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

View File

@@ -258,31 +258,6 @@ another flag like "progression", it means "an especially useful progression item
* `progression_skip_balancing`: the combination of `progression` and `skip_balancing`, i.e., a progression item that
will not be moved around by progression balancing; used, e.g., for currency or tokens, to not flood early spheres
### Events
An Event is a special combination of a Location and an Item, with both having an `id` of `None`. These can be used to
track certain logic interactions, with the Event Item being required for access in other locations or regions, but not
being "real". Since the item and location have no ID, they get dropped at the end of generation and so the server is
never made aware of them and these locations can never be checked, nor can the items be received during play.
They may also be used for making the spoiler log look nicer, i.e. by having a `"Victory"` Event Item, that
is required to finish the game. This makes it very clear when the player finishes, rather than only seeing their last
relevant Item. Events function just like any other Location, and can still have their own access rules, etc.
By convention, the Event "pair" of Location and Item typically have the same name, though this is not a requirement.
They must not exist in the `name_to_id` lookups, as they have no ID.
The most common way to create an Event pair is to create and place the Item on the Location as soon as it's created:
```python
from worlds.AutoWorld import World
from BaseClasses import ItemClassification
from .subclasses import MyGameLocation, MyGameItem
class MyGameWorld(World):
victory_loc = MyGameLocation(self.player, "Victory", None)
victory_loc.place_locked_item(MyGameItem("Victory", ItemClassification.progression, None, self.player))
```
### Regions
Regions are logical containers that typically hold locations that share some common access rules. If location logic is
@@ -291,7 +266,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#L298-L299)),
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#L310-L311)),
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
@@ -339,6 +314,63 @@ avoiding the need for indirect conditions at the expense of performance.
An item rule is a function that returns `True` or `False` for a `Location` based on a single item. It can be used to
reject the placement of an item there.
### Events (or "generation-only items/locations")
An event item or location is one that only exists during multiworld generation; the server is never made aware of them.
Event locations can never be checked by the player, and event items cannot be received during play.
Events are used to represent in-game actions (that aren't regular Archipelago locations) when either:
* We want to show in the spoiler log when the player is expected to perform the in-game action.
* It's the cleanest way to represent how that in-game action impacts logic.
Typical examples include completing the goal, defeating a boss, or flipping a switch that affects multiple areas.
To be precise: the term "event" on its own refers to the special combination of an "event item" placed on an "event
location". Event items and locations are created the same way as normal items and locations, except that they have an
`id` of `None`, and an event item must be placed on an event location
(and vice versa). Finally, although events are often described as "fake" items and locations, it's important to
understand that they are perfectly real during generation.
The most common way to create an event is to create the event item and the event location, then immediately call
`Location.place_locked_item()`:
```python
victory_loc = MyGameLocation(self.player, "Defeat the Final Boss", None, final_boss_arena_region)
victory_loc.place_locked_item(MyGameItem("Victory", ItemClassification.progression, None, self.player))
self.multiworld.completion_condition[self.player] = lambda state: state.has("Victory", self.player)
set_rule(victory_loc, lambda state: state.has("Boss Defeating Sword", self.player))
```
Requiring an event to finish the game will make the spoiler log display an additional
`Defeat the Final Boss: Victory` line when the player is expected to finish, rather than only showing their last
relevant item. But events aren't just about the spoiler log; a more substantial example of using events to structure
your logic might be:
```python
water_loc = MyGameLocation(self.player, "Water Level Switch", None, pump_station_region)
water_loc.place_locked_item(MyGameItem("Lowered Water Level", ItemClassification.progression, None, self.player))
pump_station_region.locations.append(water_loc)
set_rule(water_loc, lambda state: state.has("Double Jump", self.player)) # the switch is really high up
...
basement_loc = MyGameLocation(self.player, "Flooded House - Basement Chest", None, flooded_house_region)
flooded_house_region.locations += [upstairs_loc, ground_floor_loc, basement_loc]
...
set_rule(basement_loc, lambda state: state.has("Lowered Water Level", self.player))
```
This creates a "Lowered Water Level" event and a regular location whose access rule depends on that
event being reachable. If you made several more locations the same way, this would ensure all of those locations can
only become reachable when the event location is reachable (i.e. when the water level can be lowered), without
copy-pasting the event location's access rule and then repeatedly re-evaluating it. Also, the spoiler log will show
`Water Level Switch: Lowered Water Level` when the player is expected to do this.
To be clear, this example could also be modeled with a second Region (perhaps "Un-Flooded House"). Or you could modify
the game so flipping that switch checks a regular AP location in addition to lowering the water level.
Events are never required, but it may be cleaner to use an event if e.g. flipping that switch affects the logic in
dozens of half-flooded areas that would all otherwise need additional Regions, and you don't want it to be a regular
location. It depends on the game.
## Implementation
### Your World
@@ -488,8 +520,8 @@ In addition, the following methods can be implemented and are called in this ord
If it's hard to separate, this can be done during `generate_early` or `create_items` as well.
* `create_items(self)`
called to place player's items into the MultiWorld's itempool. By the end of this step all regions, locations and
items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions
after this step. Locations cannot be moved to different regions after this step.
items have to be in the MultiWorld's regions and itempool. You cannot add or remove items, locations, or regions after
this step. Locations cannot be moved to different regions after this step. This includes event items and locations.
* `set_rules(self)`
called to set access and item rules on locations and entrances.
* `connect_entrances(self)`
@@ -501,7 +533,7 @@ In addition, the following methods can be implemented and are called in this ord
called to modify item placement before, during, and after the regular fill process; all finishing before
`generate_output`. Any items that need to be placed during `pre_fill` should not exist in the itempool, and if there
are any items that need to be filled this way, but need to be in state while you fill other items, they can be
returned from `get_prefill_items`.
returned from `get_pre_fill_items`.
* `generate_output(self, output_directory: str)`
creates the output files if there is output to be generated. When this is called,
`self.multiworld.get_locations(self.player)` has all locations for the player, with attribute `item` pointing to the

View File

@@ -65,5 +65,5 @@ date, voting members and final result in the commit message.
## Handling of Unmaintained Worlds
As long as worlds are known to work for the most part, they can stay included. Once a world becomes broken it shall be
moved from `worlds/` to `worlds_disabled/`.
As long as worlds are known to work for the most part, they can stay included. Once the world becomes broken, it shall
be deleted.

View File

@@ -86,6 +86,7 @@ Type: dirifempty; Name: "{app}"
[InstallDelete]
Type: files; Name: "{app}\*.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
Type: files; Name: "{app}\data\lua\connector_ff1.lua"
Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
#include "installdelete.iss"
@@ -137,11 +138,6 @@ Root: HKCR; Subkey: "{#MyAppName}kdl3patch"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\DefaultIcon"; ValueData: "{app}\ArchipelagoSNIClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}kdl3patch\shell\open\command"; ValueData: """{app}\ArchipelagoSNIClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apmc"; ValueData: "{#MyAppName}mcdata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata"; ValueData: "Archipelago Minecraft Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata\DefaultIcon"; ValueData: "{app}\ArchipelagoMinecraftClient.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}mcdata\shell\open\command"; ValueData: """{app}\ArchipelagoMinecraftClient.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apz5"; ValueData: "{#MyAppName}n64zpf"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}n64zpf"; ValueData: "Archipelago Ocarina of Time Patch"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}n64zpf\DefaultIcon"; ValueData: "{app}\ArchipelagoOoTClient.exe,0"; ValueType: string; ValueName: "";

222
kvui.py
View File

@@ -6,7 +6,6 @@ import re
import io
import pkgutil
from collections import deque
assert "kivy" not in sys.modules, "kvui should be imported before kivy for frozen compatibility"
if sys.platform == "win32":
@@ -57,10 +56,14 @@ from kivy.animation import Animation
from kivy.uix.popup import Popup
from kivy.uix.image import AsyncImage
from kivymd.app import MDApp
from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogSupportingText, MDDialogButtonContainer
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.navigationbar import MDNavigationBar, MDNavigationItem
from kivymd.uix.screen import MDScreen
from kivymd.uix.screenmanager import MDScreenManager
from kivymd.uix.menu import MDDropdownMenu
from kivymd.uix.menu.menu import MDDropdownTextItem
from kivymd.uix.dropdownitem import MDDropDownItem, MDDropDownItemText
@@ -710,72 +713,94 @@ class CommandPromptTextInput(ResizableTextField):
self.text = self._command_history[self._command_history_index]
class MessageBoxLabel(MDLabel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._label.refresh()
class MessageBox(Popup):
class MessageBoxLabel(MDLabel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._label.refresh()
def __init__(self, title, text, error=False, **kwargs):
label = MessageBox.MessageBoxLabel(text=text)
label = 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=(0.5, None), width=max(100, int(label.width) + 40),
separator_color=separator_color, **kwargs)
self.height += max(0, label.height - 18)
class ClientTabs(MDTabsSecondary):
carousel: MDTabsCarousel
lock_swiping = True
class MDNavigationItemBase(MDNavigationItem):
text = StringProperty(None)
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)
class ButtonsPrompt(MDDialog):
def __init__(self, title: str, text: str, response: typing.Callable[[str], None],
*prompts: str, **kwargs) -> None:
"""
Customizable popup box that lets you create any number of buttons. The text of the pressed button is returned to
the callback.
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)
:param title: The title of the popup.
:param text: The message prompt in the popup.
:param response: A callable that will get called when the user presses a button. The prompt will not close
itself so should be done here if you want to close it when certain buttons are pressed.
:param prompts: Any number of strings to be used for the buttons.
"""
layout = MDBoxLayout(orientation="vertical")
label = MessageBoxLabel(text=text)
layout.add_widget(label)
item_text_object = self._get_tab_item_text_icon_object()
def on_release(button: MDButton, *args) -> None:
response(button.text)
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,
)
buttons = [MDDivider()]
for prompt in prompts:
button = MDButton(
MDButtonText(text=prompt, pos_hint={"center_x": 0.5, "center_y": 0.5}),
on_release=on_release,
style="text",
theme_width="Custom",
size_hint_x=1,
)
button.text = prompt
buttons.extend([button, MDDivider()])
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)
super().__init__(
MDDialogHeadlineText(text=title),
MDDialogSupportingText(text=text),
MDDialogButtonContainer(*buttons, orientation="vertical"),
**kwargs,
)
if not instance:
self.indicator.pos = (x, self.indicator.pos[1])
self.indicator.size = (w, self.indicator_height)
class MDScreenManagerBase(MDScreenManager):
current_tab: MDNavigationItemBase
local_screen_names: list[str]
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.local_screen_names = []
def add_widget(self, widget: Widget, *args, **kwargs) -> None:
super().add_widget(widget, *args, **kwargs)
if "index" in kwargs:
self.local_screen_names.insert(kwargs["index"], widget.name)
else:
Clock.schedule_once(update_indicator)
self.local_screen_names.append(widget.name)
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)
def switch_screens(self, new_tab: MDNavigationItemBase) -> None:
"""
Called whenever the user clicks a tab to switch to a different screen.
:param new_tab: The new screen to switch to's tab.
"""
name = new_tab.text
if self.local_screen_names.index(name) > self.local_screen_names.index(self.current_screen.name):
self.transition.direction = "left"
else:
self.transition.direction = "right"
self.current = name
self.current_tab = new_tab
class CommandButton(MDButton, MDTooltip):
@@ -803,6 +828,9 @@ class GameManager(ThemedApp):
main_area_container: MDGridLayout
""" subclasses can add more columns beside the tabs """
tabs: MDNavigationBar
screens: MDScreenManagerBase
def __init__(self, ctx: context_type):
self.title = self.base_title
self.ctx = ctx
@@ -832,7 +860,7 @@ class GameManager(ThemedApp):
@property
def tab_count(self):
if hasattr(self, "tabs"):
return max(1, len(self.tabs.tab_list))
return max(1, len(self.tabs.children))
return 1
def on_start(self):
@@ -872,30 +900,32 @@ class GameManager(ThemedApp):
self.grid.add_widget(self.progressbar)
# middle part
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))
self.tabs.carousel.add_widget(self.tabs.default_tab_content)
self.screens = MDScreenManagerBase(pos_hint={"center_x": 0.5})
self.tabs = MDNavigationBar(orientation="horizontal", size_hint_y=None, height=dp(40), set_bars_color=True)
# bind the method to the bar for back compatibility
self.tabs.remove_tab = self.remove_client_tab
self.screens.current_tab = self.add_client_tab(
"All" if len(self.logging_pairs) > 1 else "Archipelago",
UILog(*(logging.getLogger(logger_name) for logger_name, name in self.logging_pairs)),
)
self.log_panels["All"] = self.screens.current_tab.content
self.screens.current_tab.active = True
for logger_name, display_name in self.logging_pairs:
bridge_logger = logging.getLogger(logger_name)
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)
self.add_client_tab(display_name, self.log_panels[display_name])
hint_panel = self.add_client_tab("Hints", HintLayout())
self.hint_log = HintLog(self.json_to_kivy_parser)
hint_panel = self.add_client_tab("Hints", HintLayout(self.hint_log))
self.log_panels["Hints"] = hint_panel.content
hint_panel.content.add_widget(self.hint_log)
self.main_area_container = MDGridLayout(size_hint_y=1, rows=1)
self.main_area_container.add_widget(self.tabs)
tab_container = MDGridLayout(size_hint_y=1, cols=1)
tab_container.add_widget(self.tabs)
tab_container.add_widget(self.screens)
self.main_area_container.add_widget(tab_container)
self.grid.add_widget(self.main_area_container)
@@ -932,25 +962,61 @@ class GameManager(ThemedApp):
return self.container
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 = MDTabsItem(MDTabsItemText(text=title))
def add_client_tab(self, title: str, content: Widget, index: int = -1) -> MDNavigationItemBase:
"""
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.
:param title: The title of the tab.
:param content: The Widget to be added as content for this tab's new MDScreen. Will also be added to the
returned tab as tab.content.
:param index: The index to insert the tab at. Defaults to -1, meaning the tab will be appended to the end.
:return: The new tab.
"""
if self.tabs.children:
self.tabs.add_widget(MDDivider(orientation="vertical"))
new_tab = MDNavigationItemBase(text=title)
new_tab.content = content
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)
new_screen = MDScreen(name=title)
new_screen.add_widget(content)
if -1 < index <= len(self.tabs.children):
remapped_index = len(self.tabs.children) - index
self.tabs.add_widget(new_tab, index=remapped_index)
self.screens.add_widget(new_screen, index=index)
else:
self.tabs.add_widget(new_tab)
self.tabs.carousel.add_widget(new_tab.content)
self.screens.add_widget(new_screen)
return new_tab
def remove_client_tab(self, tab: MDNavigationItemBase) -> None:
"""
Called to remove a tab and its screen.
:param tab: The tab to remove.
"""
tab_index = self.tabs.children.index(tab)
# if the tab is currently active we need to swap before removing it
if tab == self.screens.current_tab:
if not tab_index:
# account for the divider
swap_index = tab_index + 2
else:
swap_index = tab_index - 2
self.tabs.children[swap_index].on_release()
# self.screens.switch_screens(self.tabs.children[swap_index])
# get the divider to the left if we can
if not tab_index:
divider_index = tab_index + 1
else:
divider_index = tab_index - 1
self.tabs.remove_widget(self.tabs.children[divider_index])
self.tabs.remove_widget(tab)
self.screens.remove_widget(self.screens.get_screen(tab.text))
def update_texts(self, dt):
for slide in self.tabs.carousel.slides:
if hasattr(slide, "fix_heights"):
slide.fix_heights() # TODO: remove this when Kivy fixes this upstream
if hasattr(self.screens.current_tab.content, "fix_heights"):
getattr(self.screens.current_tab.content, "fix_heights")()
if self.ctx.server:
self.title = self.base_title + " " + Utils.__version__ + \
f" | Connected to: {self.ctx.server_address} " \

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

@@ -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,22 +1,20 @@
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==8.0.0'
@@ -60,13 +58,11 @@ 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",
"Archipelago",
"Clique",
"Final Fantasy",
"Lufia II Ancient Cave",
"Meritous",
"Ocarina of Time",
@@ -147,7 +143,7 @@ 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:
@@ -205,7 +201,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()
@@ -255,7 +251,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
@@ -374,11 +370,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] = []
disabled_worlds_folder = "worlds_disabled"
for entry in os.listdir(disabled_worlds_folder):
if os.path.isdir(os.path.join(disabled_worlds_folder, entry)):
folders_to_remove.append(entry)
folders_to_remove: list[str] = []
generate_yaml_templates(self.buildfolder / "Players" / "Templates", False)
for worldname, worldtype in AutoWorldRegister.world_types.items():
if worldname not in non_apworlds:
@@ -446,12 +438,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
@@ -488,12 +480,12 @@ tmp="${{exe#*/}}"
if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then
exe="{default_exe.parent}/$exe"
fi
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$APPDIR/{default_exe.parent}/lib"
export LD_LIBRARY_PATH="${{LD_LIBRARY_PATH:+$LD_LIBRARY_PATH:}}$APPDIR/{default_exe.parent}/lib"
$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
@@ -556,7 +548,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 []
@@ -564,7 +556,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'):
@@ -589,8 +581,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
@@ -599,7 +591,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)

View File

@@ -159,7 +159,6 @@ class WorldTestBase(unittest.TestCase):
self.multiworld.game[self.player] = self.game
self.multiworld.player_name = {self.player: "Tester"}
self.multiworld.set_seed(seed)
self.multiworld.state = CollectionState(self.multiworld)
random.seed(self.multiworld.seed)
self.multiworld.seed_name = get_seed_name(random) # only called to get same RNG progression as Generate.py
args = Namespace()
@@ -168,6 +167,7 @@ class WorldTestBase(unittest.TestCase):
1: option.from_any(self.options.get(name, option.default))
})
self.multiworld.set_options(args)
self.multiworld.state = CollectionState(self.multiworld)
self.world = self.multiworld.worlds[self.player]
for step in gen_steps:
call_all(self.multiworld, step)

View File

@@ -59,13 +59,13 @@ def run_locations_benchmark():
multiworld.game[1] = game
multiworld.player_name = {1: "Tester"}
multiworld.set_seed(0)
multiworld.state = CollectionState(multiworld)
args = argparse.Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types[game].options_dataclass.type_hints.items():
setattr(args, name, {
1: option.from_any(getattr(option, "default"))
})
multiworld.set_options(args)
multiworld.state = CollectionState(multiworld)
gc.collect()
for step in self.gen_steps:

View File

@@ -49,7 +49,6 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
multiworld.game = {player: world_type.game for player, world_type in enumerate(worlds, 1)}
multiworld.player_name = {player: f"Tester{player}" for player in multiworld.player_ids}
multiworld.set_seed(seed)
multiworld.state = CollectionState(multiworld)
args = Namespace()
for player, world_type in enumerate(worlds, 1):
for key, option in world_type.options_dataclass.type_hints.items():
@@ -57,6 +56,7 @@ def setup_multiworld(worlds: Union[List[Type[World]], Type[World]], steps: Tuple
updated_options[player] = option.from_any(option.default)
setattr(args, key, updated_options)
multiworld.set_options(args)
multiworld.state = CollectionState(multiworld)
for step in steps:
call_all(multiworld, step)
return multiworld

View File

@@ -1,7 +1,7 @@
import unittest
from Fill import distribute_items_restrictive
from NetUtils import encode
from NetUtils import convert_to_base_types
from worlds.AutoWorld import AutoWorldRegister, call_all
from worlds import failed_world_loads
from . import setup_solo_multiworld
@@ -47,12 +47,28 @@ class TestImplemented(unittest.TestCase):
call_all(multiworld, "post_fill")
for key, data in multiworld.worlds[1].fill_slot_data().items():
self.assertIsInstance(key, str, "keys in slot data must be a string")
self.assertIsInstance(encode(data), str, f"object {type(data).__name__} not serializable.")
convert_to_base_types(data) # only put base data types into slot data
def test_no_failed_world_loads(self):
if failed_world_loads:
self.fail(f"The following worlds failed to load: {failed_world_loads}")
def test_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

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

@@ -63,12 +63,12 @@ if __name__ == "__main__":
spacer = '=' * 80
with TemporaryDirectory() as tempdir:
multis = [["Clique"], ["Temp World"], ["Clique", "Temp World"]]
multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]]
p1_games = []
data_paths = []
rooms = []
copy_world("Clique", "Temp World")
copy_world("VVVVVV", "Temp World")
try:
for n, games in enumerate(multis, 1):
print(f"Generating [{n}] {', '.join(games)}")
@@ -101,7 +101,7 @@ if __name__ == "__main__":
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Clique only has 2 Locations
if collected_items < 2: # Don't collect anything on the last iteration
client.collect_any()
# TODO: Ctrl+C test here as well
@@ -125,7 +125,7 @@ if __name__ == "__main__":
with Client(host.address, game, "Player1") as client:
web_data_packages = client.games_packages
web_collected_items = len(client.checked_locations)
if collected_items < 2: # Clique only has 2 Locations
if collected_items < 2: # Don't collect anything on the last iteration
client.collect_any()
if collected_items == 1:
sleep(1) # wait for the server to collect the item

View File

@@ -34,7 +34,7 @@ def _generate_local_inner(games: Iterable[str],
f.write(json.dumps({
"name": f"Player{n}",
"game": game,
game: {"hard_mode": "true"},
game: {},
"description": f"generate_local slot {n} ('Player{n}'): {game}",
}))

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:
@@ -31,7 +30,7 @@ def copy(src: str, dst: str) -> None:
_new_worlds[dst] = str(dst_folder)
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
contents = f.read()
contents = re.sub(r'game\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
contents = re.sub(r'game\s*(:\s*[a-zA-Z\[\]]+)?\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
f.write(contents)

View File

@@ -382,7 +382,7 @@ class World(metaclass=AutoWorldRegister):
def create_items(self) -> None:
"""
Method for creating and submitting items to the itempool. Items and Regions must *not* be created and submitted
to the MultiWorld after this step. If items need to be placed during pre_fill use `get_prefill_items`.
to the MultiWorld after this step. If items need to be placed during pre_fill use `get_pre_fill_items`.
"""
pass
@@ -528,7 +528,7 @@ class World(metaclass=AutoWorldRegister):
"""Called when an item is collected in to state. Useful for things such as progressive items or currency."""
name = self.collect_item(state, item)
if name:
state.prog_items[self.player][name] += 1
state.add_item(name, self.player)
return True
return False
@@ -536,9 +536,7 @@ class World(metaclass=AutoWorldRegister):
"""Called when an item is removed from to state. Useful for things such as progressive items or currency."""
name = self.collect_item(state, item, True)
if name:
state.prog_items[self.player][name] -= 1
if state.prog_items[self.player][name] < 1:
del (state.prog_items[self.player][name])
state.remove_item(name, self.player)
return True
return False

View File

@@ -6,6 +6,7 @@ import zipfile
from enum import IntEnum
import os
import threading
from io import BytesIO
from typing import ClassVar, Dict, List, Literal, Tuple, Any, Optional, Union, BinaryIO, overload, Sequence
@@ -70,6 +71,18 @@ class AutoPatchExtensionRegister(abc.ABCMeta):
container_version: int = 6
def is_ap_player_container(game: str, data: bytes, player: int):
if not zipfile.is_zipfile(BytesIO(data)):
return False
with zipfile.ZipFile(BytesIO(data), mode='r') as zf:
if "archipelago.json" in zf.namelist():
manifest = json.loads(zf.read("archipelago.json"))
if "game" in manifest and "player" in manifest:
if game == manifest["game"] and player == manifest["player"]:
return True
return False
class InvalidDataError(Exception):
"""
Since games can override `read_contents` in APContainer,
@@ -78,24 +91,15 @@ class InvalidDataError(Exception):
class APContainer:
"""A zipfile containing at least archipelago.json"""
version: int = container_version
compression_level: int = 9
compression_method: int = zipfile.ZIP_DEFLATED
game: Optional[str] = None
"""A zipfile containing at least archipelago.json, which contains a manifest json payload."""
version: ClassVar[int] = container_version
compression_level: ClassVar[int] = 9
compression_method: ClassVar[int] = zipfile.ZIP_DEFLATED
# instance attributes:
path: Optional[str]
player: Optional[int]
player_name: str
server: str
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
player_name: str = "", server: str = ""):
def __init__(self, path: Optional[str] = None):
self.path = path
self.player = player
self.player_name = player_name
self.server = server
def write(self, file: Optional[Union[str, BinaryIO]] = None) -> None:
zip_file = file if file else self.path
@@ -135,31 +139,60 @@ class APContainer:
message = f"{arg0} - "
raise InvalidDataError(f"{message}This might be the incorrect world version for this file") from e
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
with opened_zipfile.open("archipelago.json", "r") as f:
manifest = json.load(f)
if manifest["compatible_version"] > self.version:
raise Exception(f"File (version: {manifest['compatible_version']}) too new "
f"for this handler (version: {self.version})")
self.player = manifest["player"]
self.server = manifest["server"]
self.player_name = manifest["player_name"]
return manifest
def get_manifest(self) -> Dict[str, Any]:
return {
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
"player": self.player,
"player_name": self.player_name,
"game": self.game,
# minimum version of patch system expected for patching to be successful
"compatible_version": 5,
"version": container_version,
}
class APPatch(APContainer):
class APPlayerContainer(APContainer):
"""A zipfile containing at least archipelago.json meant for a player"""
game: ClassVar[Optional[str]] = None
patch_file_ending: str = ""
player: Optional[int]
player_name: str
server: str
def __init__(self, path: Optional[str] = None, player: Optional[int] = None,
player_name: str = "", server: str = ""):
super().__init__(path)
self.player = player
self.player_name = player_name
self.server = server
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
manifest = super().read_contents(opened_zipfile)
self.player = manifest["player"]
self.server = manifest["server"]
self.player_name = manifest["player_name"]
return manifest
def get_manifest(self) -> Dict[str, Any]:
manifest = super().get_manifest()
manifest.update({
"server": self.server, # allow immediate connection to server in multiworld. Empty string otherwise
"player": self.player,
"player_name": self.player_name,
"game": self.game,
"patch_file_ending": self.patch_file_ending,
})
return manifest
class APPatch(APPlayerContainer):
"""
An `APContainer` that represents a patch file.
An `APPlayerContainer` that represents a patch file.
It includes the `procedure` key in the manifest to indicate that it is a patch.
Your implementation should inherit from this if your output file
@@ -192,7 +225,6 @@ class APProcedurePatch(APAutoPatchInterface):
"""
hash: Optional[str] # base checksum of source file
source_data: bytes
patch_file_ending: str = ""
files: Dict[str, bytes]
@classmethod
@@ -214,7 +246,6 @@ class APProcedurePatch(APAutoPatchInterface):
manifest = super(APProcedurePatch, self).get_manifest()
manifest["base_checksum"] = self.hash
manifest["result_file_ending"] = self.result_file_ending
manifest["patch_file_ending"] = self.patch_file_ending
manifest["procedure"] = self.procedure
if self.procedure == APDeltaPatch.procedure:
manifest["compatible_version"] = 5

View File

@@ -210,30 +210,27 @@ components: List[Component] = [
Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
# Core
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
Component('Generate', 'Generate', cli=True),
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld")),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient),
file_identifier=SuffixIdentifier('.archipelago', '.zip'),
description="Host a generated multiworld on your computer."),
Component('Generate', 'Generate', cli=True,
description="Generate a multiworld with the YAMLs in the players folder."),
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld"),
description="Install an APWorld to play games not included with Archipelago by default."),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient,
description="Connect to a multiworld using the text client."),
Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')),
Component('LttP Adjuster', 'LttPAdjuster'),
# Minecraft
Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True,
file_identifier=SuffixIdentifier('.apmc')),
# Ocarina of Time
Component('OoT Client', 'OoTClient',
file_identifier=SuffixIdentifier('.apz5')),
Component('OoT Adjuster', 'OoTAdjuster'),
# FF1
Component('FF1 Client', 'FF1Client'),
# TLoZ
Component('Zelda 1 Client', 'Zelda1Client', file_identifier=SuffixIdentifier('.aptloz')),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# Wargroove
Component('Wargroove Client', 'WargrooveClient'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
@@ -246,6 +243,5 @@ components: List[Component] = [
# if registering an icon from within an apworld, the format "ap:module.name/path/to/file.png" can be used
icon_paths = {
'icon': local_path('data', 'icon.png'),
'mcicon': local_path('data', 'mcicon.png'),
'discord': local_path('data', 'discord-mark-blue.png'),
}

View File

@@ -19,7 +19,8 @@ def launch_client(*args) -> None:
component = Component("BizHawk Client", "BizHawkClient", component_type=Type.CLIENT, func=launch_client,
file_identifier=SuffixIdentifier())
file_identifier=SuffixIdentifier(),
description="Open the BizHawk client, to play games using the Bizhawk emulator.")
components.append(component)

View File

@@ -182,10 +182,11 @@ class AdventureDeltaPatch(APPatch, metaclass=AutoPatchRegister):
json.dumps(self.rom_deltas),
compress_type=zipfile.ZIP_LZMA)
def read_contents(self, opened_zipfile: zipfile.ZipFile):
super(AdventureDeltaPatch, self).read_contents(opened_zipfile)
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> dict[str, Any]:
manifest = super(AdventureDeltaPatch, self).read_contents(opened_zipfile)
self.foreign_items = AdventureDeltaPatch.read_foreign_items(opened_zipfile)
self.autocollect_items = AdventureDeltaPatch.read_autocollect_items(opened_zipfile)
return manifest
@classmethod
def get_source_data(cls) -> bytes:

View File

@@ -238,10 +238,10 @@ async def proxy_loop(ctx: AHITContext):
logger.info("Aborting AHIT Proxy Client due to errors")
def launch():
def launch(*launch_args: str):
async def main():
parser = get_base_parser()
args = parser.parse_args()
args = parser.parse_args(launch_args)
ctx = AHITContext(args.connect, args.password)
logger.info("Starting A Hat in Time proxy server")

View File

@@ -477,7 +477,7 @@ act_completions = {
"Act Completion (Rush Hour)": LocData(2000311210, "Rush Hour",
dlc_flags=HatDLC.dlc2,
hookshot=True,
required_hats=[HatType.ICE, HatType.BREWING]),
required_hats=[HatType.ICE, HatType.BREWING, HatType.DWELLER]),
"Act Completion (Time Rift - Rumbi Factory)": LocData(2000312736, "Time Rift - Rumbi Factory",
dlc_flags=HatDLC.dlc2),

View File

@@ -455,7 +455,7 @@ def set_moderate_rules(world: "HatInTimeWorld"):
if "Pink Paw Station Thug" in key and is_location_valid(world, key):
set_rule(world.multiworld.get_location(key, world.player), lambda state: True)
# Moderate: clear Rush Hour without Hookshot
# Moderate: clear Rush Hour without Hookshot or Dweller Mask
set_rule(world.multiworld.get_location("Act Completion (Rush Hour)", world.player),
lambda state: state.has("Metro Ticket - Pink", world.player)
and state.has("Metro Ticket - Yellow", world.player)

View File

@@ -16,9 +16,9 @@ from worlds.LauncherComponents import Component, components, icon_paths, launch
from Utils import local_path
def launch_client():
def launch_client(*args: str):
from .Client import launch
launch_component(launch, name="AHITClient")
launch_component(launch, name="AHITClient", args=args)
components.append(Component("A Hat in Time Client", "AHITClient", func=launch_client,

View File

@@ -54,16 +54,13 @@ def parse_arguments(argv, no_defaults=False):
ret = parser.parse_args(argv)
# cannot be set through CLI currently
ret.plando_items = []
ret.plando_texts = {}
ret.plando_connections = []
if multiargs.multi:
defaults = copy.deepcopy(ret)
for player in range(1, multiargs.multi + 1):
playerargs = parse_arguments(shlex.split(getattr(ret, f"p{player}")), True)
for name in ["plando_items", "plando_texts", "plando_connections", "game", "sprite", "sprite_pool"]:
for name in ["game", "sprite", "sprite_pool"]:
value = getattr(defaults, name) if getattr(playerargs, name) is None else getattr(playerargs, name)
if player == 1:
setattr(ret, name, {1: value})

View File

@@ -548,10 +548,12 @@ def set_up_take_anys(multiworld, world, player):
old_man_take_any.shop = TakeAny(old_man_take_any, 0x0112, 0xE2, True, True, total_shop_slots)
multiworld.shops.append(old_man_take_any.shop)
swords = [item for item in multiworld.itempool if item.player == player and item.type == 'Sword']
if swords:
sword = multiworld.random.choice(swords)
multiworld.itempool.remove(sword)
sword_indices = [
index for index, item in enumerate(multiworld.itempool) if item.player == player and item.type == 'Sword'
]
if sword_indices:
sword_index = multiworld.random.choice(sword_indices)
sword = multiworld.itempool.pop(sword_index)
multiworld.itempool.append(item_factory('Rupees (20)', world))
old_man_take_any.shop.add_inventory(0, sword.name, 0, 0)
loc_name = "Old Man Sword Cave"

View File

@@ -505,20 +505,20 @@ class ALTTPWorld(World):
def pre_fill(self):
from Fill import fill_restrictive, FillError
attempts = 5
world = self.multiworld
player = self.player
all_state = world.get_all_state(use_cache=True)
all_state = self.multiworld.get_all_state(use_cache=False)
crystals = [self.create_item(name) for name in ['Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1', 'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5', 'Crystal 6']]
crystal_locations = [world.get_location('Turtle Rock - Prize', player),
world.get_location('Eastern Palace - Prize', player),
world.get_location('Desert Palace - Prize', player),
world.get_location('Tower of Hera - Prize', player),
world.get_location('Palace of Darkness - Prize', player),
world.get_location('Thieves\' Town - Prize', player),
world.get_location('Skull Woods - Prize', player),
world.get_location('Swamp Palace - Prize', player),
world.get_location('Ice Palace - Prize', player),
world.get_location('Misery Mire - Prize', player)]
for crystal in crystals:
all_state.remove(crystal)
crystal_locations = [self.get_location('Turtle Rock - Prize'),
self.get_location('Eastern Palace - Prize'),
self.get_location('Desert Palace - Prize'),
self.get_location('Tower of Hera - Prize'),
self.get_location('Palace of Darkness - Prize'),
self.get_location('Thieves\' Town - Prize'),
self.get_location('Skull Woods - Prize'),
self.get_location('Swamp Palace - Prize'),
self.get_location('Ice Palace - Prize'),
self.get_location('Misery Mire - Prize')]
placed_prizes = {loc.item.name for loc in crystal_locations if loc.item}
unplaced_prizes = [crystal for crystal in crystals if crystal.name not in placed_prizes]
empty_crystal_locations = [loc for loc in crystal_locations if not loc.item]
@@ -526,8 +526,8 @@ class ALTTPWorld(World):
try:
prizepool = unplaced_prizes.copy()
prize_locs = empty_crystal_locations.copy()
world.random.shuffle(prize_locs)
fill_restrictive(world, all_state, prize_locs, prizepool, True, lock=True,
self.multiworld.random.shuffle(prize_locs)
fill_restrictive(self.multiworld, all_state, prize_locs, prizepool, True, lock=True,
name="LttP Dungeon Prizes")
except FillError as e:
lttp_logger.exception("Failed to place dungeon prizes (%s). Will retry %s more times", e,
@@ -541,7 +541,7 @@ class ALTTPWorld(World):
if self.options.mode == 'standard' and self.options.small_key_shuffle \
and self.options.small_key_shuffle != small_key_shuffle.option_universal and \
self.options.small_key_shuffle != small_key_shuffle.option_own_dungeons:
world.local_early_items[player]["Small Key (Hyrule Castle)"] = 1
self.multiworld.local_early_items[self.player]["Small Key (Hyrule Castle)"] = 1
@classmethod
def stage_pre_fill(cls, world):
@@ -811,12 +811,15 @@ class ALTTPWorld(World):
return GetBeemizerItem(self.multiworld, self.player, item)
def get_pre_fill_items(self):
res = []
res = [self.create_item(name) for name in ('Red Pendant', 'Blue Pendant', 'Green Pendant', 'Crystal 1',
'Crystal 2', 'Crystal 3', 'Crystal 4', 'Crystal 7', 'Crystal 5',
'Crystal 6')]
if self.dungeon_local_item_names:
for dungeon in self.dungeons.values():
for item in dungeon.all_items:
if item.name in self.dungeon_local_item_names:
res.append(item)
return res
def fill_slot_data(self):

View File

@@ -10,12 +10,12 @@ class LTTPTestBase(unittest.TestCase):
from worlds.alttp.Options import Medallion
self.multiworld = MultiWorld(1)
self.multiworld.game[1] = "A Link to the Past"
self.multiworld.state = CollectionState(self.multiworld)
self.multiworld.set_seed(None)
args = Namespace()
for name, option in AutoWorldRegister.world_types["A Link to the Past"].options_dataclass.type_hints.items():
setattr(args, name, {1: option.from_any(getattr(option, "default"))})
self.multiworld.set_options(args)
self.multiworld.state = CollectionState(self.multiworld)
self.world = self.multiworld.worlds[1]
# by default medallion access is randomized, for unittests we set it to vanilla
self.world.options.misery_mire_medallion.value = Medallion.option_ether

View File

@@ -38,7 +38,7 @@ class DungeonFillTestBase(TestCase):
def test_original_dungeons(self):
self.generate_with_options(DungeonItem.option_original_dungeon)
for location in self.multiworld.get_filled_locations():
with (self.subTest(location=location)):
with (self.subTest(location_name=location.name)):
if location.parent_region.dungeon is None:
self.assertIs(location.item.dungeon, None)
else:
@@ -52,7 +52,7 @@ class DungeonFillTestBase(TestCase):
def test_own_dungeons(self):
self.generate_with_options(DungeonItem.option_own_dungeons)
for location in self.multiworld.get_filled_locations():
with self.subTest(location=location):
with self.subTest(location_name=location.name):
if location.parent_region.dungeon is None:
self.assertIs(location.item.dungeon, None)
else:

View File

@@ -4,7 +4,7 @@ Date: Fri, 15 Mar 2024 18:41:40 +0000
Description: Used to manage Regions in the Aquaria game multiworld randomizer
"""
from typing import Dict, Optional
from typing import Dict, Optional, Iterable
from BaseClasses import MultiWorld, Region, Entrance, Item, ItemClassification, CollectionState
from .Items import AquariaItem, ItemNames
from .Locations import AquariaLocations, AquariaLocation, AquariaLocationNames
@@ -34,10 +34,15 @@ def _has_li(state: CollectionState, player: int) -> bool:
return state.has(ItemNames.LI_AND_LI_SONG, player)
def _has_damaging_item(state: CollectionState, player: int) -> bool:
"""`player` in `state` has the shield song item"""
return state.has_any({ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM, ItemNames.LI_AND_LI_SONG,
ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA, ItemNames.BABY_BLASTER}, player)
DAMAGING_ITEMS:Iterable[str] = [
ItemNames.ENERGY_FORM, ItemNames.NATURE_FORM, ItemNames.BEAST_FORM,
ItemNames.LI_AND_LI_SONG, ItemNames.BABY_NAUTILUS, ItemNames.BABY_PIRANHA,
ItemNames.BABY_BLASTER
]
def _has_damaging_item(state: CollectionState, player: int, damaging_items:Iterable[str] = DAMAGING_ITEMS) -> bool:
"""`player` in `state` has the an item that do damage other than the ones in `to_remove`"""
return state.has_any(damaging_items, player)
def _has_energy_attack_item(state: CollectionState, player: int) -> bool:
@@ -566,9 +571,11 @@ class AquariaRegions:
self.__connect_one_way_regions(self.openwater_tr, self.openwater_tr_turtle,
lambda state: _has_beast_form_or_arnassi_armor(state, self.player))
self.__connect_one_way_regions(self.openwater_tr_turtle, self.openwater_tr)
damaging_items_minus_nature_form = [item for item in DAMAGING_ITEMS if item != ItemNames.NATURE_FORM]
self.__connect_one_way_regions(self.openwater_tr, self.openwater_tr_urns,
lambda state: _has_bind_song(state, self.player) or
_has_damaging_item(state, self.player))
_has_damaging_item(state, self.player,
damaging_items_minus_nature_form))
self.__connect_regions(self.openwater_tr, self.openwater_br)
self.__connect_regions(self.openwater_tr, self.mithalas_city)
self.__connect_regions(self.openwater_tr, self.veil_b)

View File

@@ -207,7 +207,6 @@ class BlasphemousWorld(World):
if not self.options.skill_randomizer:
self.place_items_from_dict(skill_dict)
def place_items_from_set(self, location_set: Set[str], name: str):
for loc in location_set:
self.get_location(loc).place_locked_item(self.create_item(name))

View File

@@ -3,7 +3,8 @@
## Required Software
- ChecksFinder from
the [Github releases Page for the game](https://github.com/jonloveslegos/ChecksFinder/releases) (latest version)
the [Github releases Page for the game](https://github.com/jonloveslegos/ChecksFinder/releases) (latest version), or
from the [itch.io Page for the game](https://suncat0.itch.io/checksfinder) (including web version)
## Configuring your YAML file
@@ -18,13 +19,13 @@ You can customize your options by visiting the [ChecksFinder Player Options Page
## Joining a MultiWorld Game
1. Start ChecksFinder
2. Enter the following information:
- Enter the server url (starting from `wss://` for https connection like archipelago.gg, and starting from `ws://` for http connection and local multiserver)
- Enter server port
- Enter the name of the slot you wish to connect to
- Enter the room password (optional)
- Press `Play Online` to connect
3. Start playing!
Game options and controls are described in the readme on the github repository for the game
1. Start ChecksFinder and press `Play Online`
2. Switch to the console window/tab
3. Enter the following information:
- Server url
- Server port
- The name of the slot you wish to connect to
- The room password (optional)
4. Press `Connect` to connect
5. Switch to the game window/tab
6. Start playing!

View File

@@ -1,10 +1,9 @@
from dataclasses import dataclass
import os
import io
from typing import TYPE_CHECKING, Dict, List, Optional, cast
import zipfile
from BaseClasses import Location
from worlds.Files import APContainer, AutoPatchRegister
from worlds.Files import APPlayerContainer
from .Enum import CivVICheckType
from .Locations import CivVILocation, CivVILocationData
@@ -26,22 +25,19 @@ class CivTreeItem:
ui_tree_row: int
class CivVIContainer(APContainer, metaclass=AutoPatchRegister):
class CivVIContainer(APPlayerContainer):
"""
Responsible for generating the dynamic mod files for the Civ VI multiworld
"""
game: Optional[str] = "Civilization VI"
patch_file_ending = ".apcivvi"
def __init__(self, patch_data: Dict[str, str] | io.BytesIO, base_path: str = "", output_directory: str = "",
def __init__(self, patch_data: Dict[str, str], base_path: str = "", output_directory: str = "",
player: Optional[int] = None, player_name: str = "", server: str = ""):
if isinstance(patch_data, io.BytesIO):
super().__init__(patch_data, player, player_name, server)
else:
self.patch_data = patch_data
self.file_path = base_path
container_path = os.path.join(output_directory, base_path + ".apcivvi")
super().__init__(container_path, player, player_name, server)
self.patch_data = patch_data
self.file_path = base_path
container_path = os.path.join(output_directory, base_path + ".apcivvi")
super().__init__(container_path, player, player_name, server)
def write_contents(self, opened_zipfile: zipfile.ZipFile) -> None:
for filename, yml in self.patch_data.items():

View File

@@ -78,8 +78,8 @@ boosts: List[CivVIBoostData] = [
CivVIBoostData(
"BOOST_TECH_IRON_WORKING",
"ERA_CLASSICAL",
["TECH_MINING"],
1,
["TECH_MINING", "TECH_BRONZE_WORKING"],
2,
"DEFAULT",
),
CivVIBoostData(
@@ -165,15 +165,9 @@ boosts: List[CivVIBoostData] = [
"BOOST_TECH_CASTLES",
"ERA_MEDIEVAL",
[
"CIVIC_DIVINE_RIGHT",
"CIVIC_EXPLORATION",
"CIVIC_REFORMED_CHURCH",
"CIVIC_SUFFRAGE",
"CIVIC_TOTALITARIANISM",
"CIVIC_CLASS_STRUGGLE",
"CIVIC_DIGITAL_DEMOCRACY",
"CIVIC_CORPORATE_LIBERTARIANISM",
"CIVIC_SYNTHETIC_TECHNOCRACY",
],
1,
"DEFAULT",
@@ -393,9 +387,6 @@ boosts: List[CivVIBoostData] = [
"CIVIC_SUFFRAGE",
"CIVIC_TOTALITARIANISM",
"CIVIC_CLASS_STRUGGLE",
"CIVIC_DIGITAL_DEMOCRACY",
"CIVIC_CORPORATE_LIBERTARIANISM",
"CIVIC_SYNTHETIC_TECHNOCRACY",
],
1,
"DEFAULT",

View File

@@ -20,16 +20,17 @@ A short period after receiving an item, you will get a notification indicating y
## FAQs
- Do I need the DLC to play this?
- Yes, you need both Rise & Fall and Gathering Storm.
- You need both expansions, Rise & Fall and Gathering Storm. You do not need the other DLCs but they fully work with this.
- Does this work with Multiplayer?
- It does not and, despite my best efforts, probably won't until there's a new way for external programs to be able to interact with the game.
- Does my mod that reskins Barbarians as various Pro Wrestlers work with this?
- Only one way to find out! Any mods that modify techs/civics will most likely cause issues, though.
- Does this work with other mods?
- A lot of mods seem to work without issues combined with this, but you should avoid any mods that change things in the tech or civic tree, as even if they would work it could cause issues with the logic.
- "Help! I can't see any of the items that have been sent to me!"
- Both trees by default will show you the researchable Archipelago locations. To view the normal tree, you can click "Toggle Archipelago Tree" in the top-left corner of the tree view.
- "Oh no! I received the Machinery tech and now instead of getting an Archer next turn, I have to wait an additional 10 turns to get a Crossbowman!"
- Vanilla prevents you from building units of the same class from an earlier tech level after you have researched a later variant. For example, this could be problematic if someone unlocks Crossbowmen for you right out the gate since you won't be able to make Archers (which have a much lower production cost).
Solution: You can now go in to the tech tree, click "Toggle Archipelago Tree" to view your unlocked techs, and then can click any tech you have unlocked to toggle whether it is currently active or not.
- Solution: You can now go in to the tech tree, click "Toggle Archipelago Tree" to view your unlocked techs, and then can click any tech you have unlocked to toggle whether it is currently active or not.
- If you think you should be able to make Field Cannons but seemingly can't try disabling `Telecommunications`
- "How does DeathLink work? Am I going to have to start a new game every time one of my friends dies?"
- Heavens no, my fellow Archipelago appreciator. When configuring your Archipelago options for Civilization on the options page, there are several choices available for you to fine tune the way you'd like to be punished for the follies of your friends. These include: Having a random unit destroyed, losing a percentage of gold or faith, or even losing a point on your era score. If you can't make up your mind, you can elect to have any of them be selected every time a death link is sent your way.
In the event you lose one of your units in combat (this means captured units don't count), then you will send a death link event to the rest of your friends.
@@ -39,7 +40,8 @@ Solution: You can now go in to the tech tree, click "Toggle Archipelago Tree" to
1. `TECH_WRITING`
2. `TECH_EDUCATION`
3. `TECH_CHEMISTRY`
- If you want to see the details around each item, you can review [this file](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/civ_6/data/progressive_districts.json).
- An important thing to note is that the seaport is part of progressive industrial zones, due to electricity having both an industrial zone building and the seaport.
- If you want to see the details around each item, you can review [this file](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/civ_6/data/progressive_districts.py).
## Boostsanity
Boostsanity takes all of the Eureka & Inspiration events and makes them location checks. This feature is the one to change up the way Civilization is played in an AP multiworld/randomizer. What normally are mundane tasks that are passively collected now become a novel and interesting bucket list that you need to pay attention to in order to unlock items for yourself and others!
@@ -56,4 +58,3 @@ Boosts have logic associated with them in order to verify you can always reach t
- The unpredictable timing of boosts and unlocking them can occasionally lead to scenarios where you'll have to first encounter a locked era defeat and then load a previous save. To help reduce the frequency of this, local `PROGRESSIVE_ERA` items will never be located at a boost check.
- There's too many boosts, how will I know which one's I should focus on?!
- In order to give a little more focus to all the boosts rather than just arbitrarily picking them at random, items in both of the vanilla trees will now have an advisor icon on them if its associated boost contains a progression item.

View File

@@ -6,12 +6,14 @@ This guide is meant to help you get up and running with Civilization VI in Archi
The following are required in order to play Civ VI in Archipelago:
- Windows OS (Firaxis does not support the necessary tooling for Mac, or Linux)
- Windows OS (Firaxis does not support the necessary tooling for Mac, or Linux).
- Installed [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) v0.4.5 or higher.
- Installed [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases).
- The latest version of the [Civ VI AP Mod](https://github.com/hesto2/civilization_archipelago_mod/releases/latest).
- A copy of the game `Civilization VI` including the two expansions `Rise & Fall` and `Gathering Storm` (both the Steam and Epic version should work).
## Enabling the tuner
In the main menu, navigate to the "Game Options" page. On the "Game" menu, make sure that "Tuner (disables achievements)" is enabled.
@@ -20,27 +22,32 @@ In the main menu, navigate to the "Game Options" page. On the "Game" menu, make
1. Download and unzip the latest release of the mod from [GitHub](https://github.com/hesto2/civilization_archipelago_mod/releases/latest).
2. Copy the folder containing the mod files to your Civ VI mods folder. On Windows, this is usually located at `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods`. If you use OneDrive, check if the folder is instead located in your OneDrive file structure.
2. Copy the folder containing the mod files to your Civ VI mods folder. On Windows, this is usually located at `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods`. If you use OneDrive, check if the folder is instead located in your OneDrive file structure, and use that path when relevant in future steps.
3. After the Archipelago host generates a game, you should be given a `.apcivvi` file. Associate the file with the Archipelago Launcher and double click it.
4. Copy the contents of the new folder it generates (it will have the same name as the `.apcivvi` file) into your Civilization VI Archipelago Mod folder. If double clicking the `.apcivvi` file doesn't generate a folder, you can just rename it to a file ending with `.zip` and extract its contents to a new folder. To do this, right click the `.apcivvi` file and click "Rename", make sure it ends in `.zip`, then right click it again and select "Extract All".
4. Copy the contents of the new folder it generates (it will have the same name as the `.apcivvi` file) into your Civilization VI Archipelago Mod folder. If double clicking the `.apcivvi` file doesn't generate a folder, you can instead open it as a zip file. You can do this by either right clicking it and opening it with a program that handles zip files, or by right clicking and renaming the file extension from `apcivvi` to `zip`.
5. Your finished mod folder should look something like this:
- Civ VI Mods Directory
- civilization_archipelago_mod
- NewItems.xml
- InitOptions.lua
- Archipelago.modinfo
- All the other mod files, etc.
5. Place the files generated from the `.apcivvi` in your archipelago mod folder (there should be five files placed there from the apcivvi file, overwrite if asked). Your mod path should look something like `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods\civilization_archipelago_mod`.
## Configuring your game
When configuring your game, make sure to start the game in the Ancient Era and leave all settings related to starting technologies and civics as the defaults. Other than that, configure difficulty, AI, etc. as you normally would.
Make sure you enable the mod in the main title under Additional Content > Mods. When configuring your game, make sure to start the game in the Ancient Era and leave all settings related to starting technologies and civics as the defaults. Other than that, configure difficulty, AI, etc. as you normally would.
## Troubleshooting
- If you have troubles with file extension related stuff, make sure Windows shows file extensions as they are turned off by default. If you don't know how to turn them on it is just a quick google search away.
- If you are getting an error: "The remote computer refused the network connection", or something else related to the client (or tuner) not being able to connect, it likely indicates the tuner is not actually enabled. One simple way to verify that it is enabled is, after completing the setup steps, go to Main Menu &rarr; Options &rarr; Look for an option named "Tuner" and verify it is set to "Enabled"
- If your game gets in a state where someone has sent you items or you have sent locations but these are not correctly sent to the multiworld, you can run `/resync` from the Civ 6 client. This may take up to a minute depending on how many items there are.
- If your game gets in a state where someone has sent you items or you have sent locations but these are not correctly sent to the multiworld, you can run `/resync` from the Civ 6 client. This may take up to a minute depending on how many items there are. This can resend certain items to you, like one time bonuses.
- If the archipelago mod does not appear in the mod selector in the game, make sure the mod is correctly placed as a folder in the `Sid Meier's Civilization VI\Mods` folder, there should not be any loose files in there only folders. As in the path should look something like `C:\Users\YOUR_USER\Documents\My Games\Sid Meier's Civilization VI\Mods\civilization_archipelago_mod`.
- If it still does not appear make sure you have the right folder, one way to verify you are in the right place is to find the general folder area where your Civ VI save files are located.
- If you get an error when trying to start a game saying `Error - One or more Mods failed to load content`, make sure the files from the `.apcivvi` are placed into the `civilization_archipelago_mod` as loose files and not as a folder.
- If you still have any errors make sure the two expansions Rise & Fall and Gathering Storm are active in the mod selector (all the official DLC works without issues but Rise & Fall and Gathering Storm are required for the mod).
- If boostsanity is enabled and those items are not being sent out but regular techs are, make sure you placed the files from your new room in the mod folder.

View File

@@ -1,38 +0,0 @@
from typing import Callable, Dict, NamedTuple, Optional, TYPE_CHECKING
from BaseClasses import Item, ItemClassification
if TYPE_CHECKING:
from . import CliqueWorld
class CliqueItem(Item):
game = "Clique"
class CliqueItemData(NamedTuple):
code: Optional[int] = None
type: ItemClassification = ItemClassification.filler
can_create: Callable[["CliqueWorld"], bool] = lambda world: True
item_data_table: Dict[str, CliqueItemData] = {
"Feeling of Satisfaction": CliqueItemData(
code=69696969,
type=ItemClassification.progression,
),
"Button Activation": CliqueItemData(
code=69696968,
type=ItemClassification.progression,
can_create=lambda world: world.options.hard_mode,
),
"A Cool Filler Item (No Satisfaction Guaranteed)": CliqueItemData(
code=69696967,
can_create=lambda world: False # Only created from `get_filler_item_name`.
),
"The Urge to Push": CliqueItemData(
type=ItemClassification.progression,
),
}
item_table = {name: data.code for name, data in item_data_table.items() if data.code is not None}

View File

@@ -1,37 +0,0 @@
from typing import Callable, Dict, NamedTuple, Optional, TYPE_CHECKING
from BaseClasses import Location
if TYPE_CHECKING:
from . import CliqueWorld
class CliqueLocation(Location):
game = "Clique"
class CliqueLocationData(NamedTuple):
region: str
address: Optional[int] = None
can_create: Callable[["CliqueWorld"], bool] = lambda world: True
locked_item: Optional[str] = None
location_data_table: Dict[str, CliqueLocationData] = {
"The Big Red Button": CliqueLocationData(
region="The Button Realm",
address=69696969,
),
"The Item on the Desk": CliqueLocationData(
region="The Button Realm",
address=69696968,
can_create=lambda world: world.options.hard_mode,
),
"In the Player's Mind": CliqueLocationData(
region="The Button Realm",
locked_item="The Urge to Push",
),
}
location_table = {name: data.address for name, data in location_data_table.items() if data.address is not None}
locked_locations = {name: data for name, data in location_data_table.items() if data.locked_item}

View File

@@ -1,34 +0,0 @@
from dataclasses import dataclass
from Options import Choice, Toggle, PerGameCommonOptions, StartInventoryPool
class HardMode(Toggle):
"""Only for the most masochistically inclined... Requires button activation!"""
display_name = "Hard Mode"
class ButtonColor(Choice):
"""Customize your button! Now available in 12 unique colors."""
display_name = "Button Color"
option_red = 0
option_orange = 1
option_yellow = 2
option_green = 3
option_cyan = 4
option_blue = 5
option_magenta = 6
option_purple = 7
option_pink = 8
option_brown = 9
option_white = 10
option_black = 11
@dataclass
class CliqueOptions(PerGameCommonOptions):
color: ButtonColor
hard_mode: HardMode
start_inventory_from_pool: StartInventoryPool
# DeathLink is always on. Always.
# death_link: DeathLink

View File

@@ -1,11 +0,0 @@
from typing import Dict, List, NamedTuple
class CliqueRegionData(NamedTuple):
connecting_regions: List[str] = []
region_data_table: Dict[str, CliqueRegionData] = {
"Menu": CliqueRegionData(["The Button Realm"]),
"The Button Realm": CliqueRegionData(),
}

View File

@@ -1,13 +0,0 @@
from typing import Callable, TYPE_CHECKING
from BaseClasses import CollectionState
if TYPE_CHECKING:
from . import CliqueWorld
def get_button_rule(world: "CliqueWorld") -> Callable[[CollectionState], bool]:
if world.options.hard_mode:
return lambda state: state.has("Button Activation", world.player)
return lambda state: True

View File

@@ -1,102 +0,0 @@
from typing import List, Dict, Any
from BaseClasses import Region, Tutorial
from worlds.AutoWorld import WebWorld, World
from .Items import CliqueItem, item_data_table, item_table
from .Locations import CliqueLocation, location_data_table, location_table, locked_locations
from .Options import CliqueOptions
from .Regions import region_data_table
from .Rules import get_button_rule
class CliqueWebWorld(WebWorld):
theme = "partyTime"
setup_en = Tutorial(
tutorial_name="Start Guide",
description="A guide to playing Clique.",
language="English",
file_name="guide_en.md",
link="guide/en",
authors=["Phar"]
)
setup_de = Tutorial(
tutorial_name="Anleitung zum Anfangen",
description="Eine Anleitung um Clique zu spielen.",
language="Deutsch",
file_name="guide_de.md",
link="guide/de",
authors=["Held_der_Zeit"]
)
tutorials = [setup_en, setup_de]
game_info_languages = ["en", "de"]
class CliqueWorld(World):
"""The greatest game of all time."""
game = "Clique"
web = CliqueWebWorld()
options: CliqueOptions
options_dataclass = CliqueOptions
location_name_to_id = location_table
item_name_to_id = item_table
def create_item(self, name: str) -> CliqueItem:
return CliqueItem(name, item_data_table[name].type, item_data_table[name].code, self.player)
def create_items(self) -> None:
item_pool: List[CliqueItem] = []
for name, item in item_data_table.items():
if item.code and item.can_create(self):
item_pool.append(self.create_item(name))
self.multiworld.itempool += item_pool
def create_regions(self) -> None:
# Create regions.
for region_name in region_data_table.keys():
region = Region(region_name, self.player, self.multiworld)
self.multiworld.regions.append(region)
# Create locations.
for region_name, region_data in region_data_table.items():
region = self.get_region(region_name)
region.add_locations({
location_name: location_data.address for location_name, location_data in location_data_table.items()
if location_data.region == region_name and location_data.can_create(self)
}, CliqueLocation)
region.add_exits(region_data_table[region_name].connecting_regions)
# Place locked locations.
for location_name, location_data in locked_locations.items():
# Ignore locations we never created.
if not location_data.can_create(self):
continue
locked_item = self.create_item(location_data_table[location_name].locked_item)
self.get_location(location_name).place_locked_item(locked_item)
# Set priority location for the Big Red Button!
self.options.priority_locations.value.add("The Big Red Button")
def get_filler_item_name(self) -> str:
return "A Cool Filler Item (No Satisfaction Guaranteed)"
def set_rules(self) -> None:
button_rule = get_button_rule(self)
self.get_location("The Big Red Button").access_rule = button_rule
self.get_location("In the Player's Mind").access_rule = button_rule
# Do not allow button activations on buttons.
self.get_location("The Big Red Button").item_rule = lambda item: item.name != "Button Activation"
# Completion condition.
self.multiworld.completion_condition[self.player] = lambda state: state.has("The Urge to Push", self.player)
def fill_slot_data(self) -> Dict[str, Any]:
return {
"color": self.options.color.current_key
}

View File

@@ -1,18 +0,0 @@
# Clique
## Was ist das für ein Spiel?
~~Clique ist ein psychologisches Überlebens-Horror Spiel, in dem der Spieler der Versuchung wiederstehen muss große~~
~~(rote) Knöpfe zu drücken.~~
Clique ist ein scherzhaftes Spiel, welches für Archipelago im März 2023 entwickelt wurde, um zu zeigen, wie einfach
es sein kann eine Welt für Archipelago zu entwicklen. Das Ziel des Spiels ist es den großen (standardmäßig) roten
Knopf zu drücken. Wenn ein Spieler auf dem `hard_mode` (schwieriger Modus) spielt, muss dieser warten bis jemand
anderes in der Multiworld den Knopf aktiviert, damit er gedrückt werden kann.
Clique kann auf den meisten modernen, HTML5-fähigen Browsern gespielt werden.
## Wo ist die Seite für die Einstellungen?
Die [Seite für die Spielereinstellungen dieses Spiels](../player-options) enthält alle Optionen die man benötigt um
eine YAML-Datei zu konfigurieren und zu exportieren.

View File

@@ -1,16 +0,0 @@
# Clique
## What is this game?
~~Clique is a psychological survival horror game where a player must survive the temptation to press red buttons.~~
Clique is a joke game developed for Archipelago in March 2023 to showcase how easy it can be to develop a world for
Archipelago. The objective of the game is to press the big red button. If a player is playing on `hard_mode`, they must
wait for someone else in the multiworld to "activate" their button before they can press it.
Clique can be played on most modern HTML5-capable browsers.
## Where is the options page?
The [player options page for this game](../player-options) contains all the options you need to configure
and export a config file.

View File

@@ -1,25 +0,0 @@
# Clique Anleitung
Nachdem dein Seed generiert wurde, gehe auf die Website von [Clique dem Spiel](http://clique.pharware.com/) und gib
Server-Daten, deinen Slot-Namen und ein Passwort (falls vorhanden) ein. Klicke dann auf "Connect" (Verbinden).
Wenn du auf "Einfach" spielst, kannst du unbedenklich den Knopf drücken und deine "Befriedigung" erhalten.
Wenn du auf "Schwer" spielst, ist es sehr wahrscheinlich, dass du warten musst bevor du dein Ziel erreichen kannst.
Glücklicherweise läuft Click auf den meißten großen Browsern, die HTML5 unterstützen. Das heißt du kannst Clique auf
deinem Handy starten und produktiv sein während du wartest!
Falls du einige Ideen brauchst was du tun kannst, während du wartest bis der Knopf aktiviert wurde, versuche
(mindestens) eins der Folgenden:
- Dein Zimmer aufräumen.
- Die Wäsche machen.
- Etwas Essen von einem X-Belieben Fast Food Restaruant holen.
- Das tägliche Wordle machen.
- ~~Deine Seele an **Phar** verkaufen.~~
- Deine Hausaufgaben erledigen.
- Deine Post abholen.
~~Solltest du auf irgendwelche Probleme in diesem Spiel stoßen, solltest du keinesfalls nicht **thephar** auf~~
~~Discord kontaktieren. *zwinker* *zwinker*~~

View File

@@ -1,22 +0,0 @@
# Clique Start Guide
After rolling your seed, go to the [Clique Game](http://clique.pharware.com/) site and enter the server details, your
slot name, and a room password if one is required. Then click "Connect".
If you're playing on "easy mode", just click the button and receive "Satisfaction".
If you're playing on "hard mode", you may need to wait for activation before you can complete your objective. Luckily,
Clique runs in most major browsers that support HTML5, so you can load Clique on your phone and be productive while
you wait!
If you need some ideas for what to do while waiting for button activation, give the following a try:
- Clean your room.
- Wash the dishes.
- Get some food from a non-descript fast food restaurant.
- Do the daily Wordle.
- ~~Sell your soul to Phar.~~
- Do your school work.
~~If you run into any issues with this game, definitely do not contact **thephar** on discord. *wink* *wink*~~

View File

@@ -2893,3 +2893,18 @@ dog_bite_ice_trap_fix = [
0x25291CB8, # ADDIU T1, T1, 0x1CB8
0x01200008 # JR T1
]
shimmy_speed_modifier = [
# Increases the player's speed while shimmying as long as they are not holding down Z. If they are holding Z, it
# will be the normal speed, allowing it to still be used to set up any tricks that might require the normal speed
# (like Left Tower Skip).
0x3C088038, # LUI T0, 0x8038
0x91087D7E, # LBU T0, 0x7D7E (T0)
0x31090020, # ANDI T1, T0, 0x0020
0x3C0A800A, # LUI T2, 0x800A
0x240B005A, # ADDIU T3, R0, 0x005A
0x55200001, # BNEZL T1, [forward 0x01]
0x240B0032, # ADDIU T3, R0, 0x0032
0xA14B3641, # SB T3, 0x3641 (T2)
0x0800B7C3 # J 0x8002DF0C
]

View File

@@ -424,6 +424,7 @@ class PantherDash(Choice):
class IncreaseShimmySpeed(Toggle):
"""
Increases the speed at which characters shimmy left and right while hanging on ledges.
Hold Z to use the regular speed in case it's needed to do something.
"""
display_name = "Increase Shimmy Speed"

View File

@@ -607,9 +607,10 @@ class CV64PatchExtensions(APPatchExtension):
rom_data.write_int32(0xAA530, 0x080FF880) # J 0x803FE200
rom_data.write_int32s(0xBFE200, patches.coffin_cutscene_skipper)
# Increase shimmy speed
# Shimmy speed increase hack
if options["increase_shimmy_speed"]:
rom_data.write_byte(0xA4241, 0x5A)
rom_data.write_int32(0x97EB4, 0x803FE9F0)
rom_data.write_int32s(0xBFE9F0, patches.shimmy_speed_modifier)
# Disable landing fall damage
if options["fall_guard"]:

View File

@@ -211,7 +211,8 @@ class CVCotMWorld(World):
"ignore_cleansing": self.options.ignore_cleansing.value,
"skip_tutorials": self.options.skip_tutorials.value,
"required_last_keys": self.required_last_keys,
"completion_goal": self.options.completion_goal.value}
"completion_goal": self.options.completion_goal.value,
"nerf_roc_wing": self.options.nerf_roc_wing.value}
def get_filler_item_name(self) -> str:
return self.random.choice(FILLER_ITEM_NAMES)

View File

@@ -48,11 +48,17 @@ class OtherGameAppearancesInfo(TypedDict):
other_game_item_appearances: Dict[str, Dict[str, OtherGameAppearancesInfo]] = {
# NOTE: Symphony of the Night is currently an unsupported world not in main.
# NOTE: Symphony of the Night and Harmony of Dissonance are custom worlds that are not core verified.
"Symphony of the Night": {"Life Vessel": {"type": 0xE4,
"appearance": 0x01},
"Heart Vessel": {"type": 0xE4,
"appearance": 0x00}},
"Castlevania - Harmony of Dissonance": {"Life Max Up": {"type": 0xE4,
"appearance": 0x01},
"Heart Max Up": {"type": 0xE4,
"appearance": 0x00}},
"Timespinner": {"Max HP": {"type": 0xE4,
"appearance": 0x01},
"Max Aura": {"type": 0xE4,
@@ -728,8 +734,8 @@ def get_start_inventory_data(world: "CVCotMWorld") -> Tuple[Dict[int, bytes], bo
magic_items_array[array_offset] += 1
# Add the start inventory arrays to the offset data in bytes form.
start_inventory_data[0x680080] = bytes(magic_items_array)
start_inventory_data[0x6800A0] = bytes(cards_array)
start_inventory_data[0x690080] = bytes(magic_items_array)
start_inventory_data[0x6900A0] = bytes(cards_array)
# Add the extra max HP/MP/Hearts to all classes' base stats. Doing it this way makes us less likely to hit the max
# possible Max Ups.

View File

@@ -132,40 +132,40 @@ start_inventory_giver = [
# Magic Items
0x13, 0x48, # ldr r0, =0x202572F
0x14, 0x49, # ldr r1, =0x8680080
0x14, 0x49, # ldr r1, =0x8690080
0x00, 0x22, # mov r2, #0
0x8B, 0x5C, # ldrb r3, [r1, r2]
0x83, 0x54, # strb r3, [r0, r2]
0x01, 0x32, # adds r2, #1
0x08, 0x2A, # cmp r2, #8
0xFA, 0xDB, # blt 0x8680006
0xFA, 0xDB, # blt 0x8690006
# Max Ups
0x11, 0x48, # ldr r0, =0x202572C
0x12, 0x49, # ldr r1, =0x8680090
0x12, 0x49, # ldr r1, =0x8690090
0x00, 0x22, # mov r2, #0
0x8B, 0x5C, # ldrb r3, [r1, r2]
0x83, 0x54, # strb r3, [r0, r2]
0x01, 0x32, # adds r2, #1
0x03, 0x2A, # cmp r2, #3
0xFA, 0xDB, # blt 0x8680016
0xFA, 0xDB, # blt 0x8690016
# Cards
0x0F, 0x48, # ldr r0, =0x2025674
0x10, 0x49, # ldr r1, =0x86800A0
0x10, 0x49, # ldr r1, =0x86900A0
0x00, 0x22, # mov r2, #0
0x8B, 0x5C, # ldrb r3, [r1, r2]
0x83, 0x54, # strb r3, [r0, r2]
0x01, 0x32, # adds r2, #1
0x14, 0x2A, # cmp r2, #0x14
0xFA, 0xDB, # blt 0x8680026
0xFA, 0xDB, # blt 0x8690026
# Inventory Items (not currently supported)
0x0D, 0x48, # ldr r0, =0x20256ED
0x0E, 0x49, # ldr r1, =0x86800C0
0x0E, 0x49, # ldr r1, =0x86900C0
0x00, 0x22, # mov r2, #0
0x8B, 0x5C, # ldrb r3, [r1, r2]
0x83, 0x54, # strb r3, [r0, r2]
0x01, 0x32, # adds r2, #1
0x36, 0x2A, # cmp r2, #36
0xFA, 0xDB, # blt 0x8680036
0xFA, 0xDB, # blt 0x8690036
# Return to the function that checks for Magician Mode.
0xBA, 0x21, # movs r1, #0xBA
0x89, 0x00, # lsls r1, r1, #2
@@ -176,13 +176,13 @@ start_inventory_giver = [
# LDR number pool
0x78, 0x7F, 0x00, 0x08,
0x2F, 0x57, 0x02, 0x02,
0x80, 0x00, 0x68, 0x08,
0x80, 0x00, 0x69, 0x08,
0x2C, 0x57, 0x02, 0x02,
0x90, 0x00, 0x68, 0x08,
0x90, 0x00, 0x69, 0x08,
0x74, 0x56, 0x02, 0x02,
0xA0, 0x00, 0x68, 0x08,
0xA0, 0x00, 0x69, 0x08,
0xED, 0x56, 0x02, 0x02,
0xC0, 0x00, 0x68, 0x08,
0xC0, 0x00, 0x69, 0x08,
]
max_max_up_checker = [

View File

@@ -3,7 +3,7 @@
## Quick Links
- [Setup](/tutorial/Castlevania%20-%20Circle%20of%20the%20Moon/setup/en)
- [Options Page](/games/Castlevania%20-%20Circle%20of%20the%20Moon/player-options)
- [PopTracker Pack](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest)
- [PopTracker Pack](https://github.com/BowserCrusher/Circle-of-the-Moon-AP-Tracker/releases/latest)
- [Repo for the original, standalone CotMR](https://github.com/calm-palm/cotm-randomizer)
- [Web version of the above randomizer](https://rando.circleofthemoon.com/)
- [A more in-depth guide to CotMR's nuances](https://docs.google.com/document/d/1uot4BD9XW7A--A8ecgoY8mLK_vSoQRpY5XCkzgas87c/view?usp=sharing)

View File

@@ -22,7 +22,7 @@ clear it.
## Optional Software
- [Castlevania: Circle of the Moon AP Tracker](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest), for use with
- [Castlevania: Circle of the Moon AP Tracker](https://github.com/BowserCrusher/Circle-of-the-Moon-AP-Tracker/releases/latest), for use with
[PopTracker](https://github.com/black-sliver/PopTracker/releases).
## Generating and Patching a Game
@@ -64,7 +64,7 @@ perfectly safe to make progress offline; everything will re-sync when you reconn
Castlevania: Circle of the Moon has a fully functional map tracker that supports auto-tracking.
1. Download [Castlevania: Circle of the Moon AP Tracker](https://github.com/sassyvania/Circle-of-the-Moon-Rando-AP-Map-Tracker-/releases/latest) and
1. Download [Castlevania: Circle of the Moon AP Tracker](https://github.com/BowserCrusher/Circle-of-the-Moon-AP-Tracker/releases/latest) and
[PopTracker](https://github.com/black-sliver/PopTracker/releases).
2. Put the tracker pack into `packs/` in your PopTracker install.
3. Open PopTracker, and load the Castlevania: Circle of the Moon pack.

View File

@@ -335,8 +335,8 @@ class CVCotMPatchExtensions(APPatchExtension):
rom_data.write_bytes(0x679A60, patches.kickless_roc_height_shortener)
# Give the player their Start Inventory upon entering their name on a new file.
rom_data.write_bytes(0x7F70, [0x00, 0x48, 0x87, 0x46, 0x00, 0x00, 0x68, 0x08])
rom_data.write_bytes(0x680000, patches.start_inventory_giver)
rom_data.write_bytes(0x7F70, [0x00, 0x48, 0x87, 0x46, 0x00, 0x00, 0x69, 0x08])
rom_data.write_bytes(0x690000, patches.start_inventory_giver)
# Prevent Max Ups from exceeding 255.
rom_data.write_bytes(0x5E170, [0x00, 0x4A, 0x97, 0x46, 0x00, 0x00, 0x6A, 0x08])

View File

@@ -511,7 +511,7 @@ _vanilla_items = [
DS3ItemData("Elkhorn Round Shield", 0x0133C510, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Warrior's Round Shield", 0x0133EC20, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Caduceus Round Shield", 0x01341330, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Red and White Shield", 0x01343A40, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Red and White Round Shield", 0x01343A40, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Blessed Red and White Shield+1", 0x01343FB9, DS3ItemCategory.SHIELD),
DS3ItemData("Plank Shield", 0x01346150, DS3ItemCategory.SHIELD_INFUSIBLE),
DS3ItemData("Leather Shield", 0x01348860, DS3ItemCategory.SHIELD_INFUSIBLE),

View File

@@ -706,7 +706,7 @@ location_tables: Dict[str, List[DS3LocationData]] = {
DS3LocationData("US: Whip - back alley, behind wooden wall", "Whip", hidden=True),
DS3LocationData("US: Great Scythe - building by white tree, balcony", "Great Scythe"),
DS3LocationData("US: Homeward Bone - foot, drop overlook", "Homeward Bone",
static='02,0:53100540::'),
static='02,0:53100950::'),
DS3LocationData("US: Large Soul of a Deserted Corpse - around corner by Cliff Underside",
"Large Soul of a Deserted Corpse", hidden=True), # Hidden corner
DS3LocationData("US: Ember - behind burning tree", "Ember"),
@@ -732,8 +732,9 @@ location_tables: Dict[str, List[DS3LocationData]] = {
missable=True), # requires projectile
DS3LocationData("US: Flame Stoneplate Ring - hanging corpse by Mound-Maker transport",
"Flame Stoneplate Ring"),
DS3LocationData("US: Red and White Shield - chasm, hanging corpse", "Red and White Shield",
static="02,0:53100740::", missable=True), # requires projectile
DS3LocationData("US: Red and White Round Shield - chasm, hanging corpse",
"Red and White Round Shield", static="02,0:53100740::",
missable=True), # requires projectile
DS3LocationData("US: Small Leather Shield - first building, hanging corpse by entrance",
"Small Leather Shield"),
DS3LocationData("US: Pale Tongue - tower village, hanging corpse", "Pale Tongue"),
@@ -883,7 +884,7 @@ location_tables: Dict[str, List[DS3LocationData]] = {
DS3LocationData("RS: Homeward Bone - balcony by Farron Keep", "Homeward Bone x2"),
DS3LocationData("RS: Titanite Shard - woods, surrounded by enemies", "Titanite Shard"),
DS3LocationData("RS: Twin Dragon Greatshield - woods by Crucifixion Woods bonfire",
"Twin Dragon Greatshield"),
"Twin Dragon Greatshield", missable=True), # After Eclipse
DS3LocationData("RS: Sorcerer Hood - water beneath stronghold", "Sorcerer Hood",
hidden=True), # Hidden fall
DS3LocationData("RS: Sorcerer Robe - water beneath stronghold", "Sorcerer Robe",
@@ -1886,7 +1887,7 @@ location_tables: Dict[str, List[DS3LocationData]] = {
DS3LocationData("AL: Twinkling Titanite - lizard after light cathedral #2",
"Twinkling Titanite", lizard=True),
DS3LocationData("AL: Aldrich's Ruby - dark cathedral, miniboss", "Aldrich's Ruby",
miniboss=True), # Deep Accursed drop
miniboss=True, missable=True), # Deep Accursed drop, missable after defeating Aldrich
DS3LocationData("AL: Aldrich Faithful - water reserves, talk to McDonnel", "Aldrich Faithful",
hidden=True), # Behind illusory wall

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