Compare commits

...

119 Commits

Author SHA1 Message Date
NewSoupVi
da256d7252 Take Counter back out of RestrictedUnpickler 2025-07-07 01:53:16 +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
307 changed files with 93302 additions and 8630 deletions

1
.github/labeler.yml vendored
View File

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

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

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

@@ -439,7 +439,7 @@ class MultiWorld():
return self.regions.location_cache[player][location_name]
def get_all_state(self, use_cache: bool, allow_partial_entrances: bool = False,
collect_pre_fill_items: bool = True) -> CollectionState:
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()
@@ -453,7 +453,8 @@ class MultiWorld():
subworld = self.worlds[player]
for item in subworld.get_pre_fill_items():
subworld.collect(ret, item)
ret.sweep_for_advancements()
if perform_sweep:
ret.sweep_for_advancements()
if use_cache:
self._all_state = ret
@@ -558,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
@@ -567,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:
@@ -736,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()}
@@ -1012,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:
@@ -1020,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
@@ -1293,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)
@@ -1563,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
@@ -1594,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:
@@ -1636,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()

44
Fill.py
View File

@@ -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
@@ -901,7 +890,7 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
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.",
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])
@@ -934,9 +923,9 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
if isinstance(locations, str):
locations = [locations]
locations_from_groups: list[str] = []
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:
@@ -948,13 +937,16 @@ def parse_planned_blocks(multiworld: MultiWorld) -> dict[int, list[PlandoItemBlo
count = block.count
if not count:
count = len(new_block.items)
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"] = len(new_block.items)
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)

View File

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

View File

@@ -11,6 +11,7 @@ Additional components can be added to worlds.LauncherComponents.components.
import argparse
import logging
import multiprocessing
import os
import shlex
import subprocess
import sys
@@ -41,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 = []
@@ -92,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}")
@@ -104,63 +113,48 @@ 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
return client_components, text_client_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()
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]:
@@ -202,7 +196,8 @@ def get_exe(component: str | Component) -> Sequence[str] | None:
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')
@@ -230,7 +225,7 @@ def create_shortcut(button: Any, component: Component) -> 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
@@ -263,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)
@@ -390,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
@@ -410,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.
@@ -433,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
@@ -460,7 +455,15 @@ def main(args: argparse.Namespace | dict | None = 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
@@ -476,7 +479,7 @@ def main(args: argparse.Namespace | dict | None = 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):

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()}

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

@@ -1524,9 +1524,11 @@ class PlandoItems(Option[typing.List[PlandoItem]]):
f"dictionary, not {type(items)}")
locations = item.get("locations", [])
if not locations:
locations = item.get("location", ["Everywhere"])
locations = item.get("location", [])
if locations:
count = 1
else:
locations = ["Everywhere"]
if isinstance(locations, str):
locations = [locations]
if not isinstance(locations, list):
@@ -1676,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
@@ -1714,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
@@ -80,6 +79,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

@@ -166,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
@@ -177,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()
@@ -226,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
@@ -432,9 +441,6 @@ class RestrictedUnpickler(pickle.Unpickler):
def find_class(self, module: str, name: str) -> type:
if module == "builtins" and name in safe_builtins:
return getattr(builtins, name)
# used by OptionCounter
if module == "collections" and name == "Counter":
return collections.Counter
# used by MultiServer -> savegame/multidata
if module == "NetUtils" and name in {"NetworkItem", "ClientStatus", "Hint",
"SlotType", "NetworkSlot", "HintStatus"}:
@@ -540,6 +546,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
@@ -706,25 +714,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:
@@ -758,21 +771,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:
@@ -799,9 +809,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()
@@ -812,10 +819,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

@@ -17,9 +17,7 @@
This page allows you to host a game which was not generated by the website. For example, if you have
generated a game on your own computer, you may upload the zip file created by the generator to
host the game here. This will also provide a tracker, and the ability for your players to download
their patch files if the game is core-verified. For Custom Games, you can find the patch files in
the output .zip file you are uploading here. You need to manually distribute those patch files to
your players.
their patch files.
</p>
<p>In addition to the zip file created by the generator, you may upload a multidata file here as well.</p>
<div id="host-game-form-wrapper">

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

@@ -87,6 +87,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 +121,6 @@
# The Messenger
/worlds/messenger/ @alwaysintreble
# Minecraft
/worlds/minecraft/ @KonoTyran @espeon65536
# Mega Man 2
/worlds/mm2/ @Silvris
@@ -157,6 +157,9 @@
# Saving Princess
/worlds/saving_princess/ @LeonarthCG
# shapez
/worlds/shapez/ @BlastSlimey
# Shivers
/worlds/shivers/ @GodlFire @korydondzila
@@ -175,6 +178,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 +238,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 +247,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

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

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

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

@@ -64,7 +64,6 @@ non_apworlds: set[str] = {
"ArchipIDLE",
"Archipelago",
"Clique",
"Final Fantasy",
"Lufia II Ancient Cave",
"Meritous",
"Ocarina of Time",
@@ -373,10 +372,6 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
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)
generate_yaml_templates(self.buildfolder / "Players" / "Templates", False)
for worldname, worldtype in AutoWorldRegister.world_types.items():
if worldname not in non_apworlds:
@@ -486,7 +481,7 @@ 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)

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

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

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

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

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

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

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

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

@@ -884,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",
@@ -1887,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

View File

@@ -75,6 +75,13 @@ class DarkSouls3World(World):
"""The pool of all items within this particular world. This is a subset of
`self.multiworld.itempool`."""
missable_dupe_prog_locs: Set[str] = {"PC: Storm Ruler - Siegward",
"US: Pyromancy Flame - Cornyx",
"US: Tower Key - kill Irina"}
"""Locations whose vanilla item is a missable duplicate of a non-missable progression item.
If vanilla, these locations shouldn't be expected progression, so they aren't created and don't get rules.
"""
def __init__(self, multiworld: MultiWorld, player: int):
super().__init__(multiworld, player)
self.all_excluded_locations = set()
@@ -258,10 +265,7 @@ class DarkSouls3World(World):
new_location.progress_type = LocationProgressType.EXCLUDED
else:
# Don't allow missable duplicates of progression items to be expected progression.
if location.name in {"PC: Storm Ruler - Siegward",
"US: Pyromancy Flame - Cornyx",
"US: Tower Key - kill Irina"}:
continue
if location.name in self.missable_dupe_prog_locs: continue
# Replace non-randomized items with events that give the default item
event_item = (
@@ -273,9 +277,7 @@ class DarkSouls3World(World):
self.player,
location,
parent = new_region,
event = True,
)
event_item.code = None
new_location.place_locked_item(event_item)
if location.name in excluded:
excluded.remove(location.name)
@@ -707,7 +709,7 @@ class DarkSouls3World(World):
if self._is_location_available("US: Young White Branch - by white tree #2"):
self._add_item_rule(
"US: Young White Branch - by white tree #2",
lambda item: item.player == self.player and not item.data.unique
lambda item: item.player != self.player or not item.data.unique
)
# Make sure the Storm Ruler is available BEFORE Yhorm the Giant
@@ -1288,8 +1290,9 @@ class DarkSouls3World(World):
data = location_dictionary[location]
if data.dlc and not self.options.enable_dlc: continue
if data.ngp and not self.options.enable_ngp: continue
# Don't add rules to missable duplicates of progression items
if location in self.missable_dupe_prog_locs and not self._is_location_available(location): continue
if not self._is_location_available(location): continue
if isinstance(rule, str):
assert item_dictionary[rule].classification == ItemClassification.progression
rule = lambda state, item=rule: state.has(item, self.player)

View File

@@ -73,7 +73,7 @@ things to keep in mind:
* To run the game itself, just run `launchmod_darksouls3.bat` under Proton.
[.NET Runtime]: https://dotnet.microsoft.com/en-us/download/dotnet/8.0
[.NET Runtime]: https://dotnet.microsoft.com/en-us/download/dotnet/6.0
[WINE]: https://www.winehq.org/
## Troubleshooting

View File

@@ -802,8 +802,10 @@ def connect_regions(world: World, level_list):
for i in range(0, len(kremwood_forest_levels) - 1):
connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[i])
connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[-1],
lambda state: (state.can_reach(LocationName.riverside_race_flag, "Location", world.player)))
connection = connect(world, world.player, names, LocationName.kremwood_forest_region, kremwood_forest_levels[-1],
lambda state: (state.can_reach(LocationName.riverside_race_flag, "Location", world.player)))
world.multiworld.register_indirect_condition(world.get_location(LocationName.riverside_race_flag).parent_region,
connection)
# Cotton-Top Cove Connections
cotton_top_cove_levels = [
@@ -837,8 +839,11 @@ def connect_regions(world: World, level_list):
connect(world, world.player, names, LocationName.mekanos_region, LocationName.sky_high_secret_region,
lambda state: (state.has(ItemName.bowling_ball, world.player, 1)))
else:
connect(world, world.player, names, LocationName.mekanos_region, LocationName.sky_high_secret_region,
lambda state: (state.can_reach(LocationName.bleaks_house, "Location", world.player)))
connection = connect(world, world.player, names, LocationName.mekanos_region,
LocationName.sky_high_secret_region,
lambda state: (state.can_reach(LocationName.bleaks_house, "Location", world.player)))
world.multiworld.register_indirect_condition(world.get_location(LocationName.bleaks_house).parent_region,
connection)
# K3 Connections
k3_levels = [
@@ -946,3 +951,4 @@ def connect(world: World, player: int, used_names: typing.Dict[str, int], source
source_region.exits.append(connection)
connection.connect(target_region)
return connection

View File

@@ -280,16 +280,19 @@ def set_boss_door_requirements_rules(player, world):
set_rule(world.get_entrance("Boss Door", player), has_3_swords)
def set_lfod_self_obtained_items_rules(world_options, player, world):
def set_lfod_self_obtained_items_rules(world_options, player, multiworld):
if world_options.item_shuffle != Options.ItemShuffle.option_disabled:
return
set_rule(world.get_entrance("Vines", player),
world = multiworld.worlds[player]
set_rule(world.get_entrance("Vines"),
lambda state: state.has("Incredibly Important Pack", player))
set_rule(world.get_entrance("Behind Rocks", player),
set_rule(world.get_entrance("Behind Rocks"),
lambda state: state.can_reach("Cut Content", 'region', player))
set_rule(world.get_entrance("Pickaxe Hard Cave", player),
multiworld.register_indirect_condition(world.get_region("Cut Content"), world.get_entrance("Behind Rocks"))
set_rule(world.get_entrance("Pickaxe Hard Cave"),
lambda state: state.can_reach("Cut Content", 'region', player) and
state.has("Name Change Pack", player))
multiworld.register_indirect_condition(world.get_region("Cut Content"), world.get_entrance("Pickaxe Hard Cave"))
def set_lfod_shuffled_items_rules(world_options, player, world):

View File

@@ -69,7 +69,9 @@ class FactorioContext(CommonContext):
# updated by spinup server
mod_version: Version = Version(0, 0, 0)
def __init__(self, server_address, password, filter_item_sends: bool, bridge_chat_out: bool):
def __init__(self, server_address, password, filter_item_sends: bool, bridge_chat_out: bool,
rcon_port: int, rcon_password: str, server_settings_path: str | None,
factorio_server_args: tuple[str, ...]):
super(FactorioContext, self).__init__(server_address, password)
self.send_index: int = 0
self.rcon_client = None
@@ -82,6 +84,10 @@ class FactorioContext(CommonContext):
self.filter_item_sends: bool = filter_item_sends
self.multiplayer: bool = False # whether multiple different players have connected
self.bridge_chat_out: bool = bridge_chat_out
self.rcon_port: int = rcon_port
self.rcon_password: str = rcon_password
self.server_settings_path: str = server_settings_path
self.additional_factorio_server_args = factorio_server_args
@property
def energylink_key(self) -> str:
@@ -126,6 +132,18 @@ class FactorioContext(CommonContext):
self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] "
f"{text}")
@property
def server_args(self) -> tuple[str, ...]:
if self.server_settings_path:
return (
"--rcon-port", str(self.rcon_port),
"--rcon-password", self.rcon_password,
"--server-settings", self.server_settings_path,
*self.additional_factorio_server_args)
else:
return ("--rcon-port", str(self.rcon_port), "--rcon-password", self.rcon_password,
*self.additional_factorio_server_args)
@property
def energy_link_status(self) -> str:
if not self.energy_link_increment:
@@ -311,7 +329,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
executable, "--create", savegame_name, "--preset", "archipelago"
))
factorio_process = subprocess.Popen((executable, "--start-server", savegame_name,
*(str(elem) for elem in server_args)),
*ctx.server_args),
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
@@ -331,7 +349,7 @@ async def factorio_server_watcher(ctx: FactorioContext):
factorio_queue.task_done()
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password,
ctx.rcon_client = factorio_rcon.RCONClient("localhost", ctx.rcon_port, ctx.rcon_password,
timeout=5)
if not ctx.server:
logger.info("Established bridge to Factorio Server. "
@@ -422,7 +440,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
executable, "--create", savegame_name
))
factorio_process = subprocess.Popen(
(executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)),
(executable, "--start-server", savegame_name, *ctx.server_args),
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.DEVNULL,
@@ -451,7 +469,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
"or a Factorio sharing data directories is already running. "
"Server could not start up.")
if not rcon_client and "Starting RCON interface at IP ADDR:" in msg:
rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password)
rcon_client = factorio_rcon.RCONClient("localhost", ctx.rcon_port, ctx.rcon_password)
if ctx.mod_version == ctx.__class__.mod_version:
raise Exception("No Archipelago mod was loaded. Aborting.")
await get_info(ctx, rcon_client)
@@ -474,9 +492,8 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool:
return False
async def main(args, filter_item_sends: bool, filter_bridge_chat_out: bool):
ctx = FactorioContext(args.connect, args.password, filter_item_sends, filter_bridge_chat_out)
async def main(make_context):
ctx = make_context()
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
if gui_enabled:
@@ -509,38 +526,42 @@ class FactorioJSONtoTextParser(JSONtoTextParser):
return self._handle_text(node)
parser = get_base_parser(description="Optional arguments to FactorioClient follow. "
"Remaining arguments get passed into bound Factorio instance."
"Refer to Factorio --help for those.")
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
args, rest = parser.parse_known_args()
rcon_port = args.rcon_port
rcon_password = args.rcon_password if args.rcon_password else ''.join(
random.choice(string.ascii_letters) for x in range(32))
factorio_server_logger = logging.getLogger("FactorioServer")
settings: FactorioSettings = get_settings().factorio_options
if os.path.samefile(settings.executable, sys.executable):
selected_executable = settings.executable
settings.executable = FactorioSettings.executable # reset to default
raise Exception(f"FactorioClient was set to run itself {selected_executable}, aborting process bomb.")
raise Exception(f"Factorio Client was set to run itself {selected_executable}, aborting process bomb.")
executable = settings.executable
server_settings = args.server_settings if args.server_settings \
else getattr(settings, "server_settings", None)
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password)
def launch():
def launch(*new_args: str):
import colorama
global executable, server_settings, server_args
global executable
colorama.just_fix_windows_console()
# args handling
parser = get_base_parser(description="Optional arguments to Factorio Client follow. "
"Remaining arguments get passed into bound Factorio instance."
"Refer to Factorio --help for those.")
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
parser.add_argument('--rcon-password', help='Password to authenticate with RCON.')
parser.add_argument('--server-settings', help='Factorio server settings configuration file.')
args, rest = parser.parse_known_args(args=new_args)
rcon_port = args.rcon_port
rcon_password = args.rcon_password if args.rcon_password else ''.join(
random.choice(string.ascii_letters) for _ in range(32))
server_settings = args.server_settings if args.server_settings \
else getattr(settings, "server_settings", None)
if server_settings:
server_settings = os.path.abspath(server_settings)
if not os.path.isfile(server_settings):
raise FileNotFoundError(f"Could not find file {server_settings} for server_settings. Aborting.")
initial_filter_item_sends = bool(settings.filter_item_sends)
initial_bridge_chat_out = bool(settings.bridge_chat_out)
@@ -554,14 +575,9 @@ def launch():
else:
raise FileNotFoundError(f"Path {executable} is not an executable file.")
if server_settings and os.path.isfile(server_settings):
server_args = (
"--rcon-port", rcon_port,
"--rcon-password", rcon_password,
"--server-settings", server_settings,
*rest)
else:
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest)
asyncio.run(main(args, initial_filter_item_sends, initial_bridge_chat_out))
asyncio.run(main(lambda: FactorioContext(
args.connect, args.password,
initial_filter_item_sends, initial_bridge_chat_out,
rcon_port, rcon_password, server_settings, rest
)))
colorama.deinit()

View File

@@ -63,10 +63,11 @@ recipe_time_ranges = {
}
class FactorioModFile(worlds.Files.APContainer):
class FactorioModFile(worlds.Files.APPlayerContainer):
game = "Factorio"
compression_method = zipfile.ZIP_DEFLATED # Factorio can't load LZMA archives
writing_tasks: List[Callable[[], Tuple[str, Union[str, bytes]]]]
patch_file_ending = ".zip"
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)

View File

@@ -22,9 +22,9 @@ from .Technologies import base_tech_table, recipe_sources, base_technology_table
from .settings import FactorioSettings
def launch_client():
def launch_client(*args: str):
from .Client import launch
launch_component(launch, name="FactorioClient")
launch_component(launch, name="Factorio Client", args=args)
components.append(Component("Factorio Client", func=launch_client, component_type=Type.CLIENT))

328
worlds/ff1/Client.py Normal file
View File

@@ -0,0 +1,328 @@
import logging
from collections import deque
from typing import TYPE_CHECKING
from NetUtils import ClientStatus
import worlds._bizhawk as bizhawk
from worlds._bizhawk.client import BizHawkClient
if TYPE_CHECKING:
from worlds._bizhawk.context import BizHawkClientContext
base_id = 7000
logger = logging.getLogger("Client")
rom_name_location = 0x07FFE3
locations_array_start = 0x200
locations_array_length = 0x100
items_obtained = 0x03
gp_location_low = 0x1C
gp_location_middle = 0x1D
gp_location_high = 0x1E
weapons_arrays_starts = [0x118, 0x158, 0x198, 0x1D8]
armors_arrays_starts = [0x11C, 0x15C, 0x19C, 0x1DC]
status_a_location = 0x102
status_b_location = 0x0FC
status_c_location = 0x0A3
key_items = ["Lute", "Crown", "Crystal", "Herb", "Key", "Tnt", "Adamant", "Slab", "Ruby", "Rod",
"Floater", "Chime", "Tail", "Cube", "Bottle", "Oxyale", "EarthOrb", "FireOrb", "WaterOrb", "AirOrb"]
consumables = ["Shard", "Tent", "Cabin", "House", "Heal", "Pure", "Soft"]
weapons = ["WoodenNunchucks", "SmallKnife", "WoodenRod", "Rapier", "IronHammer", "ShortSword", "HandAxe", "Scimitar",
"IronNunchucks", "LargeKnife", "IronStaff", "Sabre", "LongSword", "GreatAxe", "Falchon", "SilverKnife",
"SilverSword", "SilverHammer", "SilverAxe", "FlameSword", "IceSword", "DragonSword", "GiantSword",
"SunSword", "CoralSword", "WereSword", "RuneSword", "PowerRod", "LightAxe", "HealRod", "MageRod", "Defense",
"WizardRod", "Vorpal", "CatClaw", "ThorHammer", "BaneSword", "Katana", "Xcalber", "Masamune"]
armor = ["Cloth", "WoodenArmor", "ChainArmor", "IronArmor", "SteelArmor", "SilverArmor", "FlameArmor", "IceArmor",
"OpalArmor", "DragonArmor", "Copper", "Silver", "Gold", "Opal", "WhiteShirt", "BlackShirt", "WoodenShield",
"IronShield", "SilverShield", "FlameShield", "IceShield", "OpalShield", "AegisShield", "Buckler", "ProCape",
"Cap", "WoodenHelm", "IronHelm", "SilverHelm", "OpalHelm", "HealHelm", "Ribbon", "Gloves", "CopperGauntlets",
"IronGauntlets", "SilverGauntlets", "ZeusGauntlets", "PowerGauntlets", "OpalGauntlets", "ProRing"]
gold_items = ["Gold10", "Gold20", "Gold25", "Gold30", "Gold55", "Gold70", "Gold85", "Gold110", "Gold135", "Gold155",
"Gold160", "Gold180", "Gold240", "Gold255", "Gold260", "Gold295", "Gold300", "Gold315", "Gold330",
"Gold350", "Gold385", "Gold400", "Gold450", "Gold500", "Gold530", "Gold575", "Gold620", "Gold680",
"Gold750", "Gold795", "Gold880", "Gold1020", "Gold1250", "Gold1455", "Gold1520", "Gold1760", "Gold1975",
"Gold2000", "Gold2750", "Gold3400", "Gold4150", "Gold5000", "Gold5450", "Gold6400", "Gold6720",
"Gold7340", "Gold7690", "Gold7900", "Gold8135", "Gold9000", "Gold9300", "Gold9500", "Gold9900",
"Gold10000", "Gold12350", "Gold13000", "Gold13450", "Gold14050", "Gold14720", "Gold15000", "Gold17490",
"Gold18010", "Gold19990", "Gold20000", "Gold20010", "Gold26000", "Gold45000", "Gold65000"]
extended_consumables = ["FullCure", "Phoenix", "Blast", "Smoke",
"Refresh", "Flare", "Black", "Guard",
"Quick", "HighPotion", "Wizard", "Cloak"]
ext_consumables_lookup = {"FullCure": "Ext1", "Phoenix": "Ext2", "Blast": "Ext3", "Smoke": "Ext4",
"Refresh": "Ext1", "Flare": "Ext2", "Black": "Ext3", "Guard": "Ext4",
"Quick": "Ext1", "HighPotion": "Ext2", "Wizard": "Ext3", "Cloak": "Ext4"}
ext_consumables_locations = {"Ext1": 0x3C, "Ext2": 0x3D, "Ext3": 0x3E, "Ext4": 0x3F}
movement_items = ["Ship", "Bridge", "Canal", "Canoe"]
no_overworld_items = ["Sigil", "Mark"]
class FF1Client(BizHawkClient):
game = "Final Fantasy"
system = "NES"
weapons_queue: deque[int]
armor_queue: deque[int]
consumable_stack_amounts: dict[str, int] | None
def __init__(self) -> None:
self.wram = "RAM"
self.sram = "WRAM"
self.rom = "PRG ROM"
self.consumable_stack_amounts = None
self.weapons_queue = deque()
self.armor_queue = deque()
self.guard_character = 0x00
async def validate_rom(self, ctx: "BizHawkClientContext") -> bool:
try:
# Check ROM name/patch version
rom_name = ((await bizhawk.read(ctx.bizhawk_ctx, [(rom_name_location, 0x0D, self.rom)]))[0])
rom_name = rom_name.decode("ascii")
if rom_name != "FINAL FANTASY":
return False # Not a Final Fantasy 1 ROM
except bizhawk.RequestFailedError:
return False # Not able to get a response, say no for now
ctx.game = self.game
ctx.items_handling = 0b111
ctx.want_slot_data = True
# Resetting these in case of switching ROMs
self.consumable_stack_amounts = None
self.weapons_queue = deque()
self.armor_queue = deque()
return True
async def game_watcher(self, ctx: "BizHawkClientContext") -> None:
if ctx.server is None:
return
if ctx.slot is None:
return
try:
self.guard_character = await self.read_sram_value(ctx, status_a_location)
# If the first character's name starts with a 0 value, we're at the title screen/character creation.
# In that case, don't allow any read/writes.
# We do this by setting the guard to 1 because that's neither a valid character nor the initial value.
if self.guard_character == 0:
self.guard_character = 0x01
if self.consumable_stack_amounts is None:
self.consumable_stack_amounts = {}
self.consumable_stack_amounts["Shard"] = 1
other_consumable_amounts = await self.read_rom(ctx, 0x47400, 10)
self.consumable_stack_amounts["Tent"] = other_consumable_amounts[0] + 1
self.consumable_stack_amounts["Cabin"] = other_consumable_amounts[1] + 1
self.consumable_stack_amounts["House"] = other_consumable_amounts[2] + 1
self.consumable_stack_amounts["Heal"] = other_consumable_amounts[3] + 1
self.consumable_stack_amounts["Pure"] = other_consumable_amounts[4] + 1
self.consumable_stack_amounts["Soft"] = other_consumable_amounts[5] + 1
self.consumable_stack_amounts["Ext1"] = other_consumable_amounts[6] + 1
self.consumable_stack_amounts["Ext2"] = other_consumable_amounts[7] + 1
self.consumable_stack_amounts["Ext3"] = other_consumable_amounts[8] + 1
self.consumable_stack_amounts["Ext4"] = other_consumable_amounts[9] + 1
await self.location_check(ctx)
await self.received_items_check(ctx)
await self.process_weapons_queue(ctx)
await self.process_armor_queue(ctx)
except bizhawk.RequestFailedError:
# The connector didn't respond. Exit handler and return to main loop to reconnect
pass
async def location_check(self, ctx: "BizHawkClientContext"):
locations_data = await self.read_sram_values_guarded(ctx, locations_array_start, locations_array_length)
if locations_data is None:
return
locations_checked = []
if len(locations_data) > 0xFE and locations_data[0xFE] & 0x02 != 0 and not ctx.finished_game:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": ClientStatus.CLIENT_GOAL}
])
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
if locations_data[index] & flag != 0:
locations_checked.append(location)
found_locations = await ctx.check_locations(locations_checked)
for location in found_locations:
ctx.locations_checked.add(location)
location_name = ctx.location_names.lookup_in_game(location)
logger.info(
f'New Check: {location_name} ({len(ctx.locations_checked)}/'
f'{len(ctx.missing_locations) + len(ctx.checked_locations)})')
async def received_items_check(self, ctx: "BizHawkClientContext") -> None:
assert self.consumable_stack_amounts, "shouldn't call this function without reading consumable_stack_amounts"
write_list: list[tuple[int, list[int], str]] = []
items_received_count = await self.read_sram_value_guarded(ctx, items_obtained)
if items_received_count is None:
return
if items_received_count < len(ctx.items_received):
current_item = ctx.items_received[items_received_count]
current_item_id = current_item.item
current_item_name = ctx.item_names.lookup_in_game(current_item_id, ctx.game)
if current_item_name in key_items:
location = current_item_id - 0xE0
write_list.append((location, [1], self.sram))
elif current_item_name in movement_items:
location = current_item_id - 0x1E0
if current_item_name != "Canal":
write_list.append((location, [1], self.sram))
else:
write_list.append((location, [0], self.sram))
elif current_item_name in no_overworld_items:
if current_item_name == "Sigil":
location = 0x28
else:
location = 0x12
write_list.append((location, [1], self.sram))
elif current_item_name in gold_items:
gold_amount = int(current_item_name[4:])
current_gold_value = await self.read_sram_values_guarded(ctx, gp_location_low, 3)
if current_gold_value is None:
return
current_gold = int.from_bytes(current_gold_value, "little")
new_gold = min(gold_amount + current_gold, 999999)
lower_byte = new_gold % (2 ** 8)
middle_byte = (new_gold // (2 ** 8)) % (2 ** 8)
upper_byte = new_gold // (2 ** 16)
write_list.append((gp_location_low, [lower_byte], self.sram))
write_list.append((gp_location_middle, [middle_byte], self.sram))
write_list.append((gp_location_high, [upper_byte], self.sram))
elif current_item_name in consumables:
location = current_item_id - 0xE0
current_value = await self.read_sram_value_guarded(ctx, location)
if current_value is None:
return
amount_to_add = self.consumable_stack_amounts[current_item_name]
new_value = min(current_value + amount_to_add, 99)
write_list.append((location, [new_value], self.sram))
elif current_item_name in extended_consumables:
ext_name = ext_consumables_lookup[current_item_name]
location = ext_consumables_locations[ext_name]
current_value = await self.read_sram_value_guarded(ctx, location)
if current_value is None:
return
amount_to_add = self.consumable_stack_amounts[ext_name]
new_value = min(current_value + amount_to_add, 99)
write_list.append((location, [new_value], self.sram))
elif current_item_name in weapons:
self.weapons_queue.appendleft(current_item_id - 0x11B)
elif current_item_name in armor:
self.armor_queue.appendleft(current_item_id - 0x143)
write_list.append((items_obtained, [items_received_count + 1], self.sram))
write_successful = await self.write_sram_values_guarded(ctx, write_list)
if write_successful:
await bizhawk.display_message(ctx.bizhawk_ctx, f"Received {current_item_name}")
async def process_weapons_queue(self, ctx: "BizHawkClientContext"):
empty_slots = deque()
char1_slots = await self.read_sram_values_guarded(ctx, weapons_arrays_starts[0], 4)
char2_slots = await self.read_sram_values_guarded(ctx, weapons_arrays_starts[1], 4)
char3_slots = await self.read_sram_values_guarded(ctx, weapons_arrays_starts[2], 4)
char4_slots = await self.read_sram_values_guarded(ctx, weapons_arrays_starts[3], 4)
if char1_slots is None or char2_slots is None or char3_slots is None or char4_slots is None:
return
for i, slot in enumerate(char1_slots):
if slot == 0:
empty_slots.appendleft(weapons_arrays_starts[0] + i)
for i, slot in enumerate(char2_slots):
if slot == 0:
empty_slots.appendleft(weapons_arrays_starts[1] + i)
for i, slot in enumerate(char3_slots):
if slot == 0:
empty_slots.appendleft(weapons_arrays_starts[2] + i)
for i, slot in enumerate(char4_slots):
if slot == 0:
empty_slots.appendleft(weapons_arrays_starts[3] + i)
while len(empty_slots) > 0 and len(self.weapons_queue) > 0:
current_slot = empty_slots.pop()
current_weapon = self.weapons_queue.pop()
await self.write_sram_guarded(ctx, current_slot, current_weapon)
async def process_armor_queue(self, ctx: "BizHawkClientContext"):
empty_slots = deque()
char1_slots = await self.read_sram_values_guarded(ctx, armors_arrays_starts[0], 4)
char2_slots = await self.read_sram_values_guarded(ctx, armors_arrays_starts[1], 4)
char3_slots = await self.read_sram_values_guarded(ctx, armors_arrays_starts[2], 4)
char4_slots = await self.read_sram_values_guarded(ctx, armors_arrays_starts[3], 4)
if char1_slots is None or char2_slots is None or char3_slots is None or char4_slots is None:
return
for i, slot in enumerate(char1_slots):
if slot == 0:
empty_slots.appendleft(armors_arrays_starts[0] + i)
for i, slot in enumerate(char2_slots):
if slot == 0:
empty_slots.appendleft(armors_arrays_starts[1] + i)
for i, slot in enumerate(char3_slots):
if slot == 0:
empty_slots.appendleft(armors_arrays_starts[2] + i)
for i, slot in enumerate(char4_slots):
if slot == 0:
empty_slots.appendleft(armors_arrays_starts[3] + i)
while len(empty_slots) > 0 and len(self.armor_queue) > 0:
current_slot = empty_slots.pop()
current_armor = self.armor_queue.pop()
await self.write_sram_guarded(ctx, current_slot, current_armor)
async def read_sram_value(self, ctx: "BizHawkClientContext", location: int):
value = ((await bizhawk.read(ctx.bizhawk_ctx, [(location, 1, self.sram)]))[0])
return int.from_bytes(value, "little")
async def read_sram_values_guarded(self, ctx: "BizHawkClientContext", location: int, size: int):
value = await bizhawk.guarded_read(ctx.bizhawk_ctx,
[(location, size, self.sram)],
[(status_a_location, [self.guard_character], self.sram)])
if value is None:
return None
return value[0]
async def read_sram_value_guarded(self, ctx: "BizHawkClientContext", location: int):
value = await bizhawk.guarded_read(ctx.bizhawk_ctx,
[(location, 1, self.sram)],
[(status_a_location, [self.guard_character], self.sram)])
if value is None:
return None
return int.from_bytes(value[0], "little")
async def read_rom(self, ctx: "BizHawkClientContext", location: int, size: int):
return (await bizhawk.read(ctx.bizhawk_ctx, [(location, size, self.rom)]))[0]
async def write_sram_guarded(self, ctx: "BizHawkClientContext", location: int, value: int):
return await bizhawk.guarded_write(ctx.bizhawk_ctx,
[(location, [value], self.sram)],
[(status_a_location, [self.guard_character], self.sram)])
async def write_sram_values_guarded(self, ctx: "BizHawkClientContext", write_list):
return await bizhawk.guarded_write(ctx.bizhawk_ctx,
write_list,
[(status_a_location, [self.guard_character], self.sram)])

View File

@@ -1,5 +1,5 @@
import json
from pathlib import Path
import pkgutil
from typing import Dict, Set, NamedTuple, List
from BaseClasses import Item, ItemClassification
@@ -37,15 +37,13 @@ class FF1Items:
_item_table_lookup: Dict[str, ItemData] = {}
def _populate_item_table_from_data(self):
base_path = Path(__file__).parent
file_path = (base_path / "data/items.json").resolve()
with open(file_path) as file:
items = json.load(file)
# Hardcode progression and categories for now
self._item_table = [ItemData(name, code, "FF1Item", ItemClassification.progression if name in
FF1_PROGRESSION_LIST else ItemClassification.useful if name in FF1_USEFUL_LIST else
ItemClassification.filler) for name, code in items.items()]
self._item_table_lookup = {item.name: item for item in self._item_table}
file = pkgutil.get_data(__name__, "data/items.json").decode("utf-8")
items = json.loads(file)
# Hardcode progression and categories for now
self._item_table = [ItemData(name, code, "FF1Item", ItemClassification.progression if name in
FF1_PROGRESSION_LIST else ItemClassification.useful if name in FF1_USEFUL_LIST else
ItemClassification.filler) for name, code in items.items()]
self._item_table_lookup = {item.name: item for item in self._item_table}
def _get_item_table(self) -> List[ItemData]:
if not self._item_table or not self._item_table_lookup:

View File

@@ -1,5 +1,5 @@
import json
from pathlib import Path
import pkgutil
from typing import Dict, NamedTuple, List, Optional
from BaseClasses import Region, Location, MultiWorld
@@ -18,13 +18,11 @@ class FF1Locations:
_location_table_lookup: Dict[str, LocationData] = {}
def _populate_item_table_from_data(self):
base_path = Path(__file__).parent
file_path = (base_path / "data/locations.json").resolve()
with open(file_path) as file:
locations = json.load(file)
# Hardcode progression and categories for now
self._location_table = [LocationData(name, code) for name, code in locations.items()]
self._location_table_lookup = {item.name: item for item in self._location_table}
file = pkgutil.get_data(__name__, "data/locations.json")
locations = json.loads(file)
# Hardcode progression and categories for now
self._location_table = [LocationData(name, code) for name, code in locations.items()]
self._location_table_lookup = {item.name: item for item in self._location_table}
def _get_location_table(self) -> List[LocationData]:
if not self._location_table or not self._location_table_lookup:

View File

@@ -7,6 +7,7 @@ from .Items import ItemData, FF1Items, FF1_STARTER_ITEMS, FF1_PROGRESSION_LIST,
from .Locations import EventId, FF1Locations, generate_rule, CHAOS_TERMINATED_EVENT
from .Options import FF1Options
from ..AutoWorld import World, WebWorld
from .Client import FF1Client
class FF1Settings(settings.Group):

View File

@@ -22,11 +22,6 @@ All items can appear in other players worlds, including consumables, shards, wea
## What does another world's item look like in Final Fantasy
All local and remote items appear the same. Final Fantasy will say that you received an item, then BOTH the client log and the
emulator will display what was found external to the in-game text box.
All local and remote items appear the same. Final Fantasy will say that you received an item, then the client log will
display what was found external to the in-game text box.
## Unique Local Commands
The following commands are only available when using the FF1Client for the Final Fantasy Randomizer.
- `/nes` Shows the current status of the NES connection.
- `/toggle_msgs` Toggle displaying messages in EmuHawk

View File

@@ -2,10 +2,10 @@
## Required Software
- The FF1Client
- Bundled with Archipelago: [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases)
- The BizHawk emulator. Versions 2.3.1 and higher are supported. Version 2.7 is recommended
- [BizHawk at TASVideos](https://tasvideos.org/BizHawk)
- BizHawk: [BizHawk Releases from TASVideos](https://tasvideos.org/BizHawk/ReleaseHistory)
- Detailed installation instructions for BizHawk can be found at the above link.
- Windows users must run the prerequisite installer first, which can also be found at the above link.
- The built-in BizHawk client, which can be installed [here](https://github.com/ArchipelagoMW/Archipelago/releases)
- Your legally obtained Final Fantasy (USA Edition) ROM file, probably named `Final Fantasy (USA).nes`. Neither
Archipelago.gg nor the Final Fantasy Randomizer Community can supply you with this.
@@ -13,7 +13,7 @@
1. Download and install the latest version of Archipelago.
1. On Windows, download Setup.Archipelago.<HighestVersion\>.exe and run it
2. Assign EmuHawk version 2.3.1 or higher as your default program for launching `.nes` files.
2. Assign EmuHawk as your default program for launching `.nes` files.
1. Extract your BizHawk folder to your Desktop, or somewhere you will remember. Below are optional additional steps
for loading ROMs more conveniently
1. Right-click on a ROM file and select **Open with...**
@@ -46,7 +46,7 @@ please refer to the [game agnostic setup guide](/tutorial/Archipelago/setup/en).
Once the Archipelago server has been hosted:
1. Navigate to your Archipelago install folder and run `ArchipelagoFF1Client.exe`
1. Navigate to your Archipelago install folder and run `ArchipelagoBizhawkClient.exe`
2. Notice the `/connect command` on the server hosting page (It should look like `/connect archipelago.gg:*****`
where ***** are numbers)
3. Type the connect command into the client OR add the port to the pre-populated address on the top bar (it should
@@ -54,16 +54,11 @@ Once the Archipelago server has been hosted:
### Running Your Game and Connecting to the Client Program
1. Open EmuHawk 2.3.1 or higher and load your ROM OR click your ROM file if it is already associated with the
1. Open EmuHawk and load your ROM OR click your ROM file if it is already associated with the
extension `*.nes`
2. Navigate to where you installed Archipelago, then to `data/lua`, and drag+drop the `connector_ff1.lua` script onto
the main EmuHawk window.
1. You could instead open the Lua Console manually, click `Script``Open Script`, and navigate to
`connector_ff1.lua` with the file picker.
2. If it gives a `NLua.Exceptions.LuaScriptException: .\socket.lua:13: module 'socket.core' not found:` exception
close your emulator entirely, restart it and re-run these steps
3. If it says `Must use a version of BizHawk 2.3.1 or higher`, double-check your BizHawk version by clicking **
Help** -> **About**
2. Navigate to where you installed Archipelago, then to `data/lua`, and drag+drop the `connector_bizhawk_generic.lua`
script onto the main EmuHawk window. You can also instead open the Lua Console manually, click `Script``Open Script`,
and navigate to `connector_bizhawk_generic.lua` with the file picker.
## Play the game

View File

@@ -288,7 +288,7 @@ world and the beginning of another world. You can also combine multiple files by
### Example
```yaml
description: Example of generating multiple worlds. World 1 of 3
description: Example of generating multiple worlds. World 1 of 2
name: Mario
game: Super Mario 64
requires:
@@ -310,31 +310,6 @@ Super Mario 64:
---
description: Example of generating multiple worlds. World 2 of 3
name: Minecraft
game: Minecraft
Minecraft:
progression_balancing: 50
accessibility: items
advancement_goal: 40
combat_difficulty: hard
include_hard_advancements: false
include_unreasonable_advancements: false
include_postgame_advancements: false
shuffle_structures: true
structure_compasses: true
send_defeated_mobs: true
bee_traps: 15
egg_shards_required: 7
egg_shards_available: 10
required_bosses:
none: 0
ender_dragon: 1
wither: 0
both: 0
---
description: Example of generating multiple worlds. World 2 of 2
name: ExampleFinder
game: ChecksFinder
@@ -344,6 +319,6 @@ ChecksFinder:
accessibility: items
```
The above example will generate 3 worlds - one Super Mario 64, one Minecraft, and one ChecksFinder.
The above example will generate 2 worlds - one Super Mario 64 and one ChecksFinder.

View File

@@ -27,73 +27,176 @@ requires:
plando: bosses, items, texts, connections
```
For a basic understanding of YAML files, refer to
[YAML Formatting](/tutorial/Archipelago/advanced_settings/en#yaml-formatting)
in Advanced Settings.
## Item Plando
Item plando allows a player to place an item in a specific location or specific locations, or place multiple items into a
list of specific locations both in their own game or in another player's game.
* The options for item plando are `from_pool`, `world`, `percentage`, `force`, `count`, and either `item` and
`location`, or `items` and `locations`.
* `from_pool` determines if the item should be taken *from* the item pool or *added* to it. This can be true or
false and defaults to true if omitted.
* `world` is the target world to place the item in.
* It gets ignored if only one world is generated.
* Can be a number, name, true, false, null, or a list. False is the default.
* If a number is used, it targets that slot or player number in the multiworld.
* If a name is used, it will target the world with that player name.
* If set to true, it will be any player's world besides your own.
* If set to false, it will target your own world.
* If set to null, it will target a random world in the multiworld.
* If a list of names is used, it will target the games with the player names specified.
* `force` determines whether the generator will fail if the item can't be placed in the location. Can be true, false,
or silent. Silent is the default.
* If set to true, the item must be placed and the generator will throw an error if it is unable to do so.
* If set to false, the generator will log a warning if the placement can't be done but will still generate.
* If set to silent and the placement fails, it will be ignored entirely.
* `percentage` is the percentage chance for the relevant block to trigger. This can be any value from 0 to 100 and
if omitted will default to 100.
* Single Placement is when you use a plando block to place a single item at a single location.
* `item` is the item you would like to place and `location` is the location to place it.
* Multi Placement uses a plando block to place multiple items in multiple locations until either list is exhausted.
* `items` defines the items to use, each with a number for the amount. Using `true` instead of a number uses however many of that item are in your item pool.
* `locations` is a list of possible locations those items can be placed in.
* Some special location group names can be specified:
* `early_locations` will add all sphere 1 locations (locations logically reachable only with your starting inventory)
* `non_early_locations` will add all locations beyond sphere 1 (locations that require finding at least one item before they become logically reachable)
* Using the multi placement method, placements are picked randomly.
Item Plando allows a player to place an item in a specific location or locations, or place multiple items into a list
of specific locations in their own game and/or in another player's game.
* `count` can be used to set the maximum number of items placed from the block. The default is 1 if using `item` and False if using `items`
* If a number is used, it will try to place this number of items.
* If set to false, it will try to place as many items from the block as it can.
* If `min` and `max` are defined, it will try to place a number of items between these two numbers at random.
To add item plando to your player yaml, you add them under the `plando_items` block. You should start with `item` if you
want to do Single Placement, or `items` if you want to do Multi Placement. A list of items can still be defined under
`item` but only one of them will be chosen at random to be used.
After you define `item/items`, you would define `location` or `locations`, depending on if you want to fill one
location or many. Note that both `location` and `locations` are optional. A list of locations can still be defined under
`location` but only one of them will be chosen at random to be used.
You may do any combination of `item/items` and `location/locations` in a plando block, but the block only places items
in locations **until the shorter of the two lists is used up.**
Once you are satisfied with your first block, you may continue to define ones under the same `plando_items` parent.
Each block can have several different options to tailor it the way you like.
* The `items` section defines the items to use. Each item name can be followed by a colon and a value.
* A numerical value indicates the amount of that item.
* A `true` value uses all copies of that item that are in your item pool.
* The `item` section defines a list of items to use, from which one will be chosen at random. Each item name can be
followed by a colon and a value. The value indicates the weight of that item being chosen.
* The `locations` section defines possible locations those items can be placed in. Two special location groups exist:
* `early_locations` will add all sphere 1 locations (locations logically reachable only with your starting
inventory).
* `non_early_locations` will add all locations beyond sphere 1 (locations that require finding at least one item
before they become logically reachable).
* `from_pool` determines if the item should be taken *from* the item pool or *created* from scratch.
* `false`: Create a new item with the same name (the world will determine its properties e.g. classification).
* `true`: Take the existing item, if it exists, from the item pool. If it does not exist, one will be created from
scratch. **(Default)**
* `world` is the target world to place the item in. It gets ignored if only one world is generated.
* **A number:** Use this slot or player number in the multiworld.
* **A name:** Use the world with that player name.
* **A list of names:** Use the worlds with the player names specified.
* `true`: Locations will be in any player's world besides your own.
* `false`: Locations will be in your own world. **(Default)**
* `null`: Locations will be in a random world in the multiworld.
* `force` determines whether the generator will fail if the plando block cannot be fulfilled.
* `true`: The generator will throw an error if it is unable to place an item.
* `false`: The generator will log a warning if it is unable to place an item, but it will still generate.
* `silent`: If the placement fails, it will be ignored entirely. **(Default)**
* `percentage` is the percentage chance for the block to trigger. This can be any integer from 0 to 100.
**(Default: 100)**
* `count` sets the number of items placed from the list.
* **Default: 1 if using `item` or `location`, and `false` otherwise.**
* **A number:** It will place this number of items.
* `false`: It will place as many items from the list as it can.
* **If `min` is defined,** it will place at least `min` many items (can be combined with `max`).
* **If `max` is defined,** it will place at most `max` many items (can be combined with `min`).
### Available Items and Locations
A list of all available items and locations can be found in the [website's datapackage](/datapackage). The items and locations will be in the `"item_name_to_id"` and `"location_name_to_id"` sections of the relevant game. You do not need the quotes but the name must be entered in the same as it appears on that page and is case-sensitive.
A list of all available items and locations can be found in the [website's datapackage](/datapackage). The items and
locations will be in the `"item_name_to_id"` and `"location_name_to_id"` sections of the relevant game. Names are
case-sensitive. You can also use item groups and location groups that are defined in the datapackage.
### Examples
## Item Plando Examples
```yaml
plando_items:
# Example block - Pokémon Red and Blue
- items:
Potion: 3
locations:
- "Route 1 - Free Sample Man"
- "Mt Moon 1F - West Item"
- "Mt Moon 1F - South Item"
```
This block will lock 3 Potion items on the Route 1 Pokémart employee and 2 Mt Moon items. Note these are all
Potions in the vanilla game. The world value has not been specified, so these locations must be in this player's own
world by default.
```yaml
plando_items:
# example block 1 - Timespinner
# Example block - A Link to the Past
- items:
Progressive Sword: 4
world:
- BobsWitness
- BobsRogueLegacy
count:
min: 1
max: 4
```
This block will attempt to place a random number, between 1 and 4, of Progressive Swords into any locations within the
game slots named "BobsWitness" and "BobsRogueLegacy."
```yaml
plando_items:
# Example block - Secret of Evermore
- items:
Levitate: 1
Revealer: 1
Energize: 1
locations:
- Master Sword Pedestal
- Desert Discard
world: true
count: 2
```
This block will choose 2 from the Levitate, Revealer, and Energize items at random and attempt to put them into the
locations named "Master Sword Pedestal" and "Desert Discard". Because the world value is `true`, these locations
must be in other players' worlds.
```yaml
plando_items:
# Example block - Timespinner
- item:
Empire Orb: 1
Radiant Orb: 1
Radiant Orb: 3
location: Starter Chest 1
from_pool: true
from_pool: false
world: true
percentage: 50
# example block 2 - Ocarina of Time
```
This block will place a single item, either the Empire Orb or Radiant Orb, on the location "Starter Chest 1". There is
a 25% chance it is Empire Orb, and 75% chance it is Radiant Orb (1 to 3 odds). The world value is `true`, so this
location must be in another player's world. Because the from_pool value is `false`, a copy of these items is added to
these locations, while the originals remain in the item pool to be shuffled. Unlike the previous examples, which will
always trigger, this block only has a 50% chance to trigger.
```yaml
plando_items:
# Example block - Factorio
- items:
progressive-electric-energy-distribution: 2
electric-energy-accumulators: 1
progressive-turret: 2
locations:
- AP-1-001
- AP-1-002
- AP-1-003
- AP-1-004
percentage: 80
force: true
from_pool: true
world: false
```
This block lists 5 items but only 4 locations, so it will place all but 1 of the items randomly among the locations
chosen here. This block has an 80% chance of occurring. Because force is `true`, the Generator will fail if it cannot
place one of the selected items (not including the fifth item). From_pool and World have been set to their default
values here, but they can be omitted and have the same result: items will be removed from the pool, and the locations
are in this player's own world.
**NOTE:** Factorio's locations are dynamically generated, so the locations listed above may not exist in your game,
they are here for demonstration only.
```yaml
plando_items:
# Example block - Ocarina of Time
- items:
Kokiri Sword: 1
Biggoron Sword: 1
Bow: 1
Magic Meter: 1
Progressive Strength Upgrade: 3
Progressive Hookshot: 2
locations:
- Deku Tree Slingshot Chest
- Dodongos Cavern Bomb Bag Chest
- Jabu Jabus Belly Boomerang Chest
- Bottom of the Well Lens of Truth Chest
@@ -102,53 +205,16 @@ A list of all available items and locations can be found in the [website's datap
- Water Temple Longshot Chest
- Shadow Temple Hover Boots Chest
- Spirit Temple Silver Gauntlets Chest
world: false
# example block 3 - Factorio
- items:
progressive-electric-energy-distribution: 2
electric-energy-accumulators: 1
progressive-turret: 2
locations:
- military
- gun-turret
- logistic-science-pack
- steel-processing
percentage: 80
force: true
# example block 4 - Secret of Evermore
- items:
Levitate: 1
Revealer: 1
Energize: 1
locations:
- Master Sword Pedestal
- Boss Relic 1
world: true
count: 2
# example block 5 - A Link to the Past
- items:
Progressive Sword: 4
world:
- BobsSlaytheSpire
- BobsRogueLegacy
count:
min: 1
max: 4
from_pool: false
- item: Kokiri Sword
location: Deku Tree Slingshot Chest
from_pool: false
```
1. This block has a 50% chance to occur, and if it does, it will place either the Empire Orb or Radiant Orb on another
player's Starter Chest 1 and removes the chosen item from the item pool.
2. This block will always trigger and will place the player's swords, bow, magic meter, strength upgrades, and hookshots
in their own dungeon major item chests.
3. This block has an 80% chance of occurring, and when it does, it will place all but 1 of the items randomly among the
four locations chosen here.
4. This block will always trigger and will attempt to place a random 2 of Levitate, Revealer and Energize into
other players' Master Sword Pedestals or Boss Relic 1 locations.
5. This block will always trigger and will attempt to place a random number, between 1 and 4, of progressive swords
into any locations within the game slots named BobsSlaytheSpire and BobsRogueLegacy.
The first block will place the player's Biggoron Sword, Bow, Magic Meter, strength upgrades, and hookshots in the
dungeon major item chests. Because the from_pool value is `false`, a copy of these items is added to these locations,
while the originals remain in the item pool to be shuffled. The second block will place the Kokiri Sword in the Deku
Tree Slingshot Chest, again not from the pool.
## Boss Plando
@@ -194,7 +260,7 @@ relevant guide: [A Link to the Past Plando Guide](/tutorial/A%20Link%20to%20the%
## Connection Plando
This is currently only supported by a few games, including A Link to the Past, Minecraft, and Ocarina of Time. As the way that these games interact with their
This is currently only supported by a few games, including A Link to the Past and Ocarina of Time. As the way that these games interact with their
connections is different, only the basics are explained here. More specific information for connection plando in A Link to the Past can be found in
its [plando guide](/tutorial/A%20Link%20to%20the%20Past/plando/en#connections).
@@ -207,7 +273,6 @@ its [plando guide](/tutorial/A%20Link%20to%20the%20Past/plando/en#connections).
[A Link to the Past connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/alttp/EntranceShuffle.py#L3852)
[Minecraft connections](https://github.com/ArchipelagoMW/Archipelago/blob/main/worlds/minecraft/data/regions.json#L18****)
### Examples
@@ -223,19 +288,10 @@ its [plando guide](/tutorial/A%20Link%20to%20the%20Past/plando/en#connections).
- entrance: Agahnims Tower
exit: Old Man Cave Exit (West)
direction: exit
# example block 2 - Minecraft
- entrance: Overworld Structure 1
exit: Nether Fortress
direction: both
- entrance: Overworld Structure 2
exit: Village
direction: both
```
1. These connections are decoupled, so going into the Lake Hylia Cave Shop will take you to the inside of Cave 45, and
when you leave the interior, you will exit to the Cave 45 ledge. Going into the Cave 45 entrance will then take you to
the Lake Hylia Cave Shop. Walking into the entrance for the Old Man Cave and Agahnim's Tower entrance will both take
you to their locations as normal, but leaving Old Man Cave will exit at Agahnim's Tower.
2. This will force a Nether fortress and a village to be the Overworld structures for your game. Note that for the
Minecraft connection plando to work structure shuffle must be enabled.

View File

@@ -0,0 +1,512 @@
# Python standard libraries
from collections import defaultdict
from math import ceil
from typing import Any, ClassVar, Callable, Union, cast
# Archipelago imports
import settings
from worlds.AutoWorld import World, WebWorld
from worlds.LauncherComponents import components, Component, launch_subprocess, Type, icon_paths
from BaseClasses import (Item,
ItemClassification as ItemClass,
Tutorial,
CollectionState)
from Options import OptionGroup
# Jak imports
from . import options
from .game_id import jak1_id, jak1_name, jak1_max
from .items import (JakAndDaxterItem,
OrbAssoc,
item_table,
cell_item_table,
scout_item_table,
special_item_table,
move_item_table,
orb_item_table,
trap_item_table)
from .levels import level_table, level_table_with_global
from .locations import (JakAndDaxterLocation,
location_table,
cell_location_table,
scout_location_table,
special_location_table,
cache_location_table,
orb_location_table)
from .regions import create_regions
from .rules import (enforce_mp_absolute_limits,
enforce_mp_friendly_limits,
enforce_sp_limits,
set_orb_trade_rule)
from .locs import (cell_locations as cells,
scout_locations as scouts,
special_locations as specials,
orb_cache_locations as caches,
orb_locations as orbs)
from .regs.region_base import JakAndDaxterRegion
def launch_client():
from . import client
launch_subprocess(client.launch, name="JakAndDaxterClient")
components.append(Component("Jak and Daxter Client",
func=launch_client,
component_type=Type.CLIENT,
icon="precursor_orb"))
icon_paths["precursor_orb"] = f"ap:{__name__}/icons/precursor_orb.png"
class JakAndDaxterSettings(settings.Group):
class RootDirectory(settings.UserFolderPath):
"""Path to folder containing the ArchipelaGOAL mod executables (gk.exe and goalc.exe).
Ensure this path contains forward slashes (/) only. This setting only applies if
Auto Detect Root Directory is set to false."""
description = "ArchipelaGOAL Root Directory"
class AutoDetectRootDirectory(settings.Bool):
"""Attempt to find the OpenGOAL installation and the mod executables (gk.exe and goalc.exe)
automatically. If set to true, the ArchipelaGOAL Root Directory setting is ignored."""
description = "ArchipelaGOAL Auto Detect Root Directory"
class EnforceFriendlyOptions(settings.Bool):
"""Enforce friendly player options in both single and multiplayer seeds. Disabling this allows for
more disruptive and challenging options, but may impact seed generation. Use at your own risk!"""
description = "ArchipelaGOAL Enforce Friendly Options"
root_directory: RootDirectory = RootDirectory(
"%programfiles%/OpenGOAL-Launcher/features/jak1/mods/JakMods/archipelagoal")
# Don't ever change these type hints again.
auto_detect_root_directory: Union[AutoDetectRootDirectory, bool] = True
enforce_friendly_options: Union[EnforceFriendlyOptions, bool] = True
class JakAndDaxterWebWorld(WebWorld):
setup_en = Tutorial(
"Multiworld Setup Guide",
"A guide to setting up ArchipelaGOAL (Archipelago on OpenGOAL).",
"English",
"setup_en.md",
"setup/en",
["markustulliuscicero"]
)
tutorials = [setup_en]
bug_report_page = "https://github.com/ArchipelaGOAL/Archipelago/issues"
option_groups = [
OptionGroup("Orbsanity", [
options.EnableOrbsanity,
options.GlobalOrbsanityBundleSize,
options.PerLevelOrbsanityBundleSize,
]),
OptionGroup("Power Cell Counts", [
options.EnableOrderedCellCounts,
options.FireCanyonCellCount,
options.MountainPassCellCount,
options.LavaTubeCellCount,
]),
OptionGroup("Orb Trade Counts", [
options.CitizenOrbTradeAmount,
options.OracleOrbTradeAmount,
]),
OptionGroup("Traps", [
options.FillerPowerCellsReplacedWithTraps,
options.FillerOrbBundlesReplacedWithTraps,
options.TrapEffectDuration,
options.TrapWeights,
]),
]
class JakAndDaxterWorld(World):
"""
Jak and Daxter: The Precursor Legacy is a 2001 action platformer developed by Naughty Dog
for the PlayStation 2. The game follows the eponymous protagonists, a young boy named Jak
and his friend Daxter, who has been transformed into an ottsel. With the help of Samos
the Sage of Green Eco and his daughter Keira, the pair travel north in search of a cure for Daxter,
discovering artifacts created by an ancient race known as the Precursors along the way. When the
rogue sages Gol and Maia Acheron plan to flood the world with Dark Eco, they must stop their evil plan
and save the world.
"""
# ID, name, version
game = jak1_name
required_client_version = (0, 5, 0)
# Options
settings: ClassVar[JakAndDaxterSettings]
options_dataclass = options.JakAndDaxterOptions
options: options.JakAndDaxterOptions
# Web world
web = JakAndDaxterWebWorld()
# Stored as {ID: Name} pairs, these must now be swapped to {Name: ID} pairs.
# Remember, the game ID and various offsets for each item type have already been calculated.
item_name_to_id = {name: k for k, name in item_table.items()}
location_name_to_id = {name: k for k, name in location_table.items()}
item_name_groups = {
"Power Cells": set(cell_item_table.values()),
"Scout Flies": set(scout_item_table.values()),
"Specials": set(special_item_table.values()),
"Moves": set(move_item_table.values()),
"Precursor Orbs": set(orb_item_table.values()),
"Traps": set(trap_item_table.values()),
}
location_name_groups = {
"Power Cells": set(cell_location_table.values()),
"Power Cells - GR": set(cells.locGR_cellTable.values()),
"Power Cells - SV": set(cells.locSV_cellTable.values()),
"Power Cells - FJ": set(cells.locFJ_cellTable.values()),
"Power Cells - SB": set(cells.locSB_cellTable.values()),
"Power Cells - MI": set(cells.locMI_cellTable.values()),
"Power Cells - FC": set(cells.locFC_cellTable.values()),
"Power Cells - RV": set(cells.locRV_cellTable.values()),
"Power Cells - PB": set(cells.locPB_cellTable.values()),
"Power Cells - LPC": set(cells.locLPC_cellTable.values()),
"Power Cells - BS": set(cells.locBS_cellTable.values()),
"Power Cells - MP": set(cells.locMP_cellTable.values()),
"Power Cells - VC": set(cells.locVC_cellTable.values()),
"Power Cells - SC": set(cells.locSC_cellTable.values()),
"Power Cells - SM": set(cells.locSM_cellTable.values()),
"Power Cells - LT": set(cells.locLT_cellTable.values()),
"Power Cells - GMC": set(cells.locGMC_cellTable.values()),
"Scout Flies": set(scout_location_table.values()),
"Scout Flies - GR": set(scouts.locGR_scoutTable.values()),
"Scout Flies - SV": set(scouts.locSV_scoutTable.values()),
"Scout Flies - FJ": set(scouts.locFJ_scoutTable.values()),
"Scout Flies - SB": set(scouts.locSB_scoutTable.values()),
"Scout Flies - MI": set(scouts.locMI_scoutTable.values()),
"Scout Flies - FC": set(scouts.locFC_scoutTable.values()),
"Scout Flies - RV": set(scouts.locRV_scoutTable.values()),
"Scout Flies - PB": set(scouts.locPB_scoutTable.values()),
"Scout Flies - LPC": set(scouts.locLPC_scoutTable.values()),
"Scout Flies - BS": set(scouts.locBS_scoutTable.values()),
"Scout Flies - MP": set(scouts.locMP_scoutTable.values()),
"Scout Flies - VC": set(scouts.locVC_scoutTable.values()),
"Scout Flies - SC": set(scouts.locSC_scoutTable.values()),
"Scout Flies - SM": set(scouts.locSM_scoutTable.values()),
"Scout Flies - LT": set(scouts.locLT_scoutTable.values()),
"Scout Flies - GMC": set(scouts.locGMC_scoutTable.values()),
"Specials": set(special_location_table.values()),
"Orb Caches": set(cache_location_table.values()),
"Precursor Orbs": set(orb_location_table.values()),
"Precursor Orbs - GR": set(orbs.locGR_orbBundleTable.values()),
"Precursor Orbs - SV": set(orbs.locSV_orbBundleTable.values()),
"Precursor Orbs - FJ": set(orbs.locFJ_orbBundleTable.values()),
"Precursor Orbs - SB": set(orbs.locSB_orbBundleTable.values()),
"Precursor Orbs - MI": set(orbs.locMI_orbBundleTable.values()),
"Precursor Orbs - FC": set(orbs.locFC_orbBundleTable.values()),
"Precursor Orbs - RV": set(orbs.locRV_orbBundleTable.values()),
"Precursor Orbs - PB": set(orbs.locPB_orbBundleTable.values()),
"Precursor Orbs - LPC": set(orbs.locLPC_orbBundleTable.values()),
"Precursor Orbs - BS": set(orbs.locBS_orbBundleTable.values()),
"Precursor Orbs - MP": set(orbs.locMP_orbBundleTable.values()),
"Precursor Orbs - VC": set(orbs.locVC_orbBundleTable.values()),
"Precursor Orbs - SC": set(orbs.locSC_orbBundleTable.values()),
"Precursor Orbs - SM": set(orbs.locSM_orbBundleTable.values()),
"Precursor Orbs - LT": set(orbs.locLT_orbBundleTable.values()),
"Precursor Orbs - GMC": set(orbs.locGMC_orbBundleTable.values()),
"Trades": {location_table[cells.to_ap_id(k)] for k in
{11, 12, 31, 32, 33, 96, 97, 98, 99, 13, 14, 34, 35, 100, 101}},
"'Free 7 Scout Flies' Power Cells": set(cells.loc7SF_cellTable.values()),
}
# These functions and variables are Options-driven, keep them as instance variables here so that we don't clog up
# the seed generation routines with options checking. So we set these once, and then just use them as needed.
can_trade: Callable[[CollectionState, int, int | None], bool]
total_orbs: int = 2000
orb_bundle_item_name: str = ""
orb_bundle_size: int = 0
total_trade_orbs: int = 0
total_prog_orb_bundles: int = 0
total_trap_orb_bundles: int = 0
total_filler_orb_bundles: int = 0
total_power_cells: int = 101
total_prog_cells: int = 0
total_trap_cells: int = 0
total_filler_cells: int = 0
power_cell_thresholds: list[int]
power_cell_thresholds_minus_one: list[int]
trap_weights: tuple[list[str], list[int]]
# Store these dictionaries for speed improvements.
level_to_regions: dict[str, list[JakAndDaxterRegion]] # Contains all levels and regions.
level_to_orb_regions: dict[str, list[JakAndDaxterRegion]] # Contains only regions which contain orbs.
# Handles various options validation, rules enforcement, and caching of important information.
def generate_early(self) -> None:
# Initialize the level-region dictionary.
self.level_to_regions = defaultdict(list)
self.level_to_orb_regions = defaultdict(list)
# Cache the power cell threshold values for quicker reference.
self.power_cell_thresholds = [
self.options.fire_canyon_cell_count.value,
self.options.mountain_pass_cell_count.value,
self.options.lava_tube_cell_count.value,
100, # The 100 Power Cell Door.
]
# Order the thresholds ascending and set the options values to the new order.
if self.options.enable_ordered_cell_counts:
self.power_cell_thresholds.sort()
self.options.fire_canyon_cell_count.value = self.power_cell_thresholds[0]
self.options.mountain_pass_cell_count.value = self.power_cell_thresholds[1]
self.options.lava_tube_cell_count.value = self.power_cell_thresholds[2]
# We would have done this earlier, but we needed to sort the power cell thresholds first. Don't worry, we'll
# come back to them.
enforce_friendly_options = self.settings.enforce_friendly_options
if self.multiworld.players == 1:
# For singleplayer games, always enforce/clamp the cell counts to valid values.
enforce_sp_limits(self)
else:
if enforce_friendly_options:
# For multiplayer games, we have a host setting to make options fair/sane for other players.
# If this setting is enabled, enforce/clamp some friendly limitations on our options.
enforce_mp_friendly_limits(self)
else:
# Even if the setting is disabled, some values must be clamped to avoid generation errors.
enforce_mp_absolute_limits(self)
# That's right, set the collection of thresholds again. Don't just clamp the values without updating this list!
self.power_cell_thresholds = [
self.options.fire_canyon_cell_count.value,
self.options.mountain_pass_cell_count.value,
self.options.lava_tube_cell_count.value,
100, # The 100 Power Cell Door.
]
# Now that the threshold list is finalized, store this for the remove function.
self.power_cell_thresholds_minus_one = [x - 1 for x in self.power_cell_thresholds]
# Calculate the number of power cells needed for full region access, the number being replaced by traps,
# and the number of remaining filler.
if self.options.jak_completion_condition == options.CompletionCondition.option_open_100_cell_door:
self.total_prog_cells = 100
else:
self.total_prog_cells = max(self.power_cell_thresholds[:3])
non_prog_cells = self.total_power_cells - self.total_prog_cells
self.total_trap_cells = min(self.options.filler_power_cells_replaced_with_traps.value, non_prog_cells)
self.options.filler_power_cells_replaced_with_traps.value = self.total_trap_cells
self.total_filler_cells = non_prog_cells - self.total_trap_cells
# Cache the orb bundle size and item name for quicker reference.
if self.options.enable_orbsanity == options.EnableOrbsanity.option_per_level:
self.orb_bundle_size = self.options.level_orbsanity_bundle_size.value
self.orb_bundle_item_name = orb_item_table[self.orb_bundle_size]
elif self.options.enable_orbsanity == options.EnableOrbsanity.option_global:
self.orb_bundle_size = self.options.global_orbsanity_bundle_size.value
self.orb_bundle_item_name = orb_item_table[self.orb_bundle_size]
else:
self.orb_bundle_size = 0
self.orb_bundle_item_name = ""
# Calculate the number of orb bundles needed for trades, the number being replaced by traps,
# and the number of remaining filler. If Orbsanity is off, default values of 0 will prevail for all.
if self.orb_bundle_size > 0:
total_orb_bundles = self.total_orbs // self.orb_bundle_size
self.total_prog_orb_bundles = ceil(self.total_trade_orbs / self.orb_bundle_size)
non_prog_orb_bundles = total_orb_bundles - self.total_prog_orb_bundles
self.total_trap_orb_bundles = min(self.options.filler_orb_bundles_replaced_with_traps.value,
non_prog_orb_bundles)
self.options.filler_orb_bundles_replaced_with_traps.value = self.total_trap_orb_bundles
self.total_filler_orb_bundles = non_prog_orb_bundles - self.total_trap_orb_bundles
else:
self.options.filler_orb_bundles_replaced_with_traps.value = 0
self.trap_weights = self.options.trap_weights.weights_pair
# Options drive which trade rules to use, so they need to be setup before we create_regions.
set_orb_trade_rule(self)
# This will also set Locations, Location access rules, Region access rules, etc.
def create_regions(self) -> None:
create_regions(self)
# Don't forget to add the created regions to the multiworld!
for level in self.level_to_regions:
self.multiworld.regions.extend(self.level_to_regions[level])
# As a lazy measure, let's also fill level_to_orb_regions here.
# This should help speed up orbsanity calculations.
self.level_to_orb_regions[level] = [reg for reg in self.level_to_regions[level] if reg.orb_count > 0]
# from Utils import visualize_regions
# visualize_regions(self.multiworld.get_region("Menu", self.player), "jakanddaxter.puml")
def item_data_helper(self, item: int) -> list[tuple[int, ItemClass, OrbAssoc, int]]:
"""
Helper function to reuse some nasty if/else trees. This outputs a list of pairs of item count and class.
For instance, not all 101 power cells need to be marked progression if you only need 72 to beat the game.
So we will have 72 Progression Power Cells, and 29 Filler Power Cells.
"""
data: list[tuple[int, ItemClass, OrbAssoc, int]] = []
# Make N Power Cells. We only want AP's Progression Fill routine to handle the amount of cells we need
# to reach the furthest possible region. Even for early completion goals, all areas in the game must be
# reachable or generation will fail. TODO - Option-driven region creation would be an enormous refactor.
if item in range(jak1_id, jak1_id + scouts.fly_offset):
data.append((self.total_prog_cells, ItemClass.progression_skip_balancing, OrbAssoc.IS_POWER_CELL, 0))
data.append((self.total_filler_cells, ItemClass.filler, OrbAssoc.IS_POWER_CELL, 0))
# Make 7 Scout Flies per level.
elif item in range(jak1_id + scouts.fly_offset, jak1_id + specials.special_offset):
data.append((7, ItemClass.progression_skip_balancing, OrbAssoc.NEVER_UNLOCKS_ORBS, 0))
# Make only 1 of each Special Item.
elif item in range(jak1_id + specials.special_offset, jak1_id + caches.orb_cache_offset):
data.append((1, ItemClass.progression | ItemClass.useful, OrbAssoc.ALWAYS_UNLOCKS_ORBS, 0))
# Make only 1 of each Move Item.
elif item in range(jak1_id + caches.orb_cache_offset, jak1_id + orbs.orb_offset):
data.append((1, ItemClass.progression | ItemClass.useful, OrbAssoc.ALWAYS_UNLOCKS_ORBS, 0))
# Make N Precursor Orb bundles. Like Power Cells, only a fraction of these will be marked as Progression
# with the remainder as Filler, but they are still entirely fungible. See collect function for why these
# are OrbAssoc.NEVER_UNLOCKS_ORBS.
elif item in range(jak1_id + orbs.orb_offset, jak1_max - max(trap_item_table)):
data.append((self.total_prog_orb_bundles, ItemClass.progression_skip_balancing,
OrbAssoc.NEVER_UNLOCKS_ORBS, self.orb_bundle_size))
data.append((self.total_filler_orb_bundles, ItemClass.filler,
OrbAssoc.NEVER_UNLOCKS_ORBS, self.orb_bundle_size))
# We will manually create trap items as needed.
elif item in range(jak1_max - max(trap_item_table), jak1_max):
data.append((0, ItemClass.trap, OrbAssoc.NEVER_UNLOCKS_ORBS, 0))
# We will manually create filler items as needed.
elif item == jak1_max:
data.append((0, ItemClass.filler, OrbAssoc.NEVER_UNLOCKS_ORBS, 0))
# If we try to make items with ID's higher than we've defined, something has gone wrong.
else:
raise KeyError(f"Tried to fill item pool with unknown ID {item}.")
return data
def create_items(self) -> None:
items_made: int = 0
for item_name in self.item_name_to_id:
item_id = self.item_name_to_id[item_name]
# Handle Move Randomizer option.
# If it is OFF, put all moves in your starting inventory instead of the item pool,
# then fill the item pool with a corresponding amount of filler items.
if item_name in self.item_name_groups["Moves"] and not self.options.enable_move_randomizer:
self.multiworld.push_precollected(self.create_item(item_name))
self.multiworld.itempool.append(self.create_filler())
items_made += 1
continue
# Handle Orbsanity option.
# If it is OFF, don't add any orb bundles to the item pool, period.
# If it is ON, don't add any orb bundles that don't match the chosen option.
if (item_name in self.item_name_groups["Precursor Orbs"]
and (self.options.enable_orbsanity == options.EnableOrbsanity.option_off
or item_name != self.orb_bundle_item_name)):
continue
# Skip Traps for now.
if item_name in self.item_name_groups["Traps"]:
continue
# In almost every other scenario, do this. Not all items with the same name will have the same item class.
data = self.item_data_helper(item_id)
for (count, classification, orb_assoc, orb_amount) in data:
self.multiworld.itempool += [JakAndDaxterItem(item_name, classification, item_id,
self.player, orb_assoc, orb_amount)
for _ in range(count)]
items_made += count
# Handle Traps (for real).
# Manually fill the item pool with a weighted assortment of trap items, equal to the sum of
# total_trap_cells + total_trap_orb_bundles. Only do this if one or more traps have weights > 0.
names, weights = self.trap_weights
if sum(weights):
total_traps = self.total_trap_cells + self.total_trap_orb_bundles
trap_list = self.random.choices(names, weights=weights, k=total_traps)
self.multiworld.itempool += [self.create_item(trap_name) for trap_name in trap_list]
items_made += total_traps
# Handle Unfilled Locations.
# Add an amount of filler items equal to the number of locations yet to be filled.
# This is the final set of items we will add to the pool.
all_regions = self.multiworld.get_regions(self.player)
total_locations = sum(reg.location_count for reg in cast(list[JakAndDaxterRegion], all_regions))
total_filler = total_locations - items_made
self.multiworld.itempool += [self.create_filler() for _ in range(total_filler)]
def create_item(self, name: str) -> Item:
item_id = self.item_name_to_id[name]
# Use first tuple (will likely be the most important).
_, classification, orb_assoc, orb_amount = self.item_data_helper(item_id)[0]
return JakAndDaxterItem(name, classification, item_id, self.player, orb_assoc, orb_amount)
def get_filler_item_name(self) -> str:
return "Green Eco Pill"
def collect(self, state: CollectionState, item: JakAndDaxterItem) -> bool:
change = super().collect(state, item)
if change:
# Orbsanity as an option is no-factor to these conditions. Matching the item name implies Orbsanity is ON,
# so we don't need to check the option. When Orbsanity is OFF, there won't even be any orb bundle items
# to collect.
# Orb items do not intrinsically unlock anything that contains more Reachable Orbs, so they do not need to
# set the cache to stale. They just change how many orbs you have to trade with.
if item.orb_amount > 0:
state.prog_items[self.player]["Tradeable Orbs"] += self.orb_bundle_size # Give a bundle of Trade Orbs
# Power Cells DO unlock new regions that contain more Reachable Orbs - the connector levels and new
# hub levels - BUT they only do that when you have a number of them equal to one of the threshold values.
elif (item.orb_assoc == OrbAssoc.ALWAYS_UNLOCKS_ORBS
or (item.orb_assoc == OrbAssoc.IS_POWER_CELL
and state.count("Power Cell", self.player) in self.power_cell_thresholds)):
state.prog_items[self.player]["Reachable Orbs Fresh"] = False
# However, every other item that does not have an appropriate OrbAssoc that changes the CollectionState
# should NOT set the cache to stale, because they did not make it possible to reach more orb locations
# (level unlocks, region unlocks, etc.).
return change
def remove(self, state: CollectionState, item: JakAndDaxterItem) -> bool:
change = super().remove(state, item)
if change:
# Do the same thing we did in collect, except subtract trade orbs instead of add.
if item.orb_amount > 0:
state.prog_items[self.player]["Tradeable Orbs"] -= self.orb_bundle_size # Take a bundle of Trade Orbs
# Ditto Power Cells, but check thresholds - 1, because we potentially crossed the threshold in the opposite
# direction. E.g. we've removed the 20th power cell, our count is now 19, so we should stale the cache.
elif (item.orb_assoc == OrbAssoc.ALWAYS_UNLOCKS_ORBS
or (item.orb_assoc == OrbAssoc.IS_POWER_CELL
and state.count("Power Cell", self.player) in self.power_cell_thresholds_minus_one)):
state.prog_items[self.player]["Reachable Orbs Fresh"] = False
return change
def fill_slot_data(self) -> dict[str, Any]:
options_dict = self.options.as_dict("enable_move_randomizer",
"enable_orbsanity",
"global_orbsanity_bundle_size",
"level_orbsanity_bundle_size",
"fire_canyon_cell_count",
"mountain_pass_cell_count",
"lava_tube_cell_count",
"citizen_orb_trade_amount",
"oracle_orb_trade_amount",
"filler_power_cells_replaced_with_traps",
"filler_orb_bundles_replaced_with_traps",
"trap_effect_duration",
"trap_weights",
"jak_completion_condition",
"require_punch_for_klaww",
)
return options_dict

View File

View File

@@ -0,0 +1,489 @@
import logging
import random
import struct
from typing import ByteString, Callable
import json
import pymem
from pymem import pattern
from pymem.exception import ProcessNotFound, ProcessError, MemoryReadError, WinAPIError
from dataclasses import dataclass
from ..locs import (orb_locations as orbs,
cell_locations as cells,
scout_locations as flies,
special_locations as specials,
orb_cache_locations as caches)
logger = logging.getLogger("MemoryReader")
# Some helpful constants.
sizeof_uint64 = 8
sizeof_uint32 = 4
sizeof_uint8 = 1
sizeof_float = 4
# *****************************************************************************
# **** This number must match (-> *ap-info-jak1* version) in ap-struct.gc! ****
# *****************************************************************************
expected_memory_version = 5
# IMPORTANT: OpenGOAL memory structures are particular about the alignment, in memory, of member elements according to
# their size in bits. The address for an N-bit field must be divisible by N. Use this class to define the memory offsets
# of important values in the struct. It will also do the byte alignment properly for you.
# See https://opengoal.dev/docs/reference/type_system/#arrays
@dataclass
class OffsetFactory:
current_offset: int = 0
def define(self, size: int, length: int = 1) -> int:
# If necessary, align current_offset to the current size first.
bytes_to_alignment = self.current_offset % size
if bytes_to_alignment != 0:
self.current_offset += (size - bytes_to_alignment)
# Increment current_offset so the next definition can be made.
offset_to_use = self.current_offset
self.current_offset += (size * length)
return offset_to_use
# Start defining important memory address offsets here. They must be in the same order, have the same sizes, and have
# the same lengths, as defined in `ap-info-jak1`.
offsets = OffsetFactory()
# Cell, Buzzer, and Special information.
next_cell_index_offset = offsets.define(sizeof_uint64)
next_buzzer_index_offset = offsets.define(sizeof_uint64)
next_special_index_offset = offsets.define(sizeof_uint64)
cells_checked_offset = offsets.define(sizeof_uint32, 101)
buzzers_checked_offset = offsets.define(sizeof_uint32, 112)
specials_checked_offset = offsets.define(sizeof_uint32, 32)
buzzers_received_offset = offsets.define(sizeof_uint8, 16)
specials_received_offset = offsets.define(sizeof_uint8, 32)
# Deathlink information.
death_count_offset = offsets.define(sizeof_uint32)
death_cause_offset = offsets.define(sizeof_uint8)
deathlink_enabled_offset = offsets.define(sizeof_uint8)
# Move Rando information.
next_orb_cache_index_offset = offsets.define(sizeof_uint64)
orb_caches_checked_offset = offsets.define(sizeof_uint32, 16)
moves_received_offset = offsets.define(sizeof_uint8, 16)
moverando_enabled_offset = offsets.define(sizeof_uint8)
# Orbsanity information.
orbsanity_option_offset = offsets.define(sizeof_uint8)
orbsanity_bundle_offset = offsets.define(sizeof_uint32)
collected_bundle_offset = offsets.define(sizeof_uint32, 17)
# Progression and Completion information.
fire_canyon_unlock_offset = offsets.define(sizeof_float)
mountain_pass_unlock_offset = offsets.define(sizeof_float)
lava_tube_unlock_offset = offsets.define(sizeof_float)
citizen_orb_amount_offset = offsets.define(sizeof_float)
oracle_orb_amount_offset = offsets.define(sizeof_float)
completion_goal_offset = offsets.define(sizeof_uint8)
completed_offset = offsets.define(sizeof_uint8)
# Text to display in the HUD (32 char max per string).
their_item_name_offset = offsets.define(sizeof_uint8, 32)
their_item_owner_offset = offsets.define(sizeof_uint8, 32)
my_item_name_offset = offsets.define(sizeof_uint8, 32)
my_item_finder_offset = offsets.define(sizeof_uint8, 32)
# Version of the memory struct, to cut down on mod/apworld version mismatches.
memory_version_offset = offsets.define(sizeof_uint32)
# Connection status to AP server (not the game!)
server_connection_offset = offsets.define(sizeof_uint8)
slot_name_offset = offsets.define(sizeof_uint8, 16)
slot_seed_offset = offsets.define(sizeof_uint8, 8)
# Trap information.
trap_duration_offset = offsets.define(sizeof_float)
# The End.
end_marker_offset = offsets.define(sizeof_uint8, 4)
# Can't believe this is easier to do in GOAL than Python but that's how it be sometimes.
def as_float(value: int) -> int:
return int(struct.unpack('f', value.to_bytes(sizeof_float, "little"))[0])
# "Jak" to be replaced by player name in the Client.
def autopsy(cause: int) -> str:
if cause in [1, 2, 3, 4]:
return random.choice(["Jak said goodnight.",
"Jak stepped into the light.",
"Jak gave Daxter his insect collection.",
"Jak did not follow Step 1."])
if cause == 5:
return "Jak fell into an endless pit."
if cause == 6:
return "Jak drowned in the spicy water."
if cause == 7:
return "Jak tried to tackle a Lurker Shark."
if cause == 8:
return "Jak hit 500 degrees."
if cause == 9:
return "Jak took a bath in a pool of dark eco."
if cause == 10:
return "Jak got bombarded with flaming 30-ton boulders."
if cause == 11:
return "Jak hit 800 degrees."
if cause == 12:
return "Jak ceased to be."
if cause == 13:
return "Jak got eaten by the dark eco plant."
if cause == 14:
return "Jak burned up."
if cause == 15:
return "Jak hit the ground hard."
if cause == 16:
return "Jak crashed the zoomer."
if cause == 17:
return "Jak got Flut Flut hurt."
if cause == 18:
return "Jak poisoned the whole darn catch."
if cause == 19:
return "Jak collided with too many obstacles."
return "Jak died."
class JakAndDaxterMemoryReader:
marker: ByteString
goal_address: int | None = None
connected: bool = False
initiated_connect: bool = False
# The memory reader just needs the game running.
gk_process: pymem.process = None
location_outbox: list[int] = []
outbox_index: int = 0
finished_game: bool = False
# Deathlink handling
deathlink_enabled: bool = False
send_deathlink: bool = False
cause_of_death: str = ""
death_count: int = 0
# Orbsanity handling
orbsanity_enabled: bool = False
orbs_paid: int = 0
# Game-related callbacks (inform the AP server of changes to game state)
inform_checked_location: Callable
inform_finished_game: Callable
inform_died: Callable
inform_toggled_deathlink: Callable
inform_traded_orbs: Callable
# Logging callbacks
# These will write to the provided logger, as well as the Client GUI with color markup.
log_error: Callable # Red
log_warn: Callable # Orange
log_success: Callable # Green
log_info: Callable # White (default)
def __init__(self,
location_check_callback: Callable,
finish_game_callback: Callable,
send_deathlink_callback: Callable,
toggle_deathlink_callback: Callable,
orb_trade_callback: Callable,
log_error_callback: Callable,
log_warn_callback: Callable,
log_success_callback: Callable,
log_info_callback: Callable,
marker: ByteString = b'UnLiStEdStRaTs_JaK1\x00'):
self.marker = marker
self.inform_checked_location = location_check_callback
self.inform_finished_game = finish_game_callback
self.inform_died = send_deathlink_callback
self.inform_toggled_deathlink = toggle_deathlink_callback
self.inform_traded_orbs = orb_trade_callback
self.log_error = log_error_callback
self.log_warn = log_warn_callback
self.log_success = log_success_callback
self.log_info = log_info_callback
async def main_tick(self):
if self.initiated_connect:
await self.connect()
self.initiated_connect = False
if self.connected:
try:
self.gk_process.read_bool(self.gk_process.base_address) # Ping to see if it's alive.
except (ProcessError, MemoryReadError, WinAPIError):
msg = (f"Error reading game memory! (Did the game crash?)\n"
f"Please close all open windows and reopen the Jak and Daxter Client "
f"from the Archipelago Launcher.\n"
f"If the game and compiler do not restart automatically, please follow these steps:\n"
f" Run the OpenGOAL Launcher, click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n"
f" Then click Advanced > Play in Debug Mode.\n"
f" Then click Advanced > Open REPL.\n"
f" Then close and reopen the Jak and Daxter Client from the Archipelago Launcher.")
self.log_error(logger, msg)
self.connected = False
else:
return
if self.connected:
# Save some state variables temporarily.
old_deathlink_enabled = self.deathlink_enabled
# Read the memory address to check the state of the game.
self.read_memory()
# Checked Locations in game. Handle the entire outbox every tick until we're up to speed.
if len(self.location_outbox) > self.outbox_index:
self.inform_checked_location(self.location_outbox)
self.save_data()
self.outbox_index += 1
if self.finished_game:
self.inform_finished_game()
if old_deathlink_enabled != self.deathlink_enabled:
self.inform_toggled_deathlink()
logger.debug("Toggled DeathLink " + ("ON" if self.deathlink_enabled else "OFF"))
if self.send_deathlink:
self.inform_died()
if self.orbs_paid > 0:
self.inform_traded_orbs(self.orbs_paid)
self.orbs_paid = 0
async def connect(self):
try:
self.gk_process = pymem.Pymem("gk.exe") # The GOAL Kernel
logger.debug("Found the gk process: " + str(self.gk_process.process_id))
except ProcessNotFound:
self.log_error(logger, "Could not find the game process.")
self.connected = False
return
# If we don't find the marker in the first loaded module, we've failed.
modules = list(self.gk_process.list_modules())
marker_address = pattern.pattern_scan_module(self.gk_process.process_handle, modules[0], self.marker)
if marker_address:
# At this address is another address that contains the struct we're looking for: the game's state.
# From here we need to add the length in bytes for the marker and 4 bytes of padding,
# and the struct address is 8 bytes long (it's an uint64).
goal_pointer = marker_address + len(self.marker) + 4
self.goal_address = int.from_bytes(self.gk_process.read_bytes(goal_pointer, sizeof_uint64),
byteorder="little",
signed=False)
logger.debug("Found the archipelago memory address: " + str(self.goal_address))
await self.verify_memory_version()
else:
self.log_error(logger, "Could not find the Archipelago marker address!")
self.connected = False
async def verify_memory_version(self):
if self.goal_address is None:
self.log_error(logger, "Could not find the Archipelago memory address!")
self.connected = False
return
memory_version: int | None = None
try:
memory_version = self.read_goal_address(memory_version_offset, sizeof_uint32)
if memory_version == expected_memory_version:
self.log_success(logger, "The Memory Reader is ready!")
self.connected = True
else:
raise MemoryReadError(memory_version_offset, sizeof_uint32)
except (ProcessError, MemoryReadError, WinAPIError):
if memory_version is None:
msg = (f"Could not find a version number in the OpenGOAL memory structure!\n"
f" Expected Version: {str(expected_memory_version)}\n"
f" Found Version: {str(memory_version)}\n"
f"Please follow these steps:\n"
f" If the game is running, try entering '/memr connect' in the client.\n"
f" You should see 'The Memory Reader is ready!'\n"
f" If that did not work, or the game is not running, run the OpenGOAL Launcher.\n"
f" Click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n"
f" Then click Advanced > Play in Debug Mode.\n"
f" Try entering '/memr connect' in the client again.")
else:
msg = (f"The OpenGOAL memory structure is incompatible with the current Archipelago client!\n"
f" Expected Version: {str(expected_memory_version)}\n"
f" Found Version: {str(memory_version)}\n"
f"Please follow these steps:\n"
f" Run the OpenGOAL Launcher, click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n"
f" Click Update (if one is available).\n"
f" Click Advanced > Compile. When this is done, click Continue.\n"
f" Click Versions and verify the latest version is marked 'Active'.\n"
f" Close all launchers, games, clients, and console windows, then restart Archipelago.")
self.log_error(logger, msg)
self.connected = False
async def print_status(self):
proc_id = str(self.gk_process.process_id) if self.gk_process else "None"
last_loc = str(self.location_outbox[self.outbox_index - 1] if self.outbox_index else "None")
msg = (f"Memory Reader Status:\n"
f" Game process ID: {proc_id}\n"
f" Game state memory address: {str(self.goal_address)}\n"
f" Last location checked: {last_loc}")
await self.verify_memory_version()
self.log_info(logger, msg)
def read_memory(self) -> list[int]:
try:
# Need to grab these first and convert to floats, see below.
citizen_orb_amount = self.read_goal_address(citizen_orb_amount_offset, sizeof_float)
oracle_orb_amount = self.read_goal_address(oracle_orb_amount_offset, sizeof_float)
next_cell_index = self.read_goal_address(next_cell_index_offset, sizeof_uint64)
for k in range(0, next_cell_index):
next_cell = self.read_goal_address(cells_checked_offset + (k * sizeof_uint32), sizeof_uint32)
cell_ap_id = cells.to_ap_id(next_cell)
if cell_ap_id not in self.location_outbox:
self.location_outbox.append(cell_ap_id)
logger.debug("Checked power cell: " + str(next_cell))
# If orbsanity is ON and next_cell is one of the traders or oracles, then run a callback
# to add their amount to the DataStorage value holding our current orb trade total.
if next_cell in {11, 12, 31, 32, 33, 96, 97, 98, 99}:
citizen_orb_amount = as_float(citizen_orb_amount)
self.orbs_paid += citizen_orb_amount
logger.debug(f"Traded {citizen_orb_amount} orbs!")
if next_cell in {13, 14, 34, 35, 100, 101}:
oracle_orb_amount = as_float(oracle_orb_amount)
self.orbs_paid += oracle_orb_amount
logger.debug(f"Traded {oracle_orb_amount} orbs!")
next_buzzer_index = self.read_goal_address(next_buzzer_index_offset, sizeof_uint64)
for k in range(0, next_buzzer_index):
next_buzzer = self.read_goal_address(buzzers_checked_offset + (k * sizeof_uint32), sizeof_uint32)
buzzer_ap_id = flies.to_ap_id(next_buzzer)
if buzzer_ap_id not in self.location_outbox:
self.location_outbox.append(buzzer_ap_id)
logger.debug("Checked scout fly: " + str(next_buzzer))
next_special_index = self.read_goal_address(next_special_index_offset, sizeof_uint64)
for k in range(0, next_special_index):
next_special = self.read_goal_address(specials_checked_offset + (k * sizeof_uint32), sizeof_uint32)
special_ap_id = specials.to_ap_id(next_special)
if special_ap_id not in self.location_outbox:
self.location_outbox.append(special_ap_id)
logger.debug("Checked special: " + str(next_special))
death_count = self.read_goal_address(death_count_offset, sizeof_uint32)
death_cause = self.read_goal_address(death_cause_offset, sizeof_uint8)
if death_count > self.death_count:
self.cause_of_death = autopsy(death_cause) # The way he names his variables? Wack!
self.send_deathlink = True
self.death_count += 1
# Listen for any changes to this setting.
deathlink_flag = self.read_goal_address(deathlink_enabled_offset, sizeof_uint8)
self.deathlink_enabled = bool(deathlink_flag)
next_cache_index = self.read_goal_address(next_orb_cache_index_offset, sizeof_uint64)
for k in range(0, next_cache_index):
next_cache = self.read_goal_address(orb_caches_checked_offset + (k * sizeof_uint32), sizeof_uint32)
cache_ap_id = caches.to_ap_id(next_cache)
if cache_ap_id not in self.location_outbox:
self.location_outbox.append(cache_ap_id)
logger.debug("Checked orb cache: " + str(next_cache))
# Listen for any changes to this setting.
# moverando_flag = self.read_goal_address(moverando_enabled_offset, sizeof_uint8)
# self.moverando_enabled = bool(moverando_flag)
orbsanity_option = self.read_goal_address(orbsanity_option_offset, sizeof_uint8)
bundle_size = self.read_goal_address(orbsanity_bundle_offset, sizeof_uint32)
self.orbsanity_enabled = orbsanity_option > 0
# Per Level Orbsanity option. Only need to do this loop if we chose this setting.
if orbsanity_option == 1:
for level in range(0, 16):
collected_bundles = self.read_goal_address(collected_bundle_offset + (level * sizeof_uint32),
sizeof_uint32)
# Count up from the first bundle, by bundle size, until you reach the latest collected bundle.
# e.g. {25, 50, 75, 100, 125...}
if collected_bundles > 0:
for bundle in range(bundle_size,
bundle_size + collected_bundles, # Range max is non-inclusive.
bundle_size):
bundle_ap_id = orbs.to_ap_id(orbs.find_address(level, bundle, bundle_size))
if bundle_ap_id not in self.location_outbox:
self.location_outbox.append(bundle_ap_id)
logger.debug(f"Checked orb bundle: L{level} {bundle}")
# Global Orbsanity option. Index 16 refers to all orbs found regardless of level.
if orbsanity_option == 2:
collected_bundles = self.read_goal_address(collected_bundle_offset + (16 * sizeof_uint32),
sizeof_uint32)
if collected_bundles > 0:
for bundle in range(bundle_size,
bundle_size + collected_bundles, # Range max is non-inclusive.
bundle_size):
bundle_ap_id = orbs.to_ap_id(orbs.find_address(16, bundle, bundle_size))
if bundle_ap_id not in self.location_outbox:
self.location_outbox.append(bundle_ap_id)
logger.debug(f"Checked orb bundle: G {bundle}")
completed = self.read_goal_address(completed_offset, sizeof_uint8)
if completed > 0 and not self.finished_game:
self.finished_game = True
self.log_success(logger, "Congratulations! You finished the game!")
except (ProcessError, MemoryReadError, WinAPIError):
msg = (f"Error reading game memory! (Did the game crash?)\n"
f"Please close all open windows and reopen the Jak and Daxter Client "
f"from the Archipelago Launcher.\n"
f"If the game and compiler do not restart automatically, please follow these steps:\n"
f" Run the OpenGOAL Launcher, click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n"
f" Then click Advanced > Play in Debug Mode.\n"
f" Then click Advanced > Open REPL.\n"
f" Then close and reopen the Jak and Daxter Client from the Archipelago Launcher.")
self.log_error(logger, msg)
self.connected = False
return self.location_outbox
def read_goal_address(self, offset: int, length: int) -> int:
return int.from_bytes(
self.gk_process.read_bytes(self.goal_address + offset, length),
byteorder="little",
signed=False)
def save_data(self):
with open("jakanddaxter_location_outbox.json", "w+") as f:
dump = {
"outbox_index": self.outbox_index,
"location_outbox": self.location_outbox
}
json.dump(dump, f, indent=4)
def load_data(self):
try:
with open("jakanddaxter_location_outbox.json", "r") as f:
load = json.load(f)
self.outbox_index = load["outbox_index"]
self.location_outbox = load["location_outbox"]
except FileNotFoundError:
pass

View File

@@ -0,0 +1,527 @@
import json
import logging
import queue
import time
import struct
import random
from dataclasses import dataclass
from queue import Queue
from typing import Callable
import pymem
from pymem.exception import ProcessNotFound, ProcessError
import asyncio
from asyncio import StreamReader, StreamWriter, Lock
from NetUtils import NetworkItem
from ..game_id import jak1_id, jak1_max
from ..items import item_table, trap_item_table
from ..locs import (
orb_locations as orbs,
cell_locations as cells,
scout_locations as flies,
special_locations as specials,
orb_cache_locations as caches)
logger = logging.getLogger("ReplClient")
@dataclass
class JsonMessageData:
my_item_name: str | None = None
my_item_finder: str | None = None
their_item_name: str | None = None
their_item_owner: str | None = None
ALLOWED_CHARACTERS = frozenset({
"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J",
"K", "L", "M", "N", "O", "P", "Q", "R", "S", "T",
"U", "V", "W", "X", "Y", "Z", "a", "b", "c", "d",
"e", "f", "g", "h", "i", "j", "k", "l", "m", "n",
"o", "p", "q", "r", "s", "t", "u", "v", "w", "x",
"y", "z", " ", "!", ":", ",", ".", "/", "?", "-",
"=", "+", "'", "(", ")", "\""
})
class JakAndDaxterReplClient:
ip: str
port: int
reader: StreamReader
writer: StreamWriter
lock: Lock
connected: bool = False
initiated_connect: bool = False # Signals when user tells us to try reconnecting.
received_deathlink: bool = False
balanced_orbs: bool = False
# Variables to handle the title screen and initial game connection.
initial_item_count = -1 # Brand new games have 0 items, so initialize this to -1.
received_initial_items = False
processed_initial_items = False
# The REPL client needs the REPL/compiler process running, but that process
# also needs the game running. Therefore, the REPL client needs both running.
gk_process: pymem.process = None
goalc_process: pymem.process = None
item_inbox: dict[int, NetworkItem] = {}
inbox_index = 0
json_message_queue: Queue[JsonMessageData] = queue.Queue()
# Logging callbacks
# These will write to the provided logger, as well as the Client GUI with color markup.
log_error: Callable # Red
log_warn: Callable # Orange
log_success: Callable # Green
log_info: Callable # White (default)
def __init__(self,
log_error_callback: Callable,
log_warn_callback: Callable,
log_success_callback: Callable,
log_info_callback: Callable,
ip: str = "127.0.0.1",
port: int = 8181):
self.ip = ip
self.port = port
self.lock = asyncio.Lock()
self.log_error = log_error_callback
self.log_warn = log_warn_callback
self.log_success = log_success_callback
self.log_info = log_info_callback
async def main_tick(self):
if self.initiated_connect:
await self.connect()
self.initiated_connect = False
if self.connected:
try:
self.gk_process.read_bool(self.gk_process.base_address) # Ping to see if it's alive.
except ProcessError:
msg = (f"Error reading game memory! (Did the game crash?)\n"
f"Please close all open windows and reopen the Jak and Daxter Client "
f"from the Archipelago Launcher.\n"
f"If the game and compiler do not restart automatically, please follow these steps:\n"
f" Run the OpenGOAL Launcher, click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n"
f" Then click Advanced > Play in Debug Mode.\n"
f" Then click Advanced > Open REPL.\n"
f" Then close and reopen the Jak and Daxter Client from the Archipelago Launcher.")
self.log_error(logger, msg)
self.connected = False
try:
self.goalc_process.read_bool(self.goalc_process.base_address) # Ping to see if it's alive.
except ProcessError:
msg = (f"Error sending data to compiler! (Did the compiler crash?)\n"
f"Please close all open windows and reopen the Jak and Daxter Client "
f"from the Archipelago Launcher.\n"
f"If the game and compiler do not restart automatically, please follow these steps:\n"
f" Run the OpenGOAL Launcher, click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n"
f" Then click Advanced > Play in Debug Mode.\n"
f" Then click Advanced > Open REPL.\n"
f" Then close and reopen the Jak and Daxter Client from the Archipelago Launcher.")
self.log_error(logger, msg)
self.connected = False
else:
return
# When connecting the game to the AP server on the title screen, we may be processing items from starting
# inventory or items received in an async game. Once we have caught up to the initial count, tell the player
# that we are ready to start. New items may even come in during the title screen, so if we go over the count,
# we should still send the ready signal.
if not self.processed_initial_items:
if self.inbox_index >= self.initial_item_count >= 0:
self.processed_initial_items = True
await self.send_connection_status("ready")
# Receive Items from AP. Handle 1 item per tick.
if len(self.item_inbox) > self.inbox_index:
await self.receive_item()
await self.save_data()
self.inbox_index += 1
if self.received_deathlink:
await self.receive_deathlink()
self.received_deathlink = False
# Progressively empty the queue during each tick
# if text messages happen to be too slow we could pool dequeuing here,
# but it'd slow down the ItemReceived message during release
if not self.json_message_queue.empty():
json_txt_data = self.json_message_queue.get_nowait()
await self.write_game_text(json_txt_data)
# This helper function formats and sends `form` as a command to the REPL.
# ALL commands to the REPL should be sent using this function.
async def send_form(self, form: str, print_ok: bool = True) -> bool:
header = struct.pack("<II", len(form), 10)
async with self.lock:
self.writer.write(header + form.encode())
await self.writer.drain()
response_data = await self.reader.read(1024)
response = response_data.decode()
if "OK!" in response:
if print_ok:
logger.debug(response)
return True
else:
self.log_error(logger, f"Unexpected response from REPL: {response}")
return False
async def connect(self):
try:
self.gk_process = pymem.Pymem("gk.exe") # The GOAL Kernel
logger.debug("Found the gk process: " + str(self.gk_process.process_id))
except ProcessNotFound:
self.log_error(logger, "Could not find the game process.")
return
try:
self.goalc_process = pymem.Pymem("goalc.exe") # The GOAL Compiler and REPL
logger.debug("Found the goalc process: " + str(self.goalc_process.process_id))
except ProcessNotFound:
self.log_error(logger, "Could not find the compiler process.")
return
try:
self.reader, self.writer = await asyncio.open_connection(self.ip, self.port)
time.sleep(1)
connect_data = await self.reader.read(1024)
welcome_message = connect_data.decode()
# Should be the OpenGOAL welcome message (ignore version number).
if "Connected to OpenGOAL" and "nREPL!" in welcome_message:
logger.debug(welcome_message)
else:
self.log_error(logger,
f"Unable to connect to REPL websocket: unexpected welcome message \"{welcome_message}\"")
except ConnectionRefusedError as e:
self.log_error(logger, f"Unable to connect to REPL websocket: {e.strerror}")
return
ok_count = 0
if self.reader and self.writer:
# Have the REPL listen to the game's internal websocket.
if await self.send_form("(lt)", print_ok=False):
ok_count += 1
# Show this visual cue when compilation is started.
# It's the version number of the OpenGOAL Compiler.
if await self.send_form("(set! *debug-segment* #t)", print_ok=False):
ok_count += 1
# Start compilation. This is blocking, so nothing will happen until the REPL is done.
if await self.send_form("(mi)", print_ok=False):
ok_count += 1
# Play this audio cue when compilation is complete.
# It's the sound you hear when you press START + START to close the Options menu.
if await self.send_form("(dotimes (i 1) "
"(sound-play-by-name "
"(static-sound-name \"menu-close\") "
"(new-sound-id) 1024 0 0 (sound-group sfx) #t))", print_ok=False):
ok_count += 1
# Disable cheat-mode and debug (close the visual cues).
if await self.send_form("(set! *debug-segment* #f)", print_ok=False):
ok_count += 1
if await self.send_form("(set! *cheat-mode* #f)", print_ok=False):
ok_count += 1
# Run the retail game start sequence (while still connected with REPL).
if await self.send_form("(start \'play (get-continue-by-name *game-info* \"title-start\"))"):
ok_count += 1
# Now wait until we see the success message... 7 times.
if ok_count == 7:
self.connected = True
else:
self.connected = False
if self.connected:
self.log_success(logger, "The REPL is ready!")
async def print_status(self):
gc_proc_id = str(self.goalc_process.process_id) if self.goalc_process else "None"
gk_proc_id = str(self.gk_process.process_id) if self.gk_process else "None"
msg = (f"REPL Status:\n"
f" REPL process ID: {gc_proc_id}\n"
f" Game process ID: {gk_proc_id}\n")
try:
if self.reader and self.writer:
addr = self.writer.get_extra_info("peername")
addr = str(addr) if addr else "None"
msg += f" Game websocket: {addr}\n"
await self.send_form("(dotimes (i 1) "
"(sound-play-by-name "
"(static-sound-name \"menu-close\") "
"(new-sound-id) 1024 0 0 (sound-group sfx) #t))", print_ok=False)
except ConnectionResetError:
msg += f" Connection to the game was lost or reset!"
last_item = str(getattr(self.item_inbox[self.inbox_index], "item")) if self.inbox_index else "None"
msg += f" Last item received: {last_item}\n"
msg += f" Did you hear the success audio cue?"
self.log_info(logger, msg)
# To properly display in-game text:
# - It must be a valid character from the ALLOWED_CHARACTERS list.
# - All lowercase letters must be uppercase.
# - It must be wrapped in double quotes (for the REPL command).
# - Apostrophes must be handled specially - GOAL uses invisible ASCII character 0x12.
# I also only allotted 32 bytes to each string in OpenGOAL, so we must truncate.
@staticmethod
def sanitize_game_text(text: str) -> str:
result = "".join([c if c in ALLOWED_CHARACTERS else "?" for c in text[:32]]).upper()
result = result.replace("'", "\\c12")
return f"\"{result}\""
# Like sanitize_game_text, but the settings file will NOT allow any whitespace in the slot_name or slot_seed data.
# And don't replace any chars with "?" for good measure.
@staticmethod
def sanitize_file_text(text: str) -> str:
allowed_chars_no_extras = ALLOWED_CHARACTERS - {" ", "'", "(", ")", "\""}
result = "".join([c if c in allowed_chars_no_extras else "" for c in text[:16]]).upper()
return f"\"{result}\""
# Pushes a JsonMessageData object to the json message queue to be processed during the repl main_tick
def queue_game_text(self, my_item_name, my_item_finder, their_item_name, their_item_owner):
self.json_message_queue.put(JsonMessageData(my_item_name, my_item_finder, their_item_name, their_item_owner))
# OpenGOAL can handle both its own string datatype and C-like character pointers (charp).
async def write_game_text(self, data: JsonMessageData):
logger.debug(f"Sending info to the in-game messenger!")
body = ""
if data.my_item_name and data.my_item_finder:
body += (f" (append-messages (-> *ap-messenger* 0) \'recv "
f" {self.sanitize_game_text(data.my_item_name)} "
f" {self.sanitize_game_text(data.my_item_finder)})")
if data.their_item_name and data.their_item_owner:
body += (f" (append-messages (-> *ap-messenger* 0) \'sent "
f" {self.sanitize_game_text(data.their_item_name)} "
f" {self.sanitize_game_text(data.their_item_owner)})")
await self.send_form(f"(begin {body} (none))", print_ok=False)
async def receive_item(self):
ap_id = getattr(self.item_inbox[self.inbox_index], "item")
# Determine the type of item to receive.
if ap_id in range(jak1_id, jak1_id + flies.fly_offset):
await self.receive_power_cell(ap_id)
elif ap_id in range(jak1_id + flies.fly_offset, jak1_id + specials.special_offset):
await self.receive_scout_fly(ap_id)
elif ap_id in range(jak1_id + specials.special_offset, jak1_id + caches.orb_cache_offset):
await self.receive_special(ap_id)
elif ap_id in range(jak1_id + caches.orb_cache_offset, jak1_id + orbs.orb_offset):
await self.receive_move(ap_id)
elif ap_id in range(jak1_id + orbs.orb_offset, jak1_max - max(trap_item_table)):
await self.receive_precursor_orb(ap_id) # Ponder the orbs.
elif ap_id in range(jak1_max - max(trap_item_table), jak1_max):
await self.receive_trap(ap_id)
elif ap_id == jak1_max:
await self.receive_green_eco() # Ponder why I chose to do ID's this way.
else:
self.log_error(logger, f"Tried to receive item with unknown AP ID {ap_id}!")
async def receive_power_cell(self, ap_id: int) -> bool:
cell_id = cells.to_game_id(ap_id)
ok = await self.send_form("(send-event "
"*target* \'get-archipelago "
"(pickup-type fuel-cell) "
"(the float " + str(cell_id) + "))")
if ok:
logger.debug(f"Received a Power Cell!")
else:
self.log_error(logger, f"Unable to receive a Power Cell!")
return ok
async def receive_scout_fly(self, ap_id: int) -> bool:
fly_id = flies.to_game_id(ap_id)
ok = await self.send_form("(send-event "
"*target* \'get-archipelago "
"(pickup-type buzzer) "
"(the float " + str(fly_id) + "))")
if ok:
logger.debug(f"Received a {item_table[ap_id]}!")
else:
self.log_error(logger, f"Unable to receive a {item_table[ap_id]}!")
return ok
async def receive_special(self, ap_id: int) -> bool:
special_id = specials.to_game_id(ap_id)
ok = await self.send_form("(send-event "
"*target* \'get-archipelago "
"(pickup-type ap-special) "
"(the float " + str(special_id) + "))")
if ok:
logger.debug(f"Received special unlock {item_table[ap_id]}!")
else:
self.log_error(logger, f"Unable to receive special unlock {item_table[ap_id]}!")
return ok
async def receive_move(self, ap_id: int) -> bool:
move_id = caches.to_game_id(ap_id)
ok = await self.send_form("(send-event "
"*target* \'get-archipelago "
"(pickup-type ap-move) "
"(the float " + str(move_id) + "))")
if ok:
logger.debug(f"Received the ability to {item_table[ap_id]}!")
else:
self.log_error(logger, f"Unable to receive the ability to {item_table[ap_id]}!")
return ok
async def receive_precursor_orb(self, ap_id: int) -> bool:
orb_amount = orbs.to_game_id(ap_id)
ok = await self.send_form("(send-event "
"*target* \'get-archipelago "
"(pickup-type money) "
"(the float " + str(orb_amount) + "))")
if ok:
logger.debug(f"Received {orb_amount} Precursor orbs!")
else:
self.log_error(logger, f"Unable to receive {orb_amount} Precursor orbs!")
return ok
async def receive_trap(self, ap_id: int) -> bool:
trap_id = jak1_max - ap_id
ok = await self.send_form("(send-event "
"*target* \'get-archipelago "
"(pickup-type ap-trap) "
"(the float " + str(trap_id) + "))")
if ok:
logger.debug(f"Received a {item_table[ap_id]}!")
else:
self.log_error(logger, f"Unable to receive a {item_table[ap_id]}!")
return ok
# Green eco pills are our filler item. Use the get-pickup event instead to handle being full health.
async def receive_green_eco(self) -> bool:
ok = await self.send_form("(send-event *target* \'get-pickup (pickup-type eco-pill) (the float 1))")
if ok:
logger.debug(f"Received a green eco pill!")
else:
self.log_error(logger, f"Unable to receive a green eco pill!")
return ok
async def receive_deathlink(self) -> bool:
# Because it should at least be funny sometimes.
death_types = ["\'death",
"\'death",
"\'death",
"\'death",
"\'endlessfall",
"\'drown-death",
"\'melt",
"\'dark-eco-pool"]
chosen_death = random.choice(death_types)
ok = await self.send_form("(ap-deathlink-received! " + chosen_death + ")")
if ok:
logger.debug(f"Received deathlink signal!")
else:
self.log_error(logger, f"Unable to receive deathlink signal!")
return ok
async def subtract_traded_orbs(self, orb_count: int) -> bool:
# To protect against momentary server disconnects,
# this should only be done once per client session.
if not self.balanced_orbs:
self.balanced_orbs = True
ok = await self.send_form(f"(-! (-> *game-info* money) (the float {orb_count}))")
if ok:
logger.debug(f"Subtracting {orb_count} traded orbs!")
else:
self.log_error(logger, f"Unable to subtract {orb_count} traded orbs!")
return ok
return True
# OpenGOAL has a limit of 8 parameters per function. We've already hit this limit. So, define a new datatype
# in OpenGOAL that holds all these options, instantiate the type here, and have ap-setup-options! function take
# that instance as input.
async def setup_options(self,
os_option: int, os_bundle: int,
fc_count: int, mp_count: int,
lt_count: int, ct_amount: int,
ot_amount: int, trap_time: int,
goal_id: int, slot_name: str,
slot_seed: str) -> bool:
sanitized_name = self.sanitize_file_text(slot_name)
sanitized_seed = self.sanitize_file_text(slot_seed)
# I didn't want to have to do this with floats but GOAL's compile-time vs runtime types leave me no choice.
ok = await self.send_form(f"(ap-setup-options! (new 'static 'ap-seed-options "
f":orbsanity-option {os_option} "
f":orbsanity-bundle {os_bundle} "
f":fire-canyon-unlock {fc_count}.0 "
f":mountain-pass-unlock {mp_count}.0 "
f":lava-tube-unlock {lt_count}.0 "
f":citizen-orb-amount {ct_amount}.0 "
f":oracle-orb-amount {ot_amount}.0 "
f":trap-duration {trap_time}.0 "
f":completion-goal {goal_id} "
f":slot-name {sanitized_name} "
f":slot-seed {sanitized_seed} ))")
message = (f"Setting options: \n"
f" orbsanity Option {os_option}, orbsanity Bundle {os_bundle}, \n"
f" FC Cell Count {fc_count}, MP Cell Count {mp_count}, \n"
f" LT Cell Count {lt_count}, Citizen Orb Amt {ct_amount}, \n"
f" Oracle Orb Amt {ot_amount}, Trap Duration {trap_time}, \n"
f" Completion GOAL {goal_id}, Slot Name {sanitized_name}, \n"
f" Slot Seed {sanitized_seed}... ")
if ok:
logger.debug(message + "Success!")
else:
self.log_error(logger, message + "Failed!")
return ok
async def send_connection_status(self, status: str) -> bool:
ok = await self.send_form(f"(ap-set-connection-status! (connection-status {status}))")
if ok:
logger.debug(f"Connection Status {status} set!")
else:
self.log_error(logger, f"Connection Status {status} failed to set!")
return ok
async def save_data(self):
with open("jakanddaxter_item_inbox.json", "w+") as f:
dump = {
"inbox_index": self.inbox_index,
"item_inbox": [{
"item": self.item_inbox[k].item,
"location": self.item_inbox[k].location,
"player": self.item_inbox[k].player,
"flags": self.item_inbox[k].flags
} for k in self.item_inbox
]
}
json.dump(dump, f, indent=4)
def load_data(self):
try:
with open("jakanddaxter_item_inbox.json", "r") as f:
load = json.load(f)
self.inbox_index = load["inbox_index"]
self.item_inbox = {k: NetworkItem(
item=load["item_inbox"][k]["item"],
location=load["item_inbox"][k]["location"],
player=load["item_inbox"][k]["player"],
flags=load["item_inbox"][k]["flags"]
) for k in range(0, len(load["item_inbox"]))
}
except FileNotFoundError:
pass

View File

@@ -0,0 +1,600 @@
# Python standard libraries
import asyncio
import json
import logging
import os
import subprocess
import sys
from asyncio import Task
from datetime import datetime
from logging import Logger
from typing import Awaitable
# Misc imports
import colorama
import pymem
from pymem.exception import ProcessNotFound
# Archipelago imports
import ModuleUpdate
import Utils
from CommonClient import ClientCommandProcessor, CommonContext, server_loop, gui_enabled
from NetUtils import ClientStatus
# Jak imports
from .game_id import jak1_name
from .options import EnableOrbsanity
from .agents.memory_reader import JakAndDaxterMemoryReader
from .agents.repl_client import JakAndDaxterReplClient
from . import JakAndDaxterWorld
ModuleUpdate.update()
logger = logging.getLogger("JakClient")
all_tasks: set[Task] = set()
def create_task_log_exception(awaitable: Awaitable) -> asyncio.Task:
async def _log_exception(a):
try:
return await a
except Exception as e:
logger.exception(e)
finally:
all_tasks.remove(task)
task = asyncio.create_task(_log_exception(awaitable))
all_tasks.add(task)
return task
class JakAndDaxterClientCommandProcessor(ClientCommandProcessor):
ctx: "JakAndDaxterContext"
# The command processor is not async so long-running operations like the /repl connect command
# (which takes 10-15 seconds to compile the game) have to be requested with user-initiated flags.
# The flags are checked by the agents every main_tick.
def _cmd_repl(self, *arguments: str):
"""Sends a command to the OpenGOAL REPL. Arguments:
- connect : connect the client to the REPL (goalc).
- status : check internal status of the REPL."""
if arguments:
if arguments[0] == "connect":
self.ctx.on_log_info(logger, "This may take a bit... Wait for the success audio cue before continuing!")
self.ctx.repl.initiated_connect = True
if arguments[0] == "status":
create_task_log_exception(self.ctx.repl.print_status())
def _cmd_memr(self, *arguments: str):
"""Sends a command to the Memory Reader. Arguments:
- connect : connect the memory reader to the game process (gk).
- status : check the internal status of the Memory Reader."""
if arguments:
if arguments[0] == "connect":
self.ctx.memr.initiated_connect = True
if arguments[0] == "status":
create_task_log_exception(self.ctx.memr.print_status())
class JakAndDaxterContext(CommonContext):
game = jak1_name
items_handling = 0b111 # Full item handling
command_processor = JakAndDaxterClientCommandProcessor
# We'll need two agents working in tandem to handle two-way communication with the game.
# The REPL Client will handle the server->game direction by issuing commands directly to the running game.
# But the REPL cannot send information back to us, it only ingests information we send it.
# Luckily OpenGOAL sets up memory addresses to write to, that AutoSplit can read from, for speedrunning.
# We'll piggyback off this system with a Memory Reader, and that will handle the game->server direction.
repl: JakAndDaxterReplClient
memr: JakAndDaxterMemoryReader
# And two associated tasks, so we have handles on them.
repl_task: asyncio.Task
memr_task: asyncio.Task
# Storing some information for writing save slot identifiers.
slot_seed: str
def __init__(self, server_address: str | None, password: str | None) -> None:
self.repl = JakAndDaxterReplClient(self.on_log_error,
self.on_log_warn,
self.on_log_success,
self.on_log_info)
self.memr = JakAndDaxterMemoryReader(self.on_location_check,
self.on_finish_check,
self.on_deathlink_check,
self.on_deathlink_toggle,
self.on_orb_trade,
self.on_log_error,
self.on_log_warn,
self.on_log_success,
self.on_log_info)
# self.repl.load_data()
# self.memr.load_data()
super().__init__(server_address, password)
def run_gui(self):
from kvui import GameManager
class JakAndDaxterManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Jak and Daxter ArchipelaGOAL Client"
self.ui = JakAndDaxterManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(JakAndDaxterContext, self).server_auth(password_requested)
await self.get_username()
self.tags = set()
await self.send_connect()
def on_package(self, cmd: str, args: dict):
if cmd == "RoomInfo":
self.slot_seed = args["seed_name"]
if cmd == "Connected":
slot_data = args["slot_data"]
orbsanity_option = slot_data["enable_orbsanity"]
if orbsanity_option == EnableOrbsanity.option_per_level:
orbsanity_bundle = slot_data["level_orbsanity_bundle_size"]
elif orbsanity_option == EnableOrbsanity.option_global:
orbsanity_bundle = slot_data["global_orbsanity_bundle_size"]
else:
orbsanity_bundle = 1
# Connected packet is unaware of starting inventory or if player is returning to an existing game.
# Set initial_item_count to 0, see below comments for more info.
if not self.repl.received_initial_items and self.repl.initial_item_count < 0:
self.repl.initial_item_count = 0
create_task_log_exception(
self.repl.setup_options(orbsanity_option,
orbsanity_bundle,
slot_data["fire_canyon_cell_count"],
slot_data["mountain_pass_cell_count"],
slot_data["lava_tube_cell_count"],
slot_data["citizen_orb_trade_amount"],
slot_data["oracle_orb_trade_amount"],
slot_data["trap_effect_duration"],
slot_data["jak_completion_condition"],
self.auth[:16], # The slot name
self.slot_seed[:8]))
# Because Orbsanity and the orb traders in the game are intrinsically linked, we need the server
# to track our trades at all times to support async play. "Retrieved" will tell us the orbs we lost,
# while "ReceivedItems" will tell us the orbs we gained. This will give us the correct balance.
if orbsanity_option in [EnableOrbsanity.option_per_level, EnableOrbsanity.option_global]:
async def get_orb_balance():
await self.send_msgs([{"cmd": "Get", "keys": [f"jakanddaxter_{self.auth}_orbs_paid"]}])
create_task_log_exception(get_orb_balance())
# Tell the server if Deathlink is enabled or disabled in the in-game options.
# This allows us to "remember" the user's choice.
self.on_deathlink_toggle()
if cmd == "Retrieved":
if f"jakanddaxter_{self.auth}_orbs_paid" in args["keys"]:
orbs_traded = args["keys"][f"jakanddaxter_{self.auth}_orbs_paid"]
orbs_traded = orbs_traded if orbs_traded is not None else 0
create_task_log_exception(self.repl.subtract_traded_orbs(orbs_traded))
if cmd == "ReceivedItems":
# If you have a starting inventory or are returning to a game where you have items, a ReceivedItems will be
# in the same network packet as Connected. This guarantees it is the first of any ReceivedItems we process.
# In this case, we should set the initial_item_count to > 0, even if already set to 0 by Connected, as well
# as the received_initial_items flag. Finally, use send_connection_status to tell the player to wait while
# we process the initial items. However, we will skip all this if there was no initial ReceivedItems and
# the REPL indicates it already handled any initial items (0 or otherwise).
if not self.repl.received_initial_items and not self.repl.processed_initial_items:
self.repl.received_initial_items = True
self.repl.initial_item_count = len(args["items"])
create_task_log_exception(self.repl.send_connection_status("wait"))
# This enumeration should run on every ReceivedItems packet,
# regardless of it being on initial connection or midway through a game.
for index, item in enumerate(args["items"], start=args["index"]):
logger.debug(f"index: {str(index)}, item: {str(item)}")
self.repl.item_inbox[index] = item
async def json_to_game_text(self, args: dict):
if "type" in args and args["type"] in {"ItemSend"}:
my_item_name: str | None = None
my_item_finder: str | None = None
their_item_name: str | None = None
their_item_owner: str | None = None
item = args["item"]
recipient = args["receiving"]
# Receiving an item from the server.
if self.slot_concerns_self(recipient):
my_item_name = self.item_names.lookup_in_game(item.item)
# Did we find it, or did someone else?
if self.slot_concerns_self(item.player):
my_item_finder = "MYSELF"
else:
my_item_finder = self.player_names[item.player]
# Sending an item to the server.
if self.slot_concerns_self(item.player):
their_item_name = self.item_names.lookup_in_slot(item.item, recipient)
# Does it belong to us, or to someone else?
if self.slot_concerns_self(recipient):
their_item_owner = "MYSELF"
else:
their_item_owner = self.player_names[recipient]
# Write to game display.
self.repl.queue_game_text(my_item_name, my_item_finder, their_item_name, their_item_owner)
# Even though N items come in as 1 ReceivedItems packet, there are still N PrintJson packets to process,
# and they all arrive before the ReceivedItems packet does. Defer processing of these packets as
# async tasks to speed up large releases of items.
def on_print_json(self, args: dict) -> None:
create_task_log_exception(self.json_to_game_text(args))
super(JakAndDaxterContext, self).on_print_json(args)
# We need to do a little more than just use CommonClient's on_deathlink.
def on_deathlink(self, data: dict):
if self.memr.deathlink_enabled:
self.repl.received_deathlink = True
super().on_deathlink(data)
# We don't need an ap_inform function because check_locations solves that need.
def on_location_check(self, location_ids: list[int]):
create_task_log_exception(self.check_locations(location_ids))
# CommonClient has no finished_game function, so we will have to craft our own. TODO - Update if that changes.
async def ap_inform_finished_game(self):
if not self.finished_game and self.memr.finished_game:
message = [{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]
await self.send_msgs(message)
self.finished_game = True
def on_finish_check(self):
create_task_log_exception(self.ap_inform_finished_game())
# We need to do a little more than just use CommonClient's send_death.
async def ap_inform_deathlink(self):
if self.memr.deathlink_enabled:
player = self.player_names[self.slot] if self.slot is not None else "Jak"
death_text = self.memr.cause_of_death.replace("Jak", player)
await self.send_death(death_text)
self.on_log_warn(logger, death_text)
# Reset all flags, but leave the death count alone.
self.memr.send_deathlink = False
self.memr.cause_of_death = ""
def on_deathlink_check(self):
create_task_log_exception(self.ap_inform_deathlink())
# We don't need an ap_inform function because update_death_link solves that need.
def on_deathlink_toggle(self):
create_task_log_exception(self.update_death_link(self.memr.deathlink_enabled))
# Orb trades are situations unique to Jak, so we have to craft our own function.
async def ap_inform_orb_trade(self, orbs_changed: int):
if self.memr.orbsanity_enabled:
await self.send_msgs([{"cmd": "Set",
"key": f"jakanddaxter_{self.auth}_orbs_paid",
"default": 0,
"want_reply": False,
"operations": [{"operation": "add", "value": orbs_changed}]
}])
def on_orb_trade(self, orbs_changed: int):
create_task_log_exception(self.ap_inform_orb_trade(orbs_changed))
def _markup_panels(self, msg: str, c: str = None):
color = self.jsontotextparser.color_codes[c] if c else None
message = f"[color={color}]{msg}[/color]" if c else msg
self.ui.log_panels["Archipelago"].on_message_markup(message)
self.ui.log_panels["All"].on_message_markup(message)
def on_log_error(self, lg: Logger, message: str):
lg.error(message)
if self.ui:
self._markup_panels(message, "red")
def on_log_warn(self, lg: Logger, message: str):
lg.warning(message)
if self.ui:
self._markup_panels(message, "orange")
def on_log_success(self, lg: Logger, message: str):
lg.info(message)
if self.ui:
self._markup_panels(message, "green")
def on_log_info(self, lg: Logger, message: str):
lg.info(message)
if self.ui:
self._markup_panels(message)
async def run_repl_loop(self):
while True:
await self.repl.main_tick()
await asyncio.sleep(0.1)
async def run_memr_loop(self):
while True:
await self.memr.main_tick()
await asyncio.sleep(0.1)
def find_root_directory(ctx: JakAndDaxterContext):
# The path to this file is platform-dependent.
if Utils.is_windows:
appdata = os.getenv("APPDATA")
settings_path = os.path.normpath(f"{appdata}/OpenGOAL-Launcher/settings.json")
elif Utils.is_linux:
home = os.path.expanduser("~")
settings_path = os.path.normpath(f"{home}/.config/OpenGOAL-Launcher/settings.json")
elif Utils.is_macos:
home = os.path.expanduser("~")
settings_path = os.path.normpath(f"{home}/Library/Application Support/OpenGOAL-Launcher/settings.json")
else:
ctx.on_log_error(logger, f"Unknown operating system: {sys.platform}!")
return
# Boilerplate messages that all error messages in this function should have.
err_title = "Unable to locate the ArchipelaGOAL install directory"
alt_instructions = (f"Please verify that OpenGOAL and ArchipelaGOAL are installed properly. "
f"If the problem persists, follow these steps:\n"
f" Run the OpenGOAL Launcher, click Jak and Daxter > Features > Mods > ArchipelaGOAL.\n"
f" Then click Advanced > Open Game Data Folder.\n"
f" Go up one folder, then copy this path.\n"
f" Run the Archipelago Launcher, click Open host.yaml.\n"
f" Set the value of 'jakanddaxter_options > root_directory' to this path.\n"
f" Replace all backslashes in the path with forward slashes.\n"
f" Set the value of 'jakanddaxter_options > auto_detect_root_directory' to false, "
f"then save and close the host.yaml file.\n"
f" Close all launchers, games, clients, and console windows, then restart Archipelago.")
if not os.path.exists(settings_path):
msg = (f"{err_title}: the OpenGOAL settings file does not exist.\n"
f"{alt_instructions}")
ctx.on_log_error(logger, msg)
return
with open(settings_path, "r") as f:
load = json.load(f)
jak1_installed = load["games"]["Jak 1"]["isInstalled"]
if not jak1_installed:
msg = (f"{err_title}: The OpenGOAL Launcher is missing a normal install of Jak 1!\n"
f"{alt_instructions}")
ctx.on_log_error(logger, msg)
return
mod_sources = load["games"]["Jak 1"]["modsInstalledVersion"]
if mod_sources is None:
msg = (f"{err_title}: No mod sources have been configured in the OpenGOAL Launcher!\n"
f"{alt_instructions}")
ctx.on_log_error(logger, msg)
return
# Mods can come from multiple user-defined sources.
# Make no assumptions about where ArchipelaGOAL comes from, we should find it ourselves.
archipelagoal_source = None
for src in mod_sources:
for mod in mod_sources[src].keys():
if mod == "archipelagoal":
archipelagoal_source = src
# Using this file, we could verify the right version is installed, but we don't need to.
if archipelagoal_source is None:
msg = (f"{err_title}: The ArchipelaGOAL mod is not installed in the OpenGOAL Launcher!\n"
f"{alt_instructions}")
ctx.on_log_error(logger, msg)
return
# This is just the base OpenGOAL directory, we need to go deeper.
base_path = load["installationDir"]
mod_relative_path = f"features/jak1/mods/{archipelagoal_source}/archipelagoal"
mod_path = os.path.normpath(
os.path.join(
os.path.normpath(base_path),
os.path.normpath(mod_relative_path)))
return mod_path
async def run_game(ctx: JakAndDaxterContext):
# These may already be running. If they are not running, try to start them.
# TODO - Support other OS's. 1: Pymem is Windows-only. 2: on Linux, there's no ".exe."
gk_running = False
try:
pymem.Pymem("gk.exe") # The GOAL Kernel
gk_running = True
except ProcessNotFound:
ctx.on_log_warn(logger, "Game not running, attempting to start.")
goalc_running = False
try:
pymem.Pymem("goalc.exe") # The GOAL Compiler and REPL
goalc_running = True
except ProcessNotFound:
ctx.on_log_warn(logger, "Compiler not running, attempting to start.")
try:
auto_detect_root_directory = JakAndDaxterWorld.settings.auto_detect_root_directory
if auto_detect_root_directory:
root_path = find_root_directory(ctx)
else:
root_path = JakAndDaxterWorld.settings.root_directory
# Always trust your instincts... the user may not have entered their root_directory properly.
# We don't have to do this check if the root directory was auto-detected.
if "/" not in root_path:
msg = (f"The ArchipelaGOAL root directory contains no path. (Are you missing forward slashes?)\n"
f"Please check your host.yaml file.\n"
f"Verify the value of 'jakanddaxter_options > root_directory' is a valid existing path, "
f"and all backslashes have been replaced with forward slashes.")
ctx.on_log_error(logger, msg)
return
# Start by checking the existence of the root directory provided in the host.yaml file (or found automatically).
root_path = os.path.normpath(root_path)
if not os.path.exists(root_path):
msg = (f"The ArchipelaGOAL root directory does not exist, unable to locate the Game and Compiler.\n"
f"Please check your host.yaml file.\n"
f"If the value of 'jakanddaxter_options > auto_detect_root_directory' is true, verify that OpenGOAL "
f"is installed properly.\n"
f"If it is false, check the value of 'jakanddaxter_options > root_directory'. "
f"Verify it is a valid existing path, and all backslashes have been replaced with forward slashes.")
ctx.on_log_error(logger, msg)
return
# Now double-check the existence of the two executables we need.
gk_path = os.path.join(root_path, "gk.exe")
goalc_path = os.path.join(root_path, "goalc.exe")
if not os.path.exists(gk_path) or not os.path.exists(goalc_path):
msg = (f"The Game and Compiler could not be found in the ArchipelaGOAL root directory.\n"
f"Please check your host.yaml file.\n"
f"If the value of 'jakanddaxter_options > auto_detect_root_directory' is true, verify that OpenGOAL "
f"is installed properly.\n"
f"If it is false, check the value of 'jakanddaxter_options > root_directory'. "
f"Verify it is a valid existing path, and all backslashes have been replaced with forward slashes.")
ctx.on_log_error(logger, msg)
return
# Now we can FINALLY attempt to start the programs.
if not gk_running:
# Per-mod saves and settings are stored outside the ArchipelaGOAL root folder, so we have to traverse
# a relative path, normalize it, and pass it in as an argument to gk. This folder will be created if
# it does not exist.
config_relative_path = "../_settings/archipelagoal"
config_path = os.path.normpath(
os.path.join(
root_path,
os.path.normpath(config_relative_path)))
# The game freezes if text is inadvertently selected in the stdout/stderr data streams. Let's pipe those
# streams to a file, and let's not clutter the screen with another console window.
timestamp = datetime.now().strftime("%Y_%m_%d_%H_%M_%S")
log_path = os.path.join(Utils.user_path("logs"), f"JakAndDaxterGame_{timestamp}.txt")
log_path = os.path.normpath(log_path)
with open(log_path, "w") as log_file:
gk_process = subprocess.Popen(
[gk_path, "--game", "jak1",
"--config-path", config_path,
"--", "-v", "-boot", "-fakeiso", "-debug"],
stdout=log_file,
stderr=log_file,
creationflags=subprocess.CREATE_NO_WINDOW)
if not goalc_running:
# For the OpenGOAL Compiler, the existence of the "data" subfolder indicates you are running it from
# a built package. This subfolder is treated as its proj_path.
proj_path = os.path.join(root_path, "data")
if os.path.exists(proj_path):
# Look for "iso_data" path to automate away an oft-forgotten manual step of mod updates.
# All relative paths should start from root_path and end with "jak1".
goalc_args = []
possible_relative_paths = {
"../../../../../active/jak1/data/iso_data/jak1",
"./data/iso_data/jak1",
}
for iso_relative_path in possible_relative_paths:
iso_path = os.path.normpath(
os.path.join(
root_path,
os.path.normpath(iso_relative_path)))
if os.path.exists(iso_path):
goalc_args = [goalc_path, "--game", "jak1", "--proj-path", proj_path, "--iso-path", iso_path]
logger.debug(f"iso_data folder found: {iso_path}")
break
else:
logger.debug(f"iso_data folder not found, continuing: {iso_path}")
if not goalc_args:
msg = (f"The iso_data folder could not be found.\n"
f"Please follow these steps:\n"
f" Run the OpenGOAL Launcher, click Jak and Daxter > Advanced > Open Game Data Folder.\n"
f" Copy the iso_data folder from this location.\n"
f" Click Jak and Daxter > Features > Mods > ArchipelaGOAL > Advanced > "
f"Open Game Data Folder.\n"
f" Paste the iso_data folder in this location.\n"
f" Click Advanced > Compile. When this is done, click Continue.\n"
f" Close all launchers, games, clients, and console windows, then restart Archipelago.\n"
f"(See Setup Guide for more details.)")
ctx.on_log_error(logger, msg)
return
# The non-existence of the "data" subfolder indicates you are running it from source, as a developer.
# The compiler will traverse upward to find the project path on its own. It will also assume your
# "iso_data" folder is at the root of your repository. Therefore, we don't need any of those arguments.
else:
goalc_args = [goalc_path, "--game", "jak1"]
# This needs to be a new console. The REPL console cannot share a window with any other process.
goalc_process = subprocess.Popen(goalc_args, creationflags=subprocess.CREATE_NEW_CONSOLE)
except AttributeError as e:
if " " in e.args[0]:
# YAML keys in Host.yaml ought to contain no spaces, which means this is a much more important error.
ctx.on_log_error(logger, e.args[0])
else:
ctx.on_log_error(logger,
f"Host.yaml does not contain {e.args[0]}, unable to locate game executables.")
return
except FileNotFoundError as e:
msg = (f"The following path could not be found: {e.filename}\n"
f"Please check your host.yaml file.\n"
f"If the value of 'jakanddaxter_options > auto_detect_root_directory' is true, verify that OpenGOAL "
f"is installed properly.\n"
f"If it is false, check the value of 'jakanddaxter_options > root_directory'."
f"Verify it is a valid existing path, and all backslashes have been replaced with forward slashes.")
ctx.on_log_error(logger, msg)
return
# Auto connect the repl and memr agents. Sleep 5 because goalc takes just a little bit of time to load,
# and it's not something we can await.
ctx.on_log_info(logger, "This may take a bit... Wait for the game's title sequence before continuing!")
await asyncio.sleep(5)
ctx.repl.initiated_connect = True
ctx.memr.initiated_connect = True
async def main():
Utils.init_logging("JakAndDaxterClient", exception_logger="Client")
ctx = JakAndDaxterContext(None, None)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
ctx.repl_task = create_task_log_exception(ctx.run_repl_loop())
ctx.memr_task = create_task_log_exception(ctx.run_memr_loop())
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
# Find and run the game (gk) and compiler/repl (goalc).
create_task_log_exception(run_game(ctx))
await ctx.exit_event.wait()
await ctx.shutdown()
def launch():
# use colorama to display colored text highlighting
colorama.just_fix_windows_console()
asyncio.run(main())
colorama.deinit()

View File

@@ -0,0 +1,261 @@
# Jak And Daxter (ArchipelaGOAL)
## FAQ
- [Where is the Options page?](#where-is-the-options-page)
- [What does randomization do to this game?](#what-does-randomization-do-to-this-game)
- [What are the Special Checks and how do I check them?](#what-are-the-special-checks-and-how-do-i-check-them)
- [What are the Special Items and what do they unlock?](#what-are-the-special-items-and-what-do-they-unlock)
- [How do I know which Special Items I have?](#how-do-i-know-which-special-items-i-have)
- [What is the goal of the game once randomized?](#what-is-the-goal-of-the-game-once-randomized)
- [What happens when I pick up or receive a Power Cell?](#what-happens-when-i-pick-up-or-receive-a-power-cell)
- [What happens when I pick up or receive a Scout Fly?](#what-happens-when-i-pick-up-or-receive-a-scout-fly)
- [How do I check the 'Free 7 Scout Flies' Power Cell?](#how-do-i-check-the-free-7-scout-flies-power-cell)
- [What does Death Link do?](#what-does-death-link-do)
- [What does Move Randomizer do?](#what-does-move-randomizer-do)
- [What are the movement options in Move Randomizer?](#what-are-the-movement-options-in-move-randomizer)
- [How do I know which moves I have?](#how-do-i-know-which-moves-i-have)
- [What does Orbsanity do?](#what-does-orbsanity-do)
- [What do Traps do?](#what-do-traps-do)
- [What kind of Traps are there?](#what-kind-of-traps-are-there)
- [I got soft-locked and cannot leave, how do I get out of here?](#i-got-soft-locked-and-cannot-leave-how-do-i-get-out-of-here)
- [How do I generate seeds with 1 Orb Orbsanity and other extreme options?](#how-do-i-generate-seeds-with-1-orb-orbsanity-and-other-extreme-options)
- [How do I check my player options in-game?](#how-do-i-check-my-player-options-in-game)
- [How does the HUD work?](#how-does-the-hud-work)
- [I think I found a bug, where should I report it?](#i-think-i-found-a-bug-where-should-i-report-it)
## Where is the options page
The [Player Options Page](../player-options) for this game contains all the options you need to configure and export
a config file.
At this time, there are several caveats and restrictions:
- Power Cells and Scout Flies are **always** randomized.
- **All** the traders in the game become in-logic checks **if and only if** you have enough Orbs to pay all of them at once.
- This is to prevent hard locks, where an item required for progression is locked behind a trade you can't afford because you spent the orbs elsewhere.
- By default, that total is 1530.
## What does randomization do to this game
The game now contains the following Location checks:
- All 101 Power Cells
- All 112 Scout Flies
- All 14 Orb Caches (collect every orb in the cache and let it close)
These may contain Items for different games, as well as different Items from within Jak and Daxter.
Additionally, several special checks and corresponding items have been added that are required to complete the game.
## What are the special checks and how do I check them
| Check Name | How To Check |
|------------------------|------------------------------------------------------------------------------|
| Fisherman's Boat | Complete the fishing minigame in Forbidden Jungle |
| Jungle Elevator | Collect the power cell at the top of the temple in Forbidden Jungle |
| Blue Eco Switch | Collect the power cell on the blue vent switch in Forbidden Jungle |
| Flut Flut | Push the egg off the cliff in Sentinel Beach and talk to the bird lady |
| Warrior's Pontoons | Talk to the Warrior in Rock Village once (you do NOT have to trade with him) |
| Snowy Mountain Gondola | Approach the gondola in Volcanic Crater |
| Yellow Eco Switch | Collect the power cell on the yellow vent switch in Snowy Mountain |
| Snowy Fort Gate | Ride the Flut Flut in Snowy Mountain and press the fort gate switch |
| Freed The Blue Sage | Free the Blue Sage in Gol and Maia's Citadel |
| Freed The Red Sage | Free the Red Sage in Gol and Maia's Citadel |
| Freed The Yellow Sage | Free the Yellow Sage in Gol and Maia's Citadel |
| Freed The Green Sage | Free the Green Sage in Gol and Maia's Citadel |
## What are the special items and what do they unlock
| Item Name | What it Unlocks |
|--------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
| Fisherman's Boat | Misty Island |
| Jungle Elevator | The blue vent switch inside the temple in Forbidden Jungle |
| Blue Eco Switch | The plant boss inside the temple in Forbidden Jungle <br/> The cannon tower in Sentinel Beach |
| Flut Flut | The upper platforms in Boggy Swamp <br/> The fort gate switch in Snowy Mountain |
| Warrior's Pontoons | Boggy Swamp and Mountain Pass |
| Snowy Mountain Gondola | Snowy Mountain |
| Yellow Eco Switch | The frozen box in Snowy Mountain <br/> The shortcut in Mountain Pass |
| Snowy Fort Gate | The fort in Snowy Mountain |
| Freed The Blue Sage <br/> Freed The Red Sage <br/> Freed The Yellow Sage | The final staircase in Gol and Maia's Citadel |
| Freed The Green Sage | The final elevator in Gol and Maia's Citadel |
## How do I know which special items I have
Open the game's menu, navigate to `Options`, then `Archipelago Options`, then `Item Tracker`.
This will show you a list of all the special items in the game, ones not normally tracked as power cells or scout flies.
Gray items indicate you do not possess that item, light blue items indicate you possess that item.
## What is the goal of the game once randomized
By default, to complete the game you must defeat the Gol and Maia and stop them from opening the Dark Eco silo. In order
to reach them, you will need at least 72 Power Cells to cross the Lava Tube, as well as the four special items for
freeing the Red, Blue, Yellow, and Green Sages.
Alternatively, you can choose from a handful of other completion conditions like defeating a particular boss, crossing
a particular connector level, or opening the 100 Power Cell door after defeating the final boss. You can also customize
the thresholds for connector levels and orb trades. These options allow you to tailor the expected length and difficulty
of your run as you see fit.
## What happens when I pick up or receive a power cell
When you pick up a power cell, Jak and Daxter will perform their victory animation. Your power cell count will
NOT change. The pause menu will say "Task Completed" below the picked-up Power Cell. If your power cell was related
to one of the special checks listed above, you will automatically check that location as well - a 2 for 1 deal!
Finally, your text client will inform you what you found and who it belongs to.
When you receive a power cell, your power cell count will tick up by 1. Gameplay will otherwise continue as normal.
Finally, your text client will inform you where you received the power cell from.
## What happens when I pick up or receive a scout fly
When you pick up a scout fly, your scout fly count will NOT change. The pause menu will show you the number of
scout flies you picked up per-region, and this number will have ticked up by 1 for the region that scout fly belongs to.
Finally, your text client will inform you what you found and who it belongs to.
When you receive a scout fly, your total scout fly count will tick up by 1. The pause menu will show you the number of
scout flies you received per-region, and this number will have ticked up by 1 for the region that scout fly belongs to.
Finally, your text client will inform you where you received the scout fly from, and which one it is.
## How do I check the Free 7 Scout Flies power cell
You will automatically check this power cell when you _receive_ your 7th scout fly, NOT when you _pick up_ your 7th
scout fly. So in short:
- When you _pick up_ your 7th fly, the normal rules apply.
- When you _receive_ your 7th fly, 2 things will happen in quick succession.
- First, you will receive that scout fly, as normal.
- Second, you will immediately complete the "Free 7 Scout Flies" check, which will send out another item.
## What does Death Link do
If you enable Death Link, all the other players in your Multiworld who also have it enabled will be linked by death.
That means when Jak dies in your game, the players in with Death Link also die. Likewise, if any of the other
players with Death Link die, Jak will also die in a random, possibly spectacular fashion.
You can turn off Death Link at any time in the game by opening the game's menu and navigating to `Options`,
then `Archipelago Options`, then `Deathlink`.
## What does Move Randomizer do
If you enable Move Randomizer, most of Jak's movement set will be added to the randomized item pool, and you will need
to receive the move in order to use it (i.e. you must find it, or another player must send it to you). Some moves have
prerequisite moves that you must also have in order to use them (e.g. Crouch Jump is dependent on Crouch). Jak will only
be able to run, swim (including underwater), perform single jumps, and shoot yellow eco from his goggles ("firing from
the hip" requires Punch). Note that Flut Flut and the Zoomer will have access to their full movement sets at all times.
You can turn off Move Rando at any time in the game by opening the game's menu, navigate to `Options`,
then `Archipelago Options`, then `Move Randomizer`. This will give you access to the full movement set again.
## What are the movement options in Move Randomizer
| Move Name | Prerequisite Moves |
|-----------------|--------------------|
| Crouch | |
| Crouch Jump | Crouch |
| Crouch Uppercut | Crouch |
| Roll | |
| Roll Jump | Roll |
| Double Jump | |
| Jump Dive | |
| Jump Kick | |
| Punch | |
| Punch Uppercut | Punch |
| Kick | |
## How do I know which moves I have
Open the game's menu, navigate to `Options`, then `Archipelago Options`, then `Move Tracker`.
This will show you a list of all the moves in the game.
- Gray items indicate you do not possess that move.
- Yellow items indicate you possess that move, but you are missing its prerequisites.
- Light blue items indicate you possess that move, as well as its prerequisites.
## What does Orbsanity do
If you enable Orbsanity, bundles of Precursor Orbs will be turned into checks. Every time you collect the chosen number
of orbs, i.e. a "bundle," you will trigger another check. Likewise, the orbs will be added to the random item pool.
There are several options to change the difficulty of this challenge.
- "Per Level" Orbsanity means the bundles are for each level in the game. (Geyser Rock, Sandover Village, etc.)
- "Global" Orbsanity means orbs collected from any level count toward the next bundle.
- The options with "Bundle Size" in the name indicate how many orbs are in a bundle. This adds a number of Items
and Locations to the pool inversely proportional to the size of the bundle.
- For example, if your bundle size is 20 orbs, you will add 100 items to the pool. If your bundle size is 250 orbs,
you will add 8 items to the pool.
## What do Traps do
When creating your player YAML, you can choose to replace some of the game's extraneous Power Cells and Precursor Orbs
with traps. You can choose which traps you want to generate in your seed and how long they last. A random assortment
will then be chosen to populate the item pool.
When you receive one, you will hear a buzzer and some kind of negative effect will occur in game. These effects may be
challenging, maddening, or entertaining. When the trap duration ends, the game should return to its previous state.
Multiple traps can be active at the same time, and they may interact with each other in strange ways. If they become
too frustrating, you can lower their duration by navigating to `Options`, then `Archipelago Options`, then
`Seed Options`, then `Trap Duration`. Lowering this number to zero will disable traps entirely.
## What kind of Traps are there
| Trap Name | Effect |
|-----------------|--------------------------------------------------------------------------------|
| Trip Trap | Jak trips and falls |
| Slippery Trap | The world gains the physical properties of Snowy Mountain's ice lake |
| Gravity Trap | Jak falls to the ground faster and takes fall damage more easily |
| Camera Trap | The camera remains fixed in place no matter how far away Jak moves |
| Darkness Trap | The world gains the lighting properties of Dark Cave |
| Earthquake Trap | The world and camera shake |
| Teleport Trap | Jak immediately teleports to Samos's Hut |
| Despair Trap | The Warrior sobs profusely |
| Pacifism Trap | Jak's attacks have no effect on enemies, crates, or buttons |
| Ecoless Trap | Jak's eco is drained and he cannot collect new eco |
| Health Trap | Jak's health is set to 0 - not dead yet, but he will die to any attack or bonk |
| Ledge Trap | Jak cannot grab onto ledges |
| Zoomer Trap | Jak mounts an invisible zoomer (model loads properly depending on level) |
| Mirror Trap | The world is mirrored |
## I got soft-locked and cannot leave how do I get out of here
Open the game's menu, navigate to `Options`, then `Archipelago Options`, then `Warp To Home`.
Selecting this option will ask if you want to be teleported to Geyser Rock. From there, you can teleport back
to the nearest sage's hut to continue your journey.
## How do I generate seeds with 1 orb orbsanity and other extreme options?
Depending on your player YAML, Jak and Daxter can have a lot of items, which can sometimes be overwhelming or
disruptive to multiworld games. There are also options that are mutually incompatible with each other, even in a solo
game. To prevent the game from disrupting multiworlds, or generating an impossible solo seed, some options have
"friendly limits" that prevent you from choosing more extreme values.
You can override **some**, not all, of those limits by editing the `host.yaml`. In the Archipelago Launcher, click
`Open host.yaml`, then search for `jakanddaxter_options`, then search for `enforce_friendly_options`, then change this
value from `true` to `false`. You can then generate a seed locally, and upload that to the Archipelago website to host
for you (or host it yourself).
**Remember:** disabling this setting allows for more disruptive and challenging options, but it may cause seed
generation to fail. **Use at your own risk!**
## How do I check my player options in-game
When you connect your text client to the Archipelago Server, the server will tell the game what options were chosen
for this seed, and the game will apply those settings automatically.
You can verify these options by navigating to `Options`, then `Archipelago Options`, then `Seed Options`. **You can open
each option to verify them, but you should NOT alter them during a run.** This may cause you to miss important
progression items and prevent you (and others) from completing the run.
## How does the HUD work
The game's normal HUD shows you how many power cells, precursor orbs, and scout flies you currently have. But if you
hold `L2 or R2` and press a direction on the D-Pad, the HUD will show you alternate modes. Here is how the HUD works:
| HUD Mode | Button Combo | What the HUD Shows | Text Messages |
|---------------|------------------------------|-----------------------------------|---------------------------------------|
| Per-Level | `L2 or R2` + `Down` | Locations Checked (in this level) | `SENT {Other Item} TO {Other Player}` |
| Global | `L2 or R2` + `Up` | Locations Checked (in the game) | `GOT {Your Item} FROM {Other Player}` |
| Normal | `L2 or R2` + `Left or Right` | Items Received | Both Sent and Got Messages |
| | | | |
| (In Any Mode) | | (If you sent an Item to Yourself) | `FOUND {Your Item}` |
In all modes, the last 3 sent/received items and the player who sent/received it will be displayed in the
bottom left corner. This will help you quickly reference information about newly received or sent items. Items in blue
are Progression (or non-Jak items), in green are Filler, and in red are Traps. You can turn this off by navigating
to `Options`, then `Archipelago Options`, then set `Item Messages` to `Off`.
## I think I found a bug where should I report it
Depending on the nature of the bug, there are a couple of different options.
* If you found a logical error in the randomizer, please create a new Issue
[here](https://github.com/ArchipelaGOAL/Archipelago/issues). Use this page if:
* An item required for progression is unreachable.
* The randomizer did not respect one of the Options you chose.
* You see a mistake, typo, etc. on this webpage.
* You see an error or stack trace appear on the text client.
* If you encountered an error in OpenGOAL, please create a new Issue
[here](https://github.com/ArchipelaGOAL/ArchipelaGOAL/issues). Use this page if:
* You encounter a crash, freeze, reset, etc. in the game.
* You fail to send Items you find in the game to the Archipelago server.
* You fail to receive Items the server sends to you.
* Your game disconnects from the server and cannot reconnect.
* You go looking for a game item that has already disappeared before you could reach it.
* Please upload your config file, spoiler log file, and any other generated logs in the Issue, so we can troubleshoot the problem.

View File

@@ -0,0 +1,181 @@
# Jak And Daxter (ArchipelaGOAL) Setup Guide
## Required Software
- A legally purchased copy of *Jak And Daxter: The Precursor Legacy.*
- [The OpenGOAL Launcher](https://opengoal.dev/)
At this time, this method of setup works on Windows only, but Linux support is a strong likelihood in the near future as OpenGOAL itself supports Linux.
## Installation via OpenGOAL Launcher
**You must set up a vanilla installation of Jak and Daxter before you can install mods for it.**
- Follow the installation process for the official OpenGOAL Launcher. See [here](https://opengoal.dev/docs/usage/installation).
- Follow the setup process for adding mods to the OpenGOAL Launcher. See [here](https://jakmods.dev/).
- Run the OpenGOAL Launcher (if you had it open before, close it and reopen it).
- Click the Jak and Daxter logo on the left sidebar.
- Click `Features` in the bottom right corner, then click `Mods`.
- Under `Available Mods`, click `ArchipelaGOAL`. The mod should begin installing. When it is done, click `Continue` in the bottom right corner.
- **DO NOT PLAY AN ARCHIPELAGO GAME THROUGH THE OPENGOAL LAUNCHER.** The Archipelago Client should handle everything for you.
### For NTSC versions of the game, follow these steps.
- Run the OpenGOAL Launcher (if you had it open before, close it and reopen it).
- Click the Jak and Daxter logo on the left sidebar.
- Click `Features` in the bottom right corner, then click `Mods`, then under `Installed Mods`, click `ArchipelaGOAL`.
- In the bottom right corner, click `Advanced`, then click `Compile`.
### For PAL versions of the game, follow these steps.
PAL versions of the game seem to require additional troubleshooting/setup in order to work properly.
Below are some instructions that may help.
If you see `-- Compilation Error! --` after pressing `Compile` or Launching the ArchipelaGOAL mod, try these steps.
- Remove these folders if you have them:
- `<opengoal active version directory>/iso_data`
- `<archipelagoal directory>/iso_data`
- `<archipelagoal directory>/data/iso_data`
- Place your Jak1 ISO in `<archipelagoal directory>` and rename it to `JakAndDaxter.iso`
- Type `cmd` in Windows search, right click `Command Prompt`, and pick `Run as Administrator`
- Run `cd <archipelagoal directory>`
- Then run `.\extractor.exe --extract --extract-path .\data\iso_data "JakAndDaxter.iso"`
- This command should end by saying `Uses Decompiler Config Version - ntsc_v1` or `... - pal`. Take note of this message.
- If you saw `ntsc_v1`:
- In cmd, run `.\decompiler.exe data\decompiler\config\jak1\jak1_config.jsonc --version "ntsc_v1" data\iso_data data\decompiler_out`
- If you saw `pal`:
- Rename `<archipelagoal directory>\data\iso_data\jak1` to `jak1_pal`
- Back in cmd, run `.\decompiler.exe data\decompiler\config\jak1\jak1_config.jsonc --version "pal" data\iso_data data\decompiler_out`
- Rename `<archipelagoal directory>\data\iso_data\jak1_pal` back to `jak1`
- Rename `<archipelagoal directory>\data\decompiler_out\jak1_pal` back to `jak1`
- Open a **brand new** console window and launch the compiler:
- `cd <archipelagoal directory>`
- `.\goalc.exe --user-auto --game jak1`
- From the compiler (in the same window): `(mi)`. This should compile the game. **Note that the parentheses are important.**
- **Don't close this first terminal, you will need it at the end.**
- Then, open **another brand new** console window and execute the game:
- `cd <archipelagoal directory>`
- `.\gk.exe -v --game jak1 -- -boot -fakeiso -debug`
- Finally, **from the first console still in the GOALC compiler**, connect to the game: `(lt)`.
## Updates and New Releases via OpenGOAL Launcher
If you are in the middle of an async game, and you do not want to update the mod, you do not need to do this step. The mod will only update when you tell it to.
- Run the OpenGOAL Launcher (if you had it open before, close it and reopen it).
- Click the Jak and Daxter logo on the left sidebar.
- Click `Features` in the bottom right corner, then click `Mods`, then under `Installed Mods`, click `ArchipelaGOAL`.
- Click `Update` to download and install any new updates that have been released.
- You can verify your version by clicking `Versions`. The version you are using will say `(Active)` next to it.
- **Then you must click `Advanced`, then click `Compile` to make the update take effect.**
## Starting a Game
### New Game
- Run the Archipelago Launcher.
- From the client list, find and click `Jak and Daxter Client`.
- 3 new windows should appear:
- The OpenGOAL compiler will launch and compile the game. They should take about 30 seconds to compile.
- You should hear a musical cue to indicate the compilation was a success. If you do not, see the Troubleshooting section.
- You can **MINIMIZE** the Compiler window, **BUT DO NOT CLOSE IT.** It is required for Archipelago and the game to communicate with each other.
- The game window itself will launch, and Jak will be standing outside Samos's Hut.
- Once compilation is complete, the title sequence will start.
- Finally, the Archipelago text client will open.
- If you see **BOTH** `The REPL is ready!` and `The Memory Reader is ready!` then that should indicate a successful startup. If you do not, see the Troubleshooting section.
- Once you see `CONNECT TO ARCHIPELAGO NOW` on the title screen, use the text client to connect to the Archipelago server. This will communicate your current settings and slot info to the game.
- If you see `RECEIVING ITEMS, PLEASE WAIT...`, the game is busy receiving items from your starting inventory, assuming you have some.
- Once you see `READY! PRESS START TO CONTINUE` on the title screen, you can press Start.
- Choose `New Game`, choose a save file, and play through the opening cutscenes.
- Once you reach Geyser Rock, the game has begun!
- You can leave Geyser Rock immediately if you so choose - just step on the warp gate button.
### Returning / Async Game
The same steps as New Game apply, with some exceptions:
- Once you reach the title screen, connect to the Archipelago server **BEFORE** you load your save file.
- This is to allow AP to give the game your current settings and all the items you had previously.
- **THESE SETTINGS AFFECT LOADING AND SAVING OF SAVE FILES, SO IT IS IMPORTANT TO DO THIS FIRST.**
- Once you see `READY! PRESS START TO CONTINUE` on the title screen, you can press Start.
- Instead of choosing `New Game` in the title menu, choose `Load Game`, then choose the save file **THAT HAS YOUR CURRENT SLOT NAME.**
- To help you find the correct save file, highlighting a save will show you that save's slot name and the first 8 digits of the multiworld seed number.
## Troubleshooting
### The Text Client Says "Unable to locate the OpenGOAL install directory"
Normally, the Archipelago client should be able to find your OpenGOAL installation automatically.
If it cannot, you may have to tell it yourself. Follow these instructions.
- Run the OpenGOAL Launcher (if you had it open before, close it and reopen it).
- Click the Jak and Daxter logo on the left sidebar.
- Click `Features` in the bottom right corner, then click `Mods`, then under `Installed Mods`, click `ArchipelaGOAL`.
- Click `Advanced` in the bottom right corner, then click `Open Game Data Folder`. You should see a new File Explorer open to that directory.
- In the File Explorer, go to the parent directory called `archipelagoal`, and you should see the `gk.exe` and `goalc.exe` executables. Copy this path.
- Run the Archipelago Launcher, then click on `Open host.yaml`. You should see a new text editor open that file.
- Search for `jakanddaxter_options`, and you will need to make 2 changes here.
- First, find the `root_directory` entry. Paste the path you noted earlier (the one containing gk.exe and goalc.exe) inside the double quotes.
- **MAKE SURE YOU CHANGE ALL BACKSLASHES `\ ` TO FORWARD SLASHES `/`.**
```yaml
root_directory: "%programfiles%/OpenGOAL-Launcher/features/jak1/mods/JakMods/archipelagoal"
```
- Second, find the `root_directory` entry. Change this to `false`. You do not need to use double quotes.
```yaml
auto_detect_root_directory: true
```
- Save the file and close it.
### The Game Fails To Load The Title Screen
You may start the game via the Text Client, but it never loads in the title screen. Check the Compiler window: you may see red and yellow errors like this.
```
-- Compilation Error! --
```
If this happens, follow these instructions. If you are using a PAL version of the game, you should skip these instructions and follow the `Special PAL Instructions` above.
- Run the OpenGOAL Launcher (if you had it open before, close it and reopen it).
- Click the Jak and Daxter logo on the left sidebar, then click `Advanced`, then click `Open Game Data Folder`. Copy the `iso_data` folder from this directory.
- Back in the OpenGOAL Launcher, click the Jak and Daxter logo on the left sidebar.
- Click `Features` in the bottom right corner, then click `Mods`, then under `Installed Mods`, click `ArchipelaGOAL`.
- In the bottom right corner, click `Advanced`, then click `Open Game Data Folder`.
- Paste the `iso_data` folder you copied earlier.
- Back in the OpenGOAL Launcher, click the Jak and Daxter logo on the left sidebar.
- Click `Features` in the bottom right corner, then click `Mods`, then under `Installed Mods`, click `ArchipelaGOAL`.
- In the bottom right corner, click `Advanced`, then click `Compile`.
### The Text Client Says "Error reading game memory!" or "Error sending data to compiler"
If at any point the text client says this, you will need to restart the **all** of these applications.
- Close all open windows: the client, the compiler, and the game.
- Run the OpenGOAL Launcher, then click `Features`, then click `Mods`, then click `ArchipelaGOAL`.
- Click `Advanced`, then click `Play in Debug Mode`.
- Click `Advanced`, then click `Open REPL`.
- Then close and reopen the Jak and Daxter Client from the Archipelago Launcher.
- Once these are done, you can enter `/repl status` and `/memr status` in the text client to verify.
### The Client Cannot Open A REPL Connection
If the client cannot open a REPL connection to the game, you may need to check the following steps:
- Ensure you are not hosting anything on ports `8181` and `8112`. Those are for the REPL (goalc) and the game (gk) respectively.
- Ensure that Windows Defender and Windows Firewall are not blocking those programs from hosting or listening on those ports.
- You can use Windows Resource Monitor to verify those ports are open when the programs are running.
- Ensure that you only opened those ports for your local network, not the wider internet.
## Known Issues
- The game needs to boot in debug mode in order to allow the compiler to connect to it. **Clicking "Play" on the mod page in the OpenGOAL Launcher will not work.**
- The Compiler console window is orphaned once you close the game - you will have to kill it manually when you stop playing.
- The console windows cannot be run as background processes due to how the REPL works, so the best we can do is minimize them.
- Orbsanity checks may show up out of order in the text client.
- Large item releases may take up to several minutes for the game to process them all. Item Messages will usually take longer to appear than Items themselves.
- In Lost Precursor City, if you die in the Color Platforms room, the game may crash after you respawn. The cause is unknown.
- Darkness Trap may cause some visual glitches on certain levels. This is temporary, and terrain and object collision are unaffected.

View File

@@ -0,0 +1,8 @@
# All Jak And Daxter Archipelago IDs must be offset by this number.
jak1_id = 741000000
# This is maximum ID we will allow.
jak1_max = jak1_id + 999999
# The name of the game.
jak1_name = "Jak and Daxter: The Precursor Legacy"

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,156 @@
from enum import IntEnum
from BaseClasses import Item, ItemClassification
from .game_id import jak1_name, jak1_max
from .locs import (orb_locations as orbs,
cell_locations as cells,
scout_locations as scouts,
special_locations as specials,
orb_cache_locations as caches)
class OrbAssoc(IntEnum):
"""
Identifies an item's association to unlocking new sources of Precursor Orbs. For example, Double Jump will unlock
new orbs, but Freed the Green Sage will not. Power Cells conditionally unlock new orbs if they get you across
connector levels.
"""
NEVER_UNLOCKS_ORBS = 0
ALWAYS_UNLOCKS_ORBS = 1
IS_POWER_CELL = 2
class JakAndDaxterItem(Item):
game: str = jak1_name
orb_assoc: OrbAssoc
orb_amount: int # Only non-zero for Orb Bundle items.
def __init__(self, name: str,
classification: ItemClassification,
code: int | None,
player: int,
orb_assoc: OrbAssoc = OrbAssoc.NEVER_UNLOCKS_ORBS,
orb_amount: int = 0):
super().__init__(name, classification, code, player)
self.orb_assoc = orb_assoc
self.orb_amount = orb_amount
# Power Cells are generic, fungible, interchangeable items. Every cell is indistinguishable from every other.
cell_item_table = {
0: "Power Cell",
}
# Scout flies are interchangeable within their respective sets of 7. Notice the level name after each item.
# Also, notice that their Item ID equals their respective Power Cell's Location ID. This is necessary for
# game<->archipelago communication.
scout_item_table = {
95: "Scout Fly - Geyser Rock",
75: "Scout Fly - Sandover Village",
7: "Scout Fly - Forbidden Jungle",
20: "Scout Fly - Sentinel Beach",
28: "Scout Fly - Misty Island",
68: "Scout Fly - Fire Canyon",
76: "Scout Fly - Rock Village",
57: "Scout Fly - Precursor Basin",
49: "Scout Fly - Lost Precursor City",
43: "Scout Fly - Boggy Swamp",
88: "Scout Fly - Mountain Pass",
77: "Scout Fly - Volcanic Crater",
85: "Scout Fly - Spider Cave",
65: "Scout Fly - Snowy Mountain",
90: "Scout Fly - Lava Tube",
91: "Scout Fly - Citadel", # Had to shorten, it was >32 characters.
}
# Orbs are also generic and interchangeable.
# These items are only used by Orbsanity, and only one of these
# items will be used corresponding to the chosen bundle size.
orb_item_table = {
1: "1 Precursor Orb",
2: "2 Precursor Orbs",
4: "4 Precursor Orbs",
5: "5 Precursor Orbs",
8: "8 Precursor Orbs",
10: "10 Precursor Orbs",
16: "16 Precursor Orbs",
20: "20 Precursor Orbs",
25: "25 Precursor Orbs",
40: "40 Precursor Orbs",
50: "50 Precursor Orbs",
80: "80 Precursor Orbs",
100: "100 Precursor Orbs",
125: "125 Precursor Orbs",
200: "200 Precursor Orbs",
250: "250 Precursor Orbs",
400: "400 Precursor Orbs",
500: "500 Precursor Orbs",
1000: "1000 Precursor Orbs",
2000: "2000 Precursor Orbs",
}
# These are special items representing unique unlocks in the world. Notice that their Item ID equals their
# respective Location ID. Like scout flies, this is necessary for game<->archipelago communication.
special_item_table = {
5: "Fisherman's Boat", # Unlocks Misty Island
4: "Jungle Elevator", # Unlocks the Forbidden Jungle Temple
2: "Blue Eco Switch", # Unlocks Blue Eco Vents
17: "Flut Flut", # Unlocks Flut Flut sections in Boggy Swamp and Snowy Mountain
33: "Warrior's Pontoons", # Unlocks Boggy Swamp and everything post-Rock Village
105: "Snowy Mountain Gondola", # Unlocks Snowy Mountain
60: "Yellow Eco Switch", # Unlocks Yellow Eco Vents
63: "Snowy Fort Gate", # Unlocks the Snowy Mountain Fort
71: "Freed The Blue Sage", # 1 of 3 unlocks for the final staircase in Citadel
72: "Freed The Red Sage", # 1 of 3 unlocks for the final staircase in Citadel
73: "Freed The Yellow Sage", # 1 of 3 unlocks for the final staircase in Citadel
70: "Freed The Green Sage", # Unlocks the final boss elevator in Citadel
}
# These are the move items for move randomizer. Notice that their Item ID equals some of the Orb Cache Location ID's.
# This was 100% arbitrary. There's no reason to tie moves to orb caches except that I need a place to put them. ;_;
move_item_table = {
10344: "Crouch",
10369: "Crouch Jump",
11072: "Crouch Uppercut",
12634: "Roll",
12635: "Roll Jump",
10945: "Double Jump",
14507: "Jump Dive",
14838: "Jump Kick",
23348: "Punch",
23349: "Punch Uppercut",
23350: "Kick",
# 24038: "Orb Cache at End of Blast Furnace", # Hold onto these ID's for future use.
# 24039: "Orb Cache at End of Launch Pad Room",
# 24040: "Orb Cache at Start of Launch Pad Room",
}
# These are trap items. Their Item ID is to be subtracted from the base game ID. They do not have corresponding
# game locations because they are intended to replace other items that have been marked as filler.
trap_item_table = {
1: "Trip Trap",
2: "Slippery Trap",
3: "Gravity Trap",
4: "Camera Trap",
5: "Darkness Trap",
6: "Earthquake Trap",
7: "Teleport Trap",
8: "Despair Trap",
9: "Pacifism Trap",
10: "Ecoless Trap",
11: "Health Trap",
12: "Ledge Trap",
13: "Zoomer Trap",
14: "Mirror Trap",
}
# All Items
# While we're here, do all the ID conversions needed.
item_table = {
**{cells.to_ap_id(k): name for k, name in cell_item_table.items()},
**{scouts.to_ap_id(k): name for k, name in scout_item_table.items()},
**{specials.to_ap_id(k): name for k, name in special_item_table.items()},
**{caches.to_ap_id(k): name for k, name in move_item_table.items()},
**{orbs.to_ap_id(k): name for k, name in orb_item_table.items()},
**{jak1_max - k: name for k, name in trap_item_table.items()},
jak1_max: "Green Eco Pill" # Filler item.
}

View File

@@ -0,0 +1,76 @@
# This contains the list of levels in Jak and Daxter.
# Not to be confused with Regions - there can be multiple Regions in every Level.
level_table = {
"Geyser Rock": {
"level_index": 0,
"orbs": 50
},
"Sandover Village": {
"level_index": 1,
"orbs": 50
},
"Sentinel Beach": {
"level_index": 2,
"orbs": 150
},
"Forbidden Jungle": {
"level_index": 3,
"orbs": 150
},
"Misty Island": {
"level_index": 4,
"orbs": 150
},
"Fire Canyon": {
"level_index": 5,
"orbs": 50
},
"Rock Village": {
"level_index": 6,
"orbs": 50
},
"Lost Precursor City": {
"level_index": 7,
"orbs": 200
},
"Boggy Swamp": {
"level_index": 8,
"orbs": 200
},
"Precursor Basin": {
"level_index": 9,
"orbs": 200
},
"Mountain Pass": {
"level_index": 10,
"orbs": 50
},
"Volcanic Crater": {
"level_index": 11,
"orbs": 50
},
"Snowy Mountain": {
"level_index": 12,
"orbs": 200
},
"Spider Cave": {
"level_index": 13,
"orbs": 200
},
"Lava Tube": {
"level_index": 14,
"orbs": 50
},
"Gol and Maia's Citadel": {
"level_index": 15,
"orbs": 200
}
}
level_table_with_global = {
**level_table,
"": {
"level_index": 16, # Global
"orbs": 2000
}
}

View File

@@ -0,0 +1,66 @@
from BaseClasses import Location
from .game_id import jak1_name
from .locs import (orb_locations as orbs,
cell_locations as cells,
scout_locations as scouts,
special_locations as specials,
orb_cache_locations as caches)
class JakAndDaxterLocation(Location):
game: str = jak1_name
# Different tables for location groups.
# Each Item ID == its corresponding Location ID. While we're here, do all the ID conversions needed.
cell_location_table = {
**{cells.to_ap_id(k): name for k, name in cells.loc7SF_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locGR_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locSV_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locFJ_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locSB_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locMI_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locFC_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locRV_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locPB_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locLPC_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locBS_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locMP_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locVC_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locSC_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locSM_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locLT_cellTable.items()},
**{cells.to_ap_id(k): name for k, name in cells.locGMC_cellTable.items()},
}
scout_location_table = {
**{scouts.to_ap_id(k): name for k, name in scouts.locGR_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locSV_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locFJ_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locSB_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locMI_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locFC_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locRV_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locPB_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locLPC_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locBS_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locMP_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locVC_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locSC_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locSM_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locLT_scoutTable.items()},
**{scouts.to_ap_id(k): name for k, name in scouts.locGMC_scoutTable.items()},
}
special_location_table = {specials.to_ap_id(k): name for k, name in specials.loc_specialTable.items()}
cache_location_table = {caches.to_ap_id(k): name for k, name in caches.loc_orbCacheTable.items()}
orb_location_table = {orbs.to_ap_id(k): name for k, name in orbs.loc_orbBundleTable.items()}
# All Locations
location_table = {
**cell_location_table,
**scout_location_table,
**special_location_table,
**cache_location_table,
**orb_location_table
}

View File

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