Compare commits

...

360 Commits
0.3.6 ... 0.4.0

Author SHA1 Message Date
Fabian Dill
8971340a66 Core: update version to 0.4.0 2023-03-31 14:43:05 +02:00
zig-for
30cfd3186c LADX: Fix local paths (#1634) 2023-03-31 14:05:51 +02:00
Chris Wilson
1dc4e2b44b Restore "random" option to weighted-settings (#1635)
* Restore "random" option to weighted-settings, adjust capitalization of hardcoded settings

* Set default value as "random" for Choice, TextChoice, and Toggle options with no default value
2023-03-30 16:01:31 -07:00
alwaysintreble
d5b4a91a13 The Messenger: Have users explicitly remove old version (#1632) 2023-03-30 23:18:56 +02:00
alwaysintreble
bf5282dfa8 add Toggle options back to player settings and remove unnecessary check (#1633) 2023-03-30 16:56:26 -04:00
agilbert1412
4eea91daab Stardew Valley: Improve Documentation (#1631) 2023-03-30 16:25:25 +02:00
Wilhelm Schürmann
20e80d06cf LADX: Add Lua connector for BizHawk (#1579)
This is a Lua script for BizHawk that implements the relevant parts of
the RetroArch networking API used by the Archipelago LADX Client.

socket.lua and core.dll are exact copies of the same files in
data/lua/OOT and various other folders. There is a PR consolidating
these into the base folder, which this commit is anticipating.

LADX "just works"(tm) when this is loaded in Bizhawk.
2023-03-30 15:55:38 +02:00
Rosalie-A
59b78528a9 TLoZ: Fix description and off-by-one error (#1625) 2023-03-30 15:31:16 +02:00
Fabian Dill
cd4fd18706 Core: update modules (#1612) 2023-03-30 15:30:43 +02:00
KernRat
af44c1ba3d Factorio: Fixed outdated energy link troughput in docs (#1626) 2023-03-30 15:30:25 +02:00
Alchav
3ef0a56ec2 Pokémon R/B: Another quiz fix 2023-03-30 15:29:55 +02:00
t3hf1gm3nt
4ff282a384 [TLOZ] Pols Voice Logic Fix (#1630)
* TLOZ: Pols Voice Logic Fix

Was informed that Pols Voice require certain weapons to kill that might not be guaranteed by the starting weapon. This is the only regular enemy in the game that cannot be killed by either any of the starting weapons or bombs which can be bought, so adding this rule should prevent any issues.
2023-03-30 15:29:06 +02:00
Chris Wilson
f3dad894ec Update ArchipIDLE item count to 200, and add a few more items (#1627) 2023-03-29 18:27:35 -07:00
Chris Wilson
a5373e3672 [WebHost] Add support for items-list, locations-list, and custom-list option types to weighted-settings (#1614)
* Add support for items-list, locations-list, and custom-list option types to weighted-settings

* Fix subclass detection for `items-list`, `locations-list`, and `custom-list` in weighted-settings

* Move specially handled options alongside each other in the UI, and split them into logical groupings

* Fix header text and location for Priority an Exclusion locations

* Add universally supported "random" option to `Choice` and `TextChoice` options in weighted-settings

* Update description text for exclusion items to clarify they also prevent helpful items.

* Be technically correct and call them `useful` items.
2023-03-29 14:37:39 -07:00
black-sliver
639606e0be worlds,clients,kvui: use user_path (#1622) 2023-03-29 20:14:45 +02:00
zig-for
bb79073ce7 LADX: Fix autotracking for shop items (#1623) 2023-03-29 14:56:10 +02:00
zig-for
53b3cd029e LADX: Add options for sword music and nag messages (#1621) 2023-03-29 14:48:10 +02:00
zig-for
99bd525c8e LADX: use verify() to verify sprites (#1620) 2023-03-29 14:42:08 +02:00
zig-for
d14ab97849 LADX: Fix gen failures with non-ascii player names, fix missing custom (#1619) 2023-03-29 14:41:11 +02:00
zig-for
f50e85b401 LADX: Fix repeated rupee adds overwriting instead of adding (#1618) 2023-03-29 14:40:19 +02:00
Fabian Dill
b64565594a kvui: block settings menu 2023-03-29 14:39:06 +02:00
JaredWeakStrike
ae7dad8bf9 Fixed Blacklist and python 3.8 support (#1616) 2023-03-28 18:02:06 +02:00
alwaysintreble
b7c74919b7 Spoiler: add player name to sphere 0 items in playthrough (#1607) 2023-03-27 19:42:15 +02:00
Jarno
a7f7f91aaf Timespinner: LOGIC FIX, RC BUG (#1610) 2023-03-27 19:17:50 +02:00
JaredWeakStrike
e62f989ce8 Kh2 rc2 fixes (#1608) 2023-03-27 19:17:06 +02:00
Chris Wilson
21c6c28755 [WebHost] Fix weighted-settings UI incorrectly populating range values (#1602)
* Fix a bug causing weighted-settings UI to incorrectly display range values with no default as having a value of 25.

* Do not set min and max values of range options to zero by default. This causes clutter in saved `.yaml` files.
2023-03-27 08:55:13 -07:00
Alchav
f0403b9c9d Pokémon R/B: Sort fix 2023-03-27 15:28:17 +02:00
Chris Wilson
f09f3663d6 [WebHost] Unify style and behavior of popover and mobile menus (#1596)
* [WebHost] Unify styles for popover and mobile menus. Adjust popover menu to function the same as the mobile menu, and toggle via JS.

* Remove class `first-link` in favor of CSS `:first-child`.

* Adjust mobile menu link font size and padding. Change wording of popover trigger text. Add border-right to popover menu. Change "Upload Host File" to "Host Game"

* Change mobile menu text to "Host Game"
2023-03-27 00:12:10 -04:00
Brooty Johnson
b5bd93c420 fixed duplicate location (#1604)
removed duplicate location for "PW: Vilhelm's Leggings"
2023-03-27 03:04:04 +02:00
Fabian Dill
90813c0f4b Test: explicitly make sure there is no double use of location Name or ID (#1605) 2023-03-26 16:04:13 -07:00
JusticePS
e2c4293a6d Adventure: Fix basepatch access from other working directories (#1600) 2023-03-26 19:34:18 +02:00
Payden K. Pringle
963c33c02a Docs: Update BizHawk Open Script wording to be clearer. (#1599) 2023-03-26 19:34:00 +02:00
black-sliver
7d603e7d8d CI: run build_exe twice (#1598)
* CI: run build_exe twice ...

... to find broken conditional imports

* CI: build again in venv
2023-03-26 00:54:56 +01:00
black-sliver
f2e1495d39 Setup: fix broken cx_freeze import 2023-03-26 00:20:14 +01:00
Tarokarr
7927b2ee25 LADX: Added: Credits for sprite sheets (#1594) 2023-03-25 22:29:59 +01:00
alwaysintreble
4f2b13a674 The Messenger, Docs: Change links for the release (#1595)
* change links to point to my fork

* Add documentation about reconnecting because that's necessary apparently

* change the bug report link

* be non ambiguous

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

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-03-25 22:11:51 +01:00
black-sliver
ffd7d5da74 ModuleUpdate/Setup: install pkg_resources, check pip, typing and cleanup (#1593)
* ModuleUpdater/setup: install pkg_resources and check for pip

plus minor cleanup in the github actions

* ModuleUpdate/setup: make flake8 happy

* ModuleUpdate/setup: make mypy happier
2023-03-25 19:54:42 +01:00
Trevor L
67eb370200 Blasphemous: Change ending option (#1592) 2023-03-25 19:37:25 +01:00
JaredWeakStrike
4456e36fbb KH2: fixes medal being shadow archive memory address (#1589) 2023-03-25 19:36:48 +01:00
ScootyPuffJr1
7fd9e71b3c TLOZ: Fix Broken Links to Player Settings Page 2023-03-25 19:36:13 +01:00
alwaysintreble
f4a68f1c3d Core: add an everywhere location group (#1582) 2023-03-25 19:35:19 +01:00
Fabian Dill
754a57cf69 WebHost: give active rooms a chance to reclaim their port 2023-03-25 19:34:09 +01:00
lordlou
384577e421 SM: cx_freeze fix (#1584) 2023-03-25 19:30:38 +01:00
alwaysintreble
0ed3865c30 The Messenger: Fix a missing location rule and missing known issue (#1586)
* fix missing rule

* document a missing known issue

* fix a break when shuffle seals is off

* test the thing i just fixed

* invert the if so it's a bit faster
2023-03-25 00:55:24 +01:00
zig-for
77b2ed54a6 LADX: Fix D6 keylogic (#1585)
* fix keylogic for d6

* markup required keys for keylogic

* add test

* Update __init__.py
2023-03-25 00:23:42 +01:00
PoryGone
0386d9f6d2 Fix SA2B Option Display Name (#1591) 2023-03-25 00:22:47 +01:00
Fabian Dill
7e52b6d8bb MultiServer: if there is a hint cost, don't make it 0 (#1581) 2023-03-24 23:14:34 +01:00
kindasneaki
03cf525b2c Webhost: Add dropdown menus (#1553)
* Change archipelago mod download page

* Docs: change connecting to archipelago in RoR2 setup guide

* dropdowns for links

* change some relative sizing

* change links and reorder links

* dropdowns for links

* change some relative sizing

* change links and reorder links

* mobile view was showing on desktop early

* add in missing relative font sizes

* clean up and add a temp downdown img

* move links around

* added cloud border

* move arrow to the left side
2023-03-23 22:11:39 -07:00
zig-for
e1f46d623c LADX: Pass in seed_name and auth separately (#1575) 2023-03-23 21:23:58 +01:00
zig-for
5bb6ff0ce0 LADX: Fixup missing descriptions (#1576) 2023-03-23 21:22:42 +01:00
zig-for
256f493ada LADX: fix web gen (#1574) 2023-03-23 14:53:48 +01:00
Chris Wilson
3ec2d45f4f [WebHost] Improve mobile styles for WebHost (#1571)
* Improve mobile styles for WebHost

- Mobile view works properly on mobile Chrome and Firefox
- Added thin-window view for desktop browsers that have been reduced in size
- Tested in Chrome and Firefox on mobile and desktop

* Improve style for small-width desktop popover
2023-03-23 00:20:34 -07:00
black-sliver
b3895750ab Docs: from source on macOS: update supported python versions 2023-03-22 19:50:30 +01:00
alwaysintreble
7591404151 OOT: set default for adult trade start if the set is empty 2023-03-22 18:39:32 +01:00
JusticePS
d48e1e447f Adventure: implement new game (#1531)
Adds Adventure for the Atari 2600, NTSC version. New randomizer, not based on prior works. Somewhat atypical of current AP rom patch games; The generator does not require the adventure rom, but writes some data to an .apadvn APContainer file that the client uses along with a base bsdiff patch to generate a final rom file.
2023-03-22 15:25:55 +01:00
JaredWeakStrike
206f8cf5ed KH2: fixed bugs of rc1 (#1565)
KH2Client:
- Now checks if the world id is in the list of checks. This fixed sending out stuff on the movie
- Cleaned up unused inports
- Not getting starting invo if the game is not open when you connect to the server

__init__:
-Cleaned up print statements
- Fixed the spoiler log not outputting the right amount of mcguffins after fixing them in the case of the player messing up their settings

Openkh:
-Fixed putting the correct dummy item on levels
2023-03-22 15:21:41 +01:00
Alchav
0c6b1827fe Pokemon R/B: Pokemon Tower Wild Pokemon logic 2023-03-22 15:20:23 +01:00
PoryGone
017f91c1b5 LADX: Remove duplicate Link's Awakening setup (#1573) 2023-03-22 15:19:29 +01:00
Jarno
95b01def6b Timespinner: RC bug, lake serene is dry, when Rising Tides is off (#1570)
* Tinmespinner: RC bug, lake serene is dry, when Rising Tides is off

* yes
2023-03-22 03:32:37 -07:00
black-sliver
5977e401d5 MultiServer: include 'Archipelago' in games, versions and checksums 2023-03-21 23:36:53 +01:00
PoryGone
21a3c74783 SA2B: v2.1 Content Update (#1563)
Changelog:

Features:
- New goal
  - Grand Prix
    - Complete all of the Kart Races to win!
- New optional Location Checks
  - Omosanity (Activating Omochao)
  - Kart Race Mode
- Ring Loss option
  - `Classic` - lose all rings on hit
  - `Modern` - lose 20 rings on hit
  - `OHKO` - instantly die on hit, regardless of ring count (shields still protect you)
- New Trap
  - Pong Trap

Quality of Life:
- SA2B is now distributed as an `.apworld`
- Maximum possible number of Emblems in item pool is increased from 180 to 250
- An indicator now shows on the Stage Select screen when `Cannon's Core` is available
- Certain traps (`Exposition` and `Pong`) are now possible to receive on `Route 101` and `Route 280`
- Certain traps (`Confusion`, `Chaos Control`, `Exposition` and `Pong`) are now possible to receive on `FinalHazard`

Bug Fixes:
- Actually swap Intermediate and Expert Chao Races correctly
- Don't always grant double score for killing Gold Beetles anymore
- Ensure upgrades are applied properly, even when received while dying
- Fix the Message Queue getting disordered when receiving many messages in quick succession
- Fix Logic errors
  - `City Escape - 3` (Hard Logic) now requires no upgrades
  - `Mission Street - Pipe 2` (Hard Logic) now requires no upgrades
  - `Crazy Gadget - Pipe 3` (Hard Logic) now requires no upgrades
  - `Egg Quarters - 3` (Hard Logic) now requires only `Rouge - Mystic Melody`
  - `Mad Space - 5` (Hard Logic) now requires no upgrades

Co-authored-by: RaspberrySpaceJam <tyler.summers@gmail.com>
2023-03-21 21:26:13 +01:00
Zach Parks
2fb9176511 Clique: The greatest game of all time. (#1566) 2023-03-21 21:23:45 +01:00
alwaysintreble
1c69fb3c3c The Messenger: Add more difficult logic options (#1550) 2023-03-21 21:21:27 +01:00
Fabian Dill
91502505a1 WebHost: fix type in states template 2023-03-21 20:05:37 +01:00
black-sliver
01c13ca243 Docs: some clarification in running from source 2023-03-21 18:28:45 +01:00
Fabian Dill
c2a8b842de Core: typo 2023-03-21 15:53:10 +01:00
alwaysintreble
856efebc39 Multiserver: Only update client status for a slot when the first enters and the last leaves (#1358) 2023-03-21 15:50:50 +01:00
Alchav
5a4203649d Pokémon R/B: Quiz fix 2023-03-21 15:46:11 +01:00
Alchav
ddb764a9b6 Pokémon R/B: Skip unnecessary sweeps for events 2023-03-21 15:46:11 +01:00
Rosalie-A
9f65f22fac TLoZ: Installer Info (#1554) 2023-03-21 15:45:31 +01:00
Magnemania
b7ff9b69ba WG: Added Client to Setup (#1556) 2023-03-21 14:18:59 +01:00
Fabian Dill
012e6ba24c WebHost: add Status to MultiTracker 2023-03-21 14:06:38 +01:00
The T
cd9d0bebc8 Add LADX to Readme.md (#1559)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-03-20 23:55:53 +01:00
Brooty Johnson
3fa6588637 TLoZ: Update instructions (#1558)
Added 'Optional Software' to instructions for setting up Z1
2023-03-20 23:45:36 +01:00
JaredWeakStrike
e6d16c905c KH2: Fixed inno_setup and fixed readme.md (#1557) 2023-03-20 23:43:29 +01:00
Fabian Dill
958829d491 Launcher: dynamic Launcher 2023-03-20 23:40:34 +01:00
KonoTyran
9ee37b0ec5 [Minecraft] Update MinecraftClient.py (#1524)
* add support for direct url for mod to download.
make a nice error message if the chosen release channel is empty.

* Rip out call to github api as it is no longer used.

* rework error message to be more descriptive, and provide basic troubleshooting.

fix command line data version not correctly overriding apmc data version.
2023-03-20 11:44:48 -07:00
zig-for
81a239325d Links Awakening: Implement New Game (#1334)
Adds Link's Awakening: DX. Fully imports and forks LADXR, with permission - https://github.com/daid/LADXR
2023-03-20 17:26:03 +01:00
JaredWeakStrike
67bf12369a KH2 game implementation (#1438) 2023-03-20 17:19:55 +01:00
toasterparty
d4b793902f [OC2] Overworld Logic (#1530) 2023-03-20 17:16:19 +01:00
Fabian Dill
6671b21a86 Core: Generic excluded fill (#1511) 2023-03-20 17:10:12 +01:00
el-u
6d13dc4944 lufia2ac: new features, bug fixes, and more (#1549)
### New features

- ***Architect mode***
  Usually the cave is randomized by the game, meaning that each attempt will produce a different dungeon. However, with this new feature the player can, between runs, opt into keeping the same cave. If activated, they will then encounter the same floor layouts, same enemy spawns, and same red chest contents as on their previous attempt.   

- ***Custom item pool***
  Previously, the multiworld item pool consisted entirely of random blue chest items because, well, the permanent checks are blue chests and that's what one would normally get from these. While blue chest items often greatly increase your odds against regular enemies, being able to defeat the Master can be contingent on having an appropriate equipment setup of red chest items (such as Dekar blade) or even enemy drops (such as Hidora rock), most of which cannot normally be obtained from blue chests.
  With the custom item pool option, players now have the freedom to place any cave item into the multiworld itempool for their world.

- ***Enemy floor number, enemy sprite, and enemy movement pattern randomization***
  Experienced players can deduce a lot of information about the opposition they will be facing, for example: Given the current floor number, one can know in advance which of the enemy types will have a chance to spawn on that floor. And when seeing a particular enemy sprite, one can already know which enemy types one might have to face in battle if one were to come in contact with it, and also how that enemy group will move through the dungeon.
  Three new randomization options are added for players who want to spice up their game: one can shuffle which enemy types appear on which floor, one can shuffle which sprite is used by which enemy type, and one can shuffle which movement pattern is used by which sprite.

- ***EXP modifier***
  Just a simple multiplier option to allow people to level up faster. (For technical reasons, the maximum amount of EXP that can be awarded for a single enemy is limited to 65535, but even with the maximum allowed modifier of 500% there are only 6 enemy types in the cave that can reach this cap.)


### Balance change

- ***proportionally adjust chest type distribution to accommodate increased blue chest chance***
  One of the main problems that became apparent in the current version has to do with the distribution of chest contents. The game considers 6 categories, namely: consumable (mostly non-restorative), consumable (restorative), blue chest item, spell, gear, and weapon. Since only blue chests count as multiworld locations, we want to have a mechanism to customize the blue chest chance.
  Given how the chest types are detetermined in game, a naive implementation of an increased blue chest chance causes only the consumable chance to be decreased in return. In practice, this has resulted in some players of worlds with a high blue chest chance struggling (more than usual) to keep their party alive because they were always low on comsumables that restore HP and MP.
  The new algorithm tries to avoid this one-sided effect by having an increase in blue chest chance resulting in a decrease of all other types, calculated in such a way that the relative distribution of the other 5 categories stays (approximately) the same.


### Bug fixes

- ***prevent using party member items if character is already in party***
  This should have been changed at the same time that 6eb00621e3 was made, but oh well... 

- ***fix glitched sprite when opening a chest immediately after receiving an item***
  When opening a chest right after receiving a multiworld item (such that there were two item get animations in the exact same iteration of the game main loop), the item from the chest would display an incorrect sprite in the wrong place. Fixed by cleaning up some relevant memory addresses after getting the multiworld item.

- ***fix death link***
  There was a condition in `deathlink_kill_player` that looked kinda smart (it checked the time against `last_death_link`), but actually wasn't smart at all because `deathlink_kill_player` is executed as an async task and the main thread will update `last_death_link` after creating the task, meaning that whether or not the incoming death link would actually be passed to the game seems to have been up to a race condition. Fixed by simply removing that check.


### Other

- ***add Lufia II Ancient Cave (and SMW) to the network diagram***
  These two games were missing from the SNES sector.

- ***implement get_filler_item_name***
  Place a restorative consumable instead of a completely random item. (Now the only known problem with item links in lufia2ac is... that noone has ever tested item links. But this should be an improvement at least. Anyway, now #1172 can come ;)
  And btw., if you think that the implementation of random selection in this method looks weird, that's because it is indeed weird. (It tries to recreate the algorithm that the game itself uses when it generates a replacement item for a chest that would contain a spell that the party already knows.)

- ***store all options in a dataclass***
  This is basically like using #993 (but without actual support from core). It makes the lufia2ac world code much nicer to maintain because one doesn't have to change 5 different places anymore when adding or renaming an option.

- ***remove master_hp.scale***
  I have to admit: `scale` was a mistake. Never have I seen a single option value cause so many user misconceptions. Some people assume it affects enemies other than the Master; some people assume it affects stats other than HP; and many people will just assume it is a magic option that will somehow counterbalance whatever settings combination they are currently trying to shoot themselves in the foot with.
  On top of that, the `scale` mechanism probably doesn't provide a good user experience even when used for its intended purpose (since having reached floor XY in general doesn't mean you will have the power to deplete XY% of the Masters usual HP; especially given that, due to the randomness of loot, you are never guaranteed to be able to defeat the vanilla Master even when you have cleared 100% of the floors).
  The intended target audience of the `master_hp` option are people who want to fight the Master (and know how to fight it), but also want to lessen (to a degree of their choosing) the harsh dependence on the specific equipment setups that are usually required to win this fight even when having done all 99 floors. They can achieve this by setting the `master_hp` option to a numeric value appropriate for the level of challenge they are seeking. Therefore, nothing of value should be lost by removing the special `scale` value from the `master_hp` option, while at the same time a major source of user confusion will be eliminated.

- ***typing***
  This (combined with the switch to the option dataclass) greatly reduces the typing problems in the lufia2ac world. The remaining typing errors mostly fall into 4 categories:
  1. Lambdas with defaults (which seem to be incorrectly reported as an error due to a mypy bug)
  1. Classmethods that return instances (which could probably be improved using PEP 673 "Self" types, but that would require Python 3.11 as the minimum supported version)
  1. Everything that inherits from TextChoice (which is a typing mess in core)
  1. Everything related to asar.py (which does not have proper typing and lies outside of this project)

## How was this tested?

https://discord.com/channels/731205301247803413/1080852357442707476 and others
2023-03-20 17:04:57 +01:00
Zach Parks
ff9f563d4a Deprecate data_version and introduce checksum for DataPackages. (#684)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-03-20 17:01:08 +01:00
Fabian Dill
d825576f12 Factorio: content update
Energy Link:
  * Transfer and Storage increased by 10X
  * Cost of building increased by roughly 10X
  * This is a way to address their effect on performance, as users now need a tenth of them to get the same throughput, it also differentiates them from Accumulators
5 new Traps:
  * Teleport Trap
  * Grenade Trap
  * Cluster Grenade Trap
  * Artillery Trap
  * Atomic Rocket Trap
When max science is lower than min science, the two are now swapped.
Max Evolution Trap count was changed from 25 -> 10.
New option: Ingredients Offset
  * When creating random recipes, use this many more or less ingredients in the new recipe.
2023-03-15 21:38:32 +01:00
Alchav
5d6184f1fd STS: update slot_seeds to per_slot_randoms 2023-03-15 09:10:35 +01:00
alwaysintreble
e433246f0c The Messenger: docs improvement (#1545)
* The Messenger: docs improvement

* more wordy mod link

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

* indent

* revert accidental indent

oop

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

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-03-14 21:18:55 +01:00
black-sliver
3a190a8fb2 CI: more filters, update CodeQL (#1540)
* CI: fix and more greedy filtering

* CI: only run lint if *.py changed

* CI: only run CodeQL if supported file changed

* CI: fix unittests still triggering for build.yml

* CI: update CodeQL action

* CI: trigger codeql when changing the workflow
2023-03-14 19:29:20 +01:00
Alchav
4b7033fce7 Pokemon R/B: Version 3 final touches (#1542)
* Pokémon R/B: Dexsanity balls

* Pokémon R/B: Early Parcel improvement

* Pokémon R/B: Early Parcel dexsanity stuff only when dexsanity
2023-03-14 18:36:17 +01:00
lordlou
37499b40a1 SMZ3: shop check fix 2 (#1538) 2023-03-14 18:31:51 +01:00
black-sliver
ca2c0e6ce2 CI: update stuff (#1534)
* CI: skip SNI, skip unittests if not needed, run build for setup.py

* CI: update actions

* CI: update upload-artifact

Fixes more warnings
2023-03-14 01:32:00 +01:00
black-sliver
2a28a6de28 Pokemon: apply rename of location_item_name 2023-03-14 01:30:58 +01:00
alwaysintreble
573a1a8402 Core: Add a function to allow worlds to easily allow self-locking items (#1383)
* implement function to allow self locking items for items accessibility

* swap some lttp locations to use new functionality

* lambda capture `item_name` and `location`

* don't lambda capture location

* Revert weird visual indent

* make location.always_allow additive

* fix always_allow rule for multiple items

* don't need to lambda capture item_names

* oop

* move player assignment to the beginning

* always_allow should only be for that player so prevent non_local_items

* messenger got merged so have it use this

* Core: fix doc string indentation for allow_self_locking_items

* Core: fix doc string indentation for allow_self_locking_items, number two

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-03-14 00:55:34 +01:00
Jarno
060ee926e7 Core: type specified missing per_game_common_options (#1509) 2023-03-13 23:45:56 +01:00
Alchav
df55455fc0 Pokémon R/B: Version 3 (#1520)
* Coin items received or found in the Game Corner are now shuffled, locations require Coin Case
* Prizesanity option (shuffle Game Corner Prizes)
* DexSanity option: location checks for marking Pokémon as caught in your Pokédex. Also an option to set all Pokémon in your Pokédex as seen from the start, to aid in locating them.
* Option to randomize the layout of the Rock Tunnel.
* Area 1-to-1 mapping: When one instance of a Wild Pokémon in a given area is randomized, all instances of that Pokémon will be the same. So that if a route had 3 different Pokémon before, it will have 3 after randomization.
* Option to randomize the moves taught by TMs.
* Exact controls for TM/HM compatibility chances.
* Option to randomize Pokémon's pallets or set them based on primary type.
* Added Cinnabar Gym trainers to Trainersanity and randomized the quiz questions and answers. Getting a correct answer will flag the trainer as defeated so that you can obtain the Trainersanity check without defeating the trainer if you answer correctly.
2023-03-13 23:40:55 +01:00
Fabian Dill
4d7bd929bc WebHost: update modules 2023-03-13 21:34:24 +01:00
Fabian Dill
030e41363a Setup: update cx-Freeze 2023-03-13 21:34:07 +01:00
Qwazzy
4bc0e84a7f Docs: SM64 Guide update to explain how to launch the game with batch files (#768)
* Update setup_en.md

Added several sections in regards to opening the completed SM64 build with batch files instead of SM64PCLauncher.

* Update setup_en.md

* Update setup_en.md

* Update setup_en.md

* Update setup_en.md

* Apply suggestions from code review

Co-authored-by: Yussur Mustafa Oraji <N00byKing@hotmail.de>
Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* Apply more suggestions from code review

matches the original suggestion from SoldierofOrder

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
Co-authored-by: Yussur Mustafa Oraji <N00byKing@hotmail.de>
Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>
2023-03-13 00:58:17 +01:00
alwaysintreble
070a92e76c The Messenger: implement new game (#1494)
* initial commit of messenger integration

* setup no_logic and needed slot_data

* fix some typos and determinism

* make all of it deterministic

* add documentation

* swapped to non local items so change the fed data

* ~~deathlink~~

* satisfy the docs test

* update doc test to show expected name

* split custom classes into a separate file and fix an errant rule

* make access dependency test give more useful errors

* implement tests

* remove some unneccessary back entrances and make names clearer

* fix some big dumbs

* successful unit tests are good also some slight reorganizing

* add astral tea quest line, and potentially power seals as items

* if TYPE_CHECKING... aahhhhhh

* oop forgot to remove legacy code

* having the seed and leaves as actual items doesn't seem to do anything so remove them. locations still work though

* update setup guide with some changes

* Tower HQ was creating duplicate locations

* allow self locking items

* cleanup

* move self_locking_items function to core

* docstring

* implement choice of notes needed for music box

* test the default value

* don't create any starting inventory items

* make item creation faster

* change default accessibility and power seals options

* improve documentation

* precollected_items is a dict of Items...

* implement shop chest goal

* tests

* always assign total and required seals

* add new goals and set music box as requiring shop chest on shop chest goals instead of just setting it as the completion

* fix dumb test quirk

* implement music box skip as an option

* world rewrite/cleanup

* default to apworld and add game to readme

* revert bleeding commits from other PRs

* more bleeds

* fix some errors in options docstrings

* ???

* make my set rules method not have an awful name

* test cleanup

* add a test for item accessibility

* fix issues with tests

* make the self locking item behavior work correctly

* misc cleanup

* more general cleanup to be a good example

* quick rules rewrite

* more general cleanup and typing

* more speed, more clean

* bump data version

* make sure the locked item belongs to current player

* fix bad name and indent. call MessengerItem directly for events

* add poptracker pack to docs

* doc cleanup and "known issues" section that I probably won't be able to fix any time soon.

* missed some spots

* add another bug i forgot about

* be consistently wrong
2023-03-12 15:05:50 +01:00
Fabian Dill
39563cc347 WebHost: add game and Factorio to multitracker (#1526)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-03-12 12:38:13 +01:00
alwaysintreble
54cce4c392 LTTP: fix ice rod hunt boss shuffle (#1529) 2023-03-12 11:03:48 +01:00
el-u
426a81a065 oot/alttp: fix bugs found through MMBN3 testing (#1527) 2023-03-11 20:15:30 +01:00
alwaysintreble
04e6a8eae8 FFR: add option __doc__s 2023-03-11 13:38:27 +01:00
NewSoupVi
0cfdc973f6 The Witness - Expert logic bug (could lead to broken seeds) (#1525) 2023-03-11 10:09:09 +01:00
alwaysintreble
f3ca0a21c9 Docs: Add an option api doc (#1181)
* write up an option api doc

* address reviews

* some clarification

* add note about using schema

* Add ItemSet and formatting

* bulletpoint option defining

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* split random description to new sentence

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* use inclusive and parallel language for example

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* changes from review

* commas

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* capitalize Toggle

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>

* the sliver conventions

---------

Co-authored-by: SoldierofOrder <107806872+SoldierofOrder@users.noreply.github.com>
2023-03-11 01:14:44 +01:00
Chris Wilson
5fef41eb97 [WebHost] Make site header mobile-friendly (#1523) 2023-03-10 17:21:17 -05:00
alwaysintreble
4068ba2f15 LTTP: add option __doc__s (#1521)
* LTTP: add option `__doc__`s

* review comments
2023-03-10 07:59:47 +01:00
NewSoupVi
b1599c557f The Witness: Death Link + Small bug fixes (#1515)
* Fully functional DeathLink implementation. But it's always on right now :D

* Death Link options. Last commit: All entity names being sent through slot_data

* Tutorial Gate Close logic fix

* Improved option tooltip wording

* Fixed shuffle_postgame: false not excluding some locations

* Link to latest stable client rather than full releases page
2023-03-10 07:58:00 +01:00
Fabian Dill
7fdf38b2ad WebHost: automatically fill PATCH_TARGET -> HOST_ADDRESS and re-use it for rooms (#1518) 2023-03-09 21:31:00 +01:00
Freya Arbjerg
2e76085cf1 WebHost: Fix generic tracker Datatables (#1519) 2023-03-09 19:24:38 +01:00
Fabian Dill
c61f467218 WebHost: fix location_name_group related spinup crash 2023-03-09 12:31:35 +01:00
KonoTyran
942d689093 [Slay the Spire] Enable support for modded characters, and add downfall support (#1368)
* add ability to choose custom characters in STS

* bump required protocol (client?) version.

* fix slot data fill.

* add downfall mode, as well as characters.

* small change in documentation for character choice as it now uses internal ID's instead of visible titles... because other languages are a thing.
2023-03-08 20:14:54 -08:00
espeon65536
5e1aa52373 Minecraft rewrite (#1493)
* Minecraft: rewrite to modern AP standards

* Fix gitignore to not try to ignore the entire minecraft world

* minecraft: clean up MC-specific tests

* minecraft: use pkgutil instead of open

* minecraft: ship as apworld

* mc: update region to new api

* Increase upper limit on advancement and egg shard goals

* mc: reduce egg shard count by 5 for structure compasses

* Minecraft: add more tests
Ensures data loading works; tests beatability with various options at their max setting; new tests for 1.19 advancements

* test improvements

* mc: typing and imports cleanup

* parens

* mc: condense filler item code and override get_filler_item_name
2023-03-08 20:13:52 -08:00
Freya Arbjerg
a95e51deda Add generic multiworld tracker, move lttp multiworld tracker (#1478)
Co-authored-by: Berserker
2023-03-08 22:39:15 +01:00
alwaysintreble
738319462d Spoiler: Don't double print if world overrides common options (#1505) 2023-03-08 22:19:38 +01:00
alwaysintreble
e3deb822ad Core: implement location_name_groups (#1502) 2023-03-08 22:15:28 +01:00
Jarno
d57314a407 Timespinner: Bring back starter progression item (#1508) 2023-03-08 20:05:30 +01:00
t3hf1gm3nt
5a8e6e61f5 TLOZ: Code Cleanup (#1514)
- consolidated declaration and population of level location lists
- moved floor_location_game_ids_late declaration for consistency
- moved generate_itempool to create_items, where it belongs
- mention that expanded pool includes take any caves in the option description again
- removed unnecessary StartingPosition check regarding Take Any Caves (leftover from older StartingPosition behavior I believe)
- use proper comparisons to option keys instead of hardcoded ints
2023-03-08 11:22:14 +01:00
Magnemania
17e90ce12c SC2: Greater variety on short generations (#1367)
Originally, short generations used an artificial cull to create balanced mission distributions. This resulted in campaigns that were somewhat too consistent, and on some standard settings combinations, this resulted in campaigns having The Outlaws as the second mission 100% of the time. It also caused generation to fail a bit too easily if the player excluded too many missions.

This removes the cull and adds an additional early Easy mission slot to all of the reduced sized campaigns.

When playing on No Build settings, this also pushes many of the missions down a difficulty level to ensure greater variety, and pushes additional missions down on Advanced Tactics.

Additional small fixes:

The in-world Excluded Missions validation check is replaced by the core OptionSet check.
Fixed issue with Existing Items not getting their upgrades locked with Units Always Have Upgrades on.
2023-03-07 14:14:49 +01:00
NewSoupVi
016157a0eb Witness: Fixed settings combination not rolling (see description)
Settings combination:
- EP Shuffle
- disable_non_randomized
- doors: panels or doors: none

An Event Item was being created that is inaccessible. This is fixed now.
(The fix makes sure that player_logic is not trying to create events for the sake of EPs that are disabled)

Note: These two sets should probably be merged anyway, they used to behave differenty but no longer really do. But that will require some extra care on the client side as well.
2023-03-07 09:13:54 +01:00
Fabian Dill
5b64c5f934 Subnautica: fix exported radiation logic (#1507) 2023-03-07 09:09:24 +01:00
alwaysintreble
414166f6a2 Core: Minor Options cleanup (#1182)
* Options.py cleanup

* TextChoice cleanup

* make Option.current_option_name a property

* title the TextChoce option names

* satisfy the linter

* a little more options cleanup

* move the typing import

* typing should be PlandoSettings

* fix incorrect conflict merging

* make imports local

* the tests seem to want me to import these twice though i hate it.

* changes from review. Make the various Location verifying Options `LocationSet`

* remove unnecessary fluff

* begrudgingly support get_current_option_name. Leave a comment that worlds shouldn't be touching this

* log a deprecation warning and return the property for `get_current_option_name()`

---------

Co-authored-by: beauxq <beauxq@yahoo.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-03-07 08:44:20 +01:00
beauxq
e6109394ad Zillion: use Option.current_key
and other minor fixes
2023-03-07 08:33:33 +01:00
Fabian Dill
8ca25fed63 Setup: clean up imports 2023-03-06 12:54:32 +01:00
0rganics
227d59ecfb WebHost: Add a ChecksFinder tracker (#1333)
Co-authored-by: Chris Wilson <chris@legendserver.info>
2023-03-05 14:17:04 +01:00
Fabian Dill
08c17c83d4 Setup: auto download SNI (#1312)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-03-05 14:10:05 +01:00
Rosalie-A
efb2ab4505 TLoZ: Implementing The Legend of Zelda (#1354)
Co-authored-by: t3hf1gm3nt <59876300+t3hf1gm3nt@users.noreply.github.com>
Co-authored-by: Alchav <59858495+Alchav@users.noreply.github.com>
2023-03-05 13:31:31 +01:00
Joethepic
3a68ce3faa PKMN: Make Exp All early (#1422) 2023-03-05 10:08:32 +01:00
black-sliver
e78800d1bc Item Plando: make world selection deterministic 2023-03-05 02:16:55 +01:00
Jérémie Bolduc
96d7a3a64c Stardew Valley: Fix generation issue with Master Angler goal and vanilla tools (#1498)
* - Can Catch every fish doesn't need fishing rods if they are not shuffled

* add has_max_fishing_rod

* add test for master angler + vanilla tools

---------

Co-authored-by: Alex Gilbert <alexgilbert@yahoo.com>
2023-03-04 18:34:51 +01:00
recklesscoder
30b70b2055 Misc collected fixes (#1497) 2023-03-04 16:34:10 +01:00
Jarno
cd234fc04a Timespinner: Fixed Dry lake serene oddity (#1501) 2023-03-04 16:31:44 +01:00
zig-for
d74c4c4c94 Core: Remove ALTTP cruft from BaseClasses (#1451) 2023-03-04 08:23:52 +01:00
Jarno
a4b61118cf Timespinner: Refactorings + fix for #1460 (#1484) 2023-03-04 08:16:05 +01:00
FlySniper
9fa1f4e85f Wargroove: Fixed Wargroove Client not removing communication files (#1492) 2023-03-03 18:24:09 +01:00
black-sliver
3a926849a0 CI: run unittests on macos
this is to ensure dependencies can be installed and loaded on macos (on AMD64)
2023-03-03 18:22:31 +01:00
Alchav
798d823397 Core: Check for show_in_spoiler (#1500) 2023-03-03 17:22:46 +01:00
Fabian Dill
4ea582f14e Windows: allow arm64 setup (#1496) 2023-03-03 09:51:36 +01:00
alwaysintreble
21fb16291d Tests: test that the game is beatable for WorldTestBases (#1495)
* Tests: test that the game is beatable for WorldTestBases

* update docstring

* don't test the bases with default options for real this time

* invert the property so worlds can use it easier

* setup check should be or

* test class needs to always be constructed

* skip default tests before multiworld setup

* check if the calling method is in the base's __dict__

* shorter property and functional setup skipping

* shorter property and functional setup skipping
2023-03-03 00:30:40 +01:00
NewSoupVi
805f33c39e Witness: Bugfixes in response to beta tests (#1473)
* Make all Keep Pressure Plates logically required for the Laser Panel

* Added more Tutorial checks

* Added the remaining two Shipwreck Boat EPs to the exclude list for normal

* Improved itempool filling system, added warning if usefuls had to be eaten

* Moved creation of said warning string to utils

* Fixed logic bug causing broken seeds on Mountain Floor 2

* Hints system change

* Expert Logic Fix

* Fixed typo

* Better wording

* Added missing games to junk hints

* Made sure Entrance names are unique

* Fixed missing Obelisk Side

* Disable Non Randomized + EP Shuffle fix

* Fixed disable_non_randomized precompleted EPs being 'disabled' instead of 'precompleted'

* Fixed if/elif error

* Tutorial Gate Open local symbol item becomes local_early_item in expert instead

* Bump required client version. There is a beta client that sends 0.3.9.

* Removed print statement, oops

* Fixed itempool manipulation in pre_fill

* Replaced string concats with fstrings

* Improved make_warning_string function signature

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

* Improved performance on removing multiple items from multiworld itempool

* Comment

* Fixed errors with the code

* Made removal from itempool not fail unit test for multiple references

* Moved all item creation to create_items, got rid of itempool modifying system

* Colored Squares is no longer a good item, that's outdated

* Removed double if

* React to from_pool: false by removing a junk item

* Fixed warning if only Fnc Brain was removed

* Make use of string truthiness instead

* Made reading of plandoed items safer

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-03-03 00:08:24 +01:00
el-u
0cf8206660 launcher: add .apl2ac support 2023-03-01 22:56:14 +01:00
Fabian Dill
2c20b56478 Core: count the world types 2023-03-01 05:47:46 +01:00
FlySniper
1d2f7d8669 Wargroove: Fixed the find all dogs check activating prematurely (#1486) 2023-02-28 16:26:48 +01:00
Fabian Dill
0733775f2c Subnautica: Allow either utility room for progression 2023-02-28 11:22:47 +01:00
black-sliver
d6f3b27695 DKC3, SMW: use user_path for file
Same as for other games, this will resolve to ~/Archipelago on Linux, if the install folder is read-only
2023-02-28 09:51:32 +01:00
black-sliver
ce7e6bcf33 Readme: fix order 2023-02-28 09:15:23 +01:00
Jarno
2c4658a7e0 Docs: More games more fun 2023-02-27 23:19:33 +01:00
TheLynk
79b8733b13 OoT, MC: add new translation setup in french (#1410)
* add new translation

* Add translation for OOT Setup in french

* Update setup_fr.md

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>

* Update setup_fr.md

Fix treu to true

* Update worlds/oot/docs/setup_fr.md

Co-authored-by: Marech <marechal-l@gmx.com>

* Update OOT Init and Update Minecraft Init

* Fix formatting errors

---------

Co-authored-by: Ludovic Marechal <marechal-l@gmx.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-02-27 23:17:54 +01:00
alwaysintreble
9cb9cbe47d Tests: test that worlds don't create regions or locations after create_items (#1465)
* Tests: test that worlds don't create regions or locations after `create_items`

* recache during the location counts just to be extra safe

* adjust typing and use a Tuple instead of a list

* remove unused import
2023-02-27 02:13:24 +01:00
alwaysintreble
7cad53c31a Docs: add docstrings to the World class 2023-02-27 01:39:30 +01:00
alwaysintreble
f3bdf0c5ed Tests: test all state and empty state on world test bases (#1476)
* Tests: test all state and empty state on world test bases

* actually add the test methods to the dict

* only test if the world test base has non default options

* remove temp logging

* ditch the meta class and document methods

* Tests: WorldTestBase comment and docstring cleanup

* skip default tests if setUp or world_setup are modified and use a property

* negation hurts my head

* docstring

* use a better name for the property

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-02-27 01:24:54 +01:00
Jérémie Bolduc
af7d0dbf37 Stardew Valley: implement new game (#1455)
* Stardew Valley Archipelago implementation

* fix breaking changes

* - Added and Updated Documentation for the game

* Removed fun

* Remove entire idea of step, due to possible inconsistency with the main AP core

* Commented out the desired steps, fix renaming after rebase

* Fixed wording

* tests now passes on 3.8

* run flake8

* remove dependency so apworld work again

* remove dependency for real

* - Fix Formatting in the Game Page
- Removed disabled Option Descriptions for Entrance Randomizer
- Improved Game Page's description of the Arcade Machine buffs
- Trimmed down the text on the Options page for Arcade Machines, so that it is smaller

* - Removed blankspace

* remove player field

* remove None check in options

* document the scripts

* fix pytest warning

* use importlib.resources.files

* fix

* add version requirement to importlib_resources

* remove __init__.py from data folder

* increment data version

* let the __init__.py for 3.9

* use sorted() instead of list()

* replace frozenset from fish_data with tuples

* remove dependency on pytest

* - Add a bit of text to the guide to tell them about how to redeem some received items

* - Added a comment about which mod version to use

* change single quotes for double quotes

* Minimum client version both ways

* Changed version number to be more specific. The mod will handle deciding

---------

Co-authored-by: Alex Gilbert <alexgilbert@yahoo.com>
2023-02-27 01:19:15 +01:00
Fabian Dill
0286edf20c Subnautica: fix the test that I didn't mean to push yet 2023-02-26 09:51:02 +01:00
Fabian Dill
05e36cab1c Subnautica: increment version 2023-02-26 09:48:51 +01:00
Klenoa
50425985c4 Subnautica: Rename location check (id 33029) (#1120)
Update southwest grassy plateaus wreck to 'Grassy Plateaus Southwest Wreck - Databox' instead of 'Grassy Plateaus West Wreck - Databox'
2023-02-26 09:48:08 +01:00
Marech
062d6eeace DS3: Added DLC Items/Locations + corresponding option and added an option to enable materials/consumables/estus randomization (#1301)
- Added more progressive locations and associated items.
- Added an option to enable materials/consumables/estus randomization, some players complain about the number of locations and the randomness of those items.
- Added an option to add DLC Items and Locations to the pool, the player must own both the ASHES OF ARIANDEL and the RINGED CITY DLC.

Co-authored-by: Br00ty <83629348+Br00ty@users.noreply.github.com>
Co-authored-by: Friðberg Reynir Traustason <fridberg.traustason@gmail.com>
2023-02-26 06:35:03 +01:00
alwaysintreble
6c460bcbf7 LTTP: Move LTTP spoiler writing out of core (#1467) 2023-02-25 04:02:51 +01:00
toasterparty
b8659d28cc [OC2] DeathLink (#1470) 2023-02-24 08:32:15 +01:00
alwaysintreble
0b12d80008 Tracker: get game names from slot_info instead of multidata["games"] and render custom game names on generic tracker (#1453) 2023-02-24 08:30:11 +01:00
FlySniper
5966aa5327 Wargroove: Implement New Game (#1401)
This adds Wargroove to the list of supported games. Wargroove uses a custom non-linear campaign over the vanilla and double trouble campaigns. A Wargroove client has been added which does a lot of heavy lifting for the Wargroove implementation and must be always on during gameplay. The mod source files can be found here: https://github.com/FlySniper/WargrooveArchipelagoMod
2023-02-24 07:35:09 +01:00
Trevor L
7c68e91d4a Blasphemous: Implement new game (#1446)
Adds @BrandenEK's Blasphemous Randomizer as a new Archipelago game.
2023-02-24 07:33:09 +01:00
alwaysintreble
1d6ab13015 ArchipIDLE: add a completion condition instead of hard coding tests around a game (#1444) 2023-02-23 21:16:10 -05:00
CaitSith2
cb3d40624c Timespinner: Make RisingTidesOverrides consistent with normal yaml behaviour. (#1474)
* Make RisingTidesOverrides consistent with normal yaml behaviour.

* Each of the options can be either string directly specifying the option, or dictionary.
* If dictionary, ensure that at least one of the options is greater than zero.

* Made keys optional

* A lot less copy/pasta.

---------

Co-authored-by: Jarno Westhof <jarnowesthof@gmail.com>
2023-02-22 17:11:27 -08:00
alwaysintreble
0eb66957b1 SMW: change random in generate_output to use slot random 2023-02-20 18:43:23 +01:00
alwaysintreble
53e2232f29 Docs: document world docs and tests (#1463)
* Docs: document world docs and tests

* regions and items shouldn't be created after `create_items`

* Changes from review

* Restructure game info section

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

* w

* urls can have extension probably

* reorder the methods by call order

* fix grammar mistake in ordered method list

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-02-19 23:16:56 +01:00
Fabian Dill
ecd2675ea8 Tests: check that Regions are reachable (#1034)
* Tests: check that Regions are reachable
try to prevent errors from unconnected/never reachable Regions

* Test region access (#1039)

* Tests: note oot's default unreachable regions

* [SM] Fixed failing testAllStateCanReachEverything (#1087)

* [SM] Fixed failing testAllStateCanReachEverything

- by adding exclusion for Regions used only when corresponding Starting Location is used
- by removing unnecessary VARIA Regions used only for EscapeRando (not supported in AP anyway)

* Update worlds/sm/Regions.py

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

* Update worlds/sm/Rules.py

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

* Update worlds/sm/Regions.py

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

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

* Update test/general/TestReachability.py

---------

Co-authored-by: espeon65536 <81029175+espeon65536@users.noreply.github.com>
Co-authored-by: lordlou <87331798+lordlou@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-02-19 23:09:54 +01:00
Jarno
fc2e555b4a Timespinner: many new stuffs (#1433)
* Timespinner: added RisingTides and DadPercent flags

* Implemented logic for DadPercent and RisingTides

* Fixed TODO's

* Logic fixes

* Fixed + removed LogicMixins

* Fixes

* More Fixes

* Added UnchainedKeys flag

* Fixed available items in pool with UnchainedKeys

* Fixed typing callable

* Fixed generation failures

* More refactorings

* Implemented traps

* Fixed more typo

* Fixed copy paste bug

* Fixed teleporter logic

* Fixed traps from pool

* Fixed pyramid gates bug that causes a crash on connecting

* Fixed seed reproduceability

* Fixed logic eye for eye spy
Now consider warp beacons as starter progression items

* Attempt to add tracker icons using table

* Replaced table layout with css grid

* Fixed tracker + added Timespinner was apworld capatible

* Updated archipelago items description

* updated URL

* Cleared up text

* Fixed based on self review of PR

* Fixed unit tests

* Fixed seed reproduceability when the traps yaml option is not provided

* Fixed logic for flooded basement

* Implemented Beserkers review result

I am not sure why, i guess this is just to make adding future games less conflicting?

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

* Added two new options (thanks to WeffJebster)

* Apply suggestions from code review

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

* Apply suggestions from code review

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

* Apply suggestions from code review

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

* Apply suggestions from code review

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

* Addition review results

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-02-19 21:22:30 +01:00
black-sliver
df020bb389 Style Guide: add world consistency 2023-02-19 19:34:45 +01:00
PoryGone
7760034ff7 DKC3 and SMW: Remove relative imports (#1472) 2023-02-19 09:10:32 +01:00
black-sliver
3e7794d5dc SoE: update evermizer to 044
* Fix energy core despawning when looting failed
* Fix fish guy dialog when cure was already obtained
see https://github.com/black-sliver/pyevermizer/releases/tag/v0.44.0 for more details
2023-02-18 15:18:51 +01:00
black-sliver
a3e8bb474a ModuleUpdater: allow new syntax, nicer output 2023-02-18 15:18:51 +01:00
kindasneaki
e4c95c940a RoR2: regions unreachable fix (#1459) 2023-02-17 22:08:18 +01:00
recklesscoder
daa1809a0f WebHost: Tweaks to search on tracker pages (#1307)
* WebHost: Tweaks to search on tracker pages
- Pressing `Ctrl+F` or `/` now focuses the search box.
- Typing now automatically focuses the search box.
- Pressing `Escape` now clears the search and scrolls to the top.

* WebHost/Trackers: Focus search box on load

* WebHost/Trackers: Remove overriding of Ctrl+F and /
2023-02-17 13:24:21 -05:00
Jarno
0a1261eb84 WebHost: Add tutorials to sitemap and hide settings link for games without settings (#1452)
* WebHost: Add tutorials to sitemap and hide settings link for games without settings

* Fixed some typing imports
2023-02-17 13:16:37 -05:00
toasterparty
b62be6f7f4 [OC2] Colored Ramp Button Items (#1466)
Before: 1 item activates all 4
After: 7 items activate 7 buttons, creating more divergent routes

Also, I consolidated the 6 filler emotes into a single "Emote Wheel" item to make space in the item pool.

I bumped my data version and min AP version to indicate this change.

The corresponding oc2-modding update is **v1.6.0**
2023-02-17 09:25:56 +01:00
toasterparty
ce2553a2b3 [OC2] Location Balancing (#1458) 2023-02-17 09:21:56 +01:00
Fabian Dill
18c4b4b1fe Subnautica: move code to be a better example 2023-02-17 08:50:22 +01:00
Fabian Dill
a85ca9cc87 Subnautica: add logic dumper for mod (#1386)
* Subnautica: add logic dumper for mod

* Subnautica: export more data

* Subnautica: fix some Cyclops logic
2023-02-16 00:40:19 +01:00
el-u
ad4846cedd core: clarify usage of classmethods in World class (#1449) 2023-02-16 00:28:02 +01:00
recklesscoder
b20be3ccec Docs/Factorio: Document EnergyLink (#1456)
* Docs/Factorio: Document EnergyLink

* Docs/Factorio: EnergyLink clarification
2023-02-16 00:25:46 +01:00
alwaysintreble
8af7908cd0 Tests: datapackage and more multiworld renaming (#1454)
* Tests: add a test that created items and locations exist in the datapackage

* move FF validation to `assert_generate` and remove test exclusion

* test created location addresses are correct

* make the assertion proper and more verbose

* make item count test ~~a bit faster~~ a lot nicer

* 120 blaze it

* name test multiworld setup better and fix another over 120 line in FFR
2023-02-15 22:46:10 +01:00
alwaysintreble
f078750b72 LTTP: make the enemizer check a property and only check for it once instead of per world (#1448)
* LTTP: do the enemizer check in `stage_assert_generate` and break after checking any world for enemizer succeeds

* use multiworld

* catch a missed `used_enemizer` check and add typing

* more typing
2023-02-14 22:22:39 +01:00
alwaysintreble
7cbeb8438b core: rip out RegionType and rework Region class (#814) 2023-02-14 01:06:43 +01:00
Fabian Dill
f7a0542898 kvui: limit UI side logs to by default 1000 messages 2023-02-13 09:02:19 +01:00
recklesscoder
cc61f16e57 Protocol: Improve machine-readability of prints (#1388)
* Protocol: Improve machine-readability of prints

* Factorio: Make use of new PrintJSON fields for echo detection.

* Protocol: Add message field to chat prints.
2023-02-13 03:17:25 +01:00
alwaysintreble
9e3c2e2464 Tests: test that exits to Regions are the parents of the Entrance (#1442) 2023-02-13 02:05:52 +01:00
Fabian Dill
f528175d8a Core: prepare server for removal of names in multidata (#1430) 2023-02-13 01:56:20 +01:00
Fabian Dill
803d7105a1 kivy: allow user-defined text colors in data/client.kv (#1429) 2023-02-13 01:55:43 +01:00
el-u
a40f6058b5 lufia2ac: fix mismatched exits/parent region 2023-02-13 00:46:46 +01:00
toasterparty
0ff3c693d5 [OC2] Relax Horde Logic for Horde H-8 and Winter H-4 (#1439) 2023-02-10 21:42:28 +01:00
Fabian Dill
873a374a69 SNIclient: connect fixes (#1436) 2023-02-07 10:16:39 +01:00
black-sliver
60584b7617 CI: more pip to fix the build 2023-02-07 10:10:27 +01:00
black-sliver
e24a85ca5c CI: update SNI to v0.0.88 2023-02-07 10:10:27 +01:00
lordlou
cc0540d3fb SMZ3: keysanity accessibility fix (#1428) 2023-02-07 03:14:03 +01:00
NewSoupVi
c360b9266c Witness: Renaming: Mill -> Stoneworks, correcting order for 'First, Second, ...', removed all instances of 'Door (Door)' (#1435) 2023-02-07 03:12:47 +01:00
beauxq
6148213e43 Zillion: fix name data overflow 2023-02-07 03:11:48 +01:00
Jarno
ff175008a1 Core: Phase out Print packets (#1364) 2023-02-05 22:06:38 +01:00
kindasneaki
cae1e683e2 RoR2: 1.20 content update (#1396)
## Adding in Explore Mode:

Features include:
* Added in `environments` to be items.
* `Location checks` are now `environment based` instead of being able to get them from anywhere.
* Added in support for the `DLC Survivors of the void` which include `Void Items` and `3 new maps` that come with it. (option added to use DLC)

---------

Co-authored-by: Dogpetkid <dogpetkid@gmail.com>
2023-02-05 21:51:03 +01:00
vgZerst
fb1a9e9c5a WebHost: add checks percent done column to tracker (#1376)
* WebHost: add checks percent done column to tracker

* WebHost: add checks percent done column to tracker
2023-02-04 00:04:00 -05:00
CaitSith2
555a0da46d Core: Rename the missed slot_seeds. (#1432)
* Rename the missed slot_seeds.

* Fixed a threaded context random error.
2023-02-03 19:39:18 +01:00
NewSoupVi
0817305d5b Witness: Added an option tooltip for "Environmental Puzzles Difficulty" option (+ another bugfix) (#1431)
* Added an option tooltip

* Fixed eclipse being on in EP difficulty normal
2023-02-03 19:38:54 +01:00
Fabian Dill
995c978628 Core: replace global random state with descriptive error (#1424)
* Core: replace global random state with descriptive error

* Core: make random a proxy object and rename slot_seeds
2023-02-02 01:14:23 +01:00
NewSoupVi
4de7ebd8b0 The Witness: v4 Content Update (#1338)
## New Features:

- EP Shuffle (Individual or Obelisk Sides, with varying difficulty levels)
- Ability to play without Puzzle Randomization (I.e. vanilla + AP layer)
- Pet the Dog to get a Puzzle Skip :) (No, really.)

## Changes:

- Starting inventory behavior improved (Consider starting items like doors and lasers logically even if they aren't part of the mode)
- Audio Log hint system improved (On low hint counts, you will no longer get the same locations hinted every time, i.e. always hints are shuffled)

## Fixes:

- Many fixes to symbol requirements
- Fixes to "shuffle_postgame" (What checks are evaluated as "postgame" in specific modes)
- Logically irrelevant doors are now "useful" instead of "progression"
2023-02-01 21:18:07 +01:00
PoryGone
3cef39a387 SMW & DKC3: Ship as .apworld (#1426) 2023-02-01 21:15:01 +01:00
el-u
ffff9ece55 core: properly declare from_any as an abstract classmethod 2023-02-01 21:14:11 +01:00
PoryGone
dc2aa5f41e SMW: v1.1 Content Update (#1344)
* Make Bowser unkillable on Egg Hunt

* Increment Data Package version

Changed a location name.

* Baseline for Bowser Rooms shuffling

* Add boss shuffle

* Remove extra space

* Overworld Palette Shuffle

* Fix Literature Trap typo

* Handle Queuing traps and new Timer Trap

* Fix trap name and actually create them

* Early Climb and Overworld Speed

* Add correct tooltip for Early Climb

* Tooltip text edit

* Address unconnected regions

* Add option to fully exclude Special Zone levels from the seed

* Fix Chocolate Island 4 Dragon Coins logic

* Update worlds/smw/Client.py to use `getattr`
2023-01-30 05:53:56 +01:00
black-sliver
428344b6bc setup: honor build automation ...
... and reorder imports to PEP it up
2023-01-30 02:33:41 +01:00
Fabian Dill
ea2175cb8a MultiServer: load old forfeit_mode if release_mode not present 2023-01-30 00:54:57 +01:00
Fabian Dill
11873e059a Setup: don't use dependency before it's installed 2023-01-29 22:36:04 +01:00
Fabian Dill
6c1023a88c Subnautica: fix swim_rule considers items property use (#1419) 2023-01-29 22:12:39 +01:00
The T
0be0732a2b WebHost: FAQ: change "seeds" to "a world" where world is the right term.
Berserker has frequently corrected, that each player's game is a single world, inside a larger seed; not that each player's game is a seed.
2023-01-29 22:11:53 +01:00
lordlou
c9aa283711 SMZ3: chest game fix (#1417)
Fixed DW Chest Game always sending checks for the 2 chests. The checks sent were the proper "Chest Game" location for the first time the player would open the second chest but all other times, it would send either the last check that was done or default to sending location 0x00 which is SM "Power Bomb (Crateria surface)".
2023-01-28 01:51:19 +01:00
Fabian Dill
cf2204a861 Factorio: add option "Tech Cost Distribution" (#1404)
* Factorio: add option "Tech Cost Distribution"

* TextClient: None out game on disconnect

* TextClient: disconnect is async
2023-01-27 15:38:12 -08:00
Fabian Dill
dfdcad28e5 TextcVient: game reset (#1416)
* TextClient: None out game on disconnect
2023-01-28 00:32:48 +01:00
Fabian Dill
ab4324c901 Factorio: add option "ramping tech cost" (#1403)
* Factorio: add option "ramping tech cost"

* Factorio: fix missing s

* Factorio: add display_name to ranmping tech costs
2023-01-27 15:30:05 -08:00
Fabian Dill
1e251dcdc0 Setup: new cx-Freeze just dropped 2023-01-27 20:06:56 +01:00
espeon65536
9c1f7bfea9 oot: remove special NL exceptions in entrance randomization
turns out they were causing lots of issues
2023-01-26 21:24:27 +01:00
KonoTyran
5393563700 MultiServer: Data Storage Additions #1411
adds 3 new operations to datastorage that allows adding and removing of elements from list and dicts.
2023-01-25 06:14:46 +01:00
toasterparty
28576f2b0d OC2: decrease default difficulty (#1413) 2023-01-25 01:04:13 +01:00
Fabian Dill
ba519fecd0 Setup: update some stuff to 6.14.0 cx-Freeze (#1412)
* Setup: update some stuff to 6.14.0 cx-Freeze

* Fix BuildCommand and replace include_files by cutom step

* setup.py: bit more cleanup for extra_libs

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-01-25 00:20:26 +01:00
espeon65536
86fb450ecc Core: recache all locations before locality rules
Some worlds would not trigger a recache, causing locations to be missed when setting locality rules.
2023-01-24 06:14:31 +01:00
SonicRPika
920240cb6f Pokemon Red and Blue: Fix to having all traps disabled (#1408) 2023-01-24 04:34:45 +01:00
Fabian Dill
53dd0d5a7d SC2: verify downloaded data is a zipfile 2023-01-24 03:54:23 +01:00
Fabian Dill
807f544b26 SC2: use warning log level for potentially broken map files 2023-01-24 03:45:43 +01:00
SoldierofOrder
1d1693df62 SC2Client: Changes to /download_data and feedback (#1347)
* SC2Client: Added feedback for users who have map files, but no version #.

* SC2Client: Fixed a missing space.

* SC2Client: /download_data now always forces a download.
2023-01-24 03:44:12 +01:00
Fabian Dill
51574959ec Setup: add moduleupdater prompt to setup.py 2023-01-24 03:42:55 +01:00
Fabian Dill
04f726aef2 kvui: always display all tab headers (#1399) 2023-01-24 03:42:34 +01:00
CaitSith2
8a4298e504 ALttP: Fix hint tile hints being potentially useless with item links. (#1400)
* ALttP: Fix hint tile hints being potentially useless with item links.

* use the set returned from world.get_player_groups(player)

* Move the group resolving to BaseClasses. Fix silver arrow hints as well.
2023-01-24 03:42:13 +01:00
Fabian Dill
e7f8f40464 Factorio: fix automation-level tech costs before automation (#1402)
* Factorio: fix automation-level tech costs before automation

* Factorio: remove double-rolling of science cost
2023-01-24 03:36:50 +01:00
Fabian Dill
847582ff5f Server: fix release_mode (#1407)
* Server: fix release_mode

* Core: actually rename forfeit to release across the program
2023-01-24 03:36:27 +01:00
recklesscoder
1a44f5cf1c CommonClient: Fix address pre-selection (#1406) 2023-01-23 04:59:51 +01:00
Fabian Dill
032bc75070 Core: demote logfile deletion loglevel to debug 2023-01-23 02:23:16 +01:00
Alchav
fb47483212 Pokémon R/B: Fix trainersanity location name 2023-01-23 00:38:38 +01:00
Alchav
d185df3972 Pokémon R/B: Use local random object when randomizing trainer parties in generate_output 2023-01-23 00:38:38 +01:00
lordlou
941dcb60e5 SM: fixed flawed and limited comeback check (#1398)
The issue at hand is fixing impossible seeds generated by a lack of properly checking if the player can come back to its previous region after reaching for a new location, as reported here: https://discord.com/channels/731205301247803413/1050529825212874763/1050529825212874763

The previous attempt at checking for comeback was only done against "Landing Site" and the custom start region which is a partial solution at best. For exemple, generating a single player plando seed with a custom starting location at "red_brinstar_elevator" with a forced red door at "RedTowerElevatorBottomLeft" and 2 Missiles set at "Morphing Ball" and "Energy Tank, Brinstar Ceiling" would generate an impossible seed where the player is expected to go through the green door "LandingSiteRight" with no Supers to go to the only possible next location "Power Bomb (red Brinstar spike room)". This is because the comeback check would pass because it would consider coming back to "Landing Site" enough.

The proposed solution is keeping a record of the last accessed region when collecting items. It would then be used as the source of the comeback check with the destination being the new location. This check had to be moved from can_fill() to can_reach() because the maximum_exploration_state of the AP filler only use can_reach().

Its still not perfect because collect() can be called in batch for many items at a time so the last accessed region will be set as the last collected item and will be used for the next comeback checks.

This was tested a bit with the given exemple above (its now failing generation) and by generating some 8 SM players seed with many door color rando, area rando and boss rando enabled.
2023-01-23 00:36:18 +01:00
Fabian Dill
25756831b7 Core: mark version as 0.3.8 2023-01-21 17:30:30 +01:00
Fabian Dill
9add1495d5 SSL support (#1340) 2023-01-21 17:29:27 +01:00
SonicRPika
34dba007dc Pokemon Red and Blue: Updates to trap weights and tracker support (#1395)
* Added cerulean_cave_condition to fill_slot_data

Added `cerulean_cave_condition` to the `fill_slot_data` function, for a poptracker feature being worked on as it was missing

* Added the potential for any traps to be disabled

Adding the ability to disable any kind of trap, for example if you want any status trap except being Poisoned. Will add a contingency to not try and roll a trap if they are all set to disabled.

* Added contingency to if all traps are disabled

Added a contingency to creating items such that it doesn't try to create a trap if all the traps are disabled

* Updated variable name

Edited name of variable to follow PEP 8 variable naming conventions
2023-01-20 18:49:12 +01:00
Fabian Dill
02d3eef565 Core: convert mixture of Plando Options and Settings into just Options 2023-01-19 17:20:23 +01:00
Fabian Dill
c839a76fe7 LttP: allow hinting and tracking "Take Any" type shops (#1392)
* LttP: allow hinting and tracking "Take Any" type shops
fix broken behaviour since bow/cave split


Co-authored-by: CaitSith2 <d_good@caitsith2.com>
2023-01-19 16:17:43 +01:00
alwaysintreble
29e1c3dcf4 LTTP: fix open pyramid for real this time (#1393) 2023-01-19 16:17:16 +01:00
recklesscoder
f6616da5a9 Docs/Subnautica: Updated console instructions, misc clarifications (#1394) 2023-01-19 16:09:08 +01:00
Fabian Dill
8678e02d54 Subnautica: correct doc string placement for early seaglide 2023-01-19 00:03:26 +01:00
Fabian Dill
2f37bedc92 Tests: ensure item name groups do not collide with item names (#1074) 2023-01-18 15:45:48 +01:00
Alchav
91fdfe3e17 Pokémon R/B: Add inheritance to "Completely Random" option as well 2023-01-18 04:26:40 +01:00
Alchav
a41b0051a6 Pokémon R/B: Fix TM/HM compatibility bug 2023-01-18 04:26:40 +01:00
alwaysintreble
b8abe9f980 Tests: add a test to check for dupe locations (#1378) 2023-01-15 20:18:32 +01:00
alwaysintreble
dd3ae5ecbd core: write the plando settings to the spoiler log (#1248)
Co-authored-by: Zach Parks <zach@alliware.com>
2023-01-15 18:10:26 +01:00
PoryGone
e96602d31b SA2B: Fix Gate region connections (#1384) 2023-01-15 17:55:36 +01:00
el-u
81d953daa3 alttp: add item rules for prize locations (#1380) 2023-01-14 14:29:54 +01:00
Alchav
bd774a454e Pokémon R/B: Fix Safari Zone Gate bug (#1381) 2023-01-14 04:59:09 +01:00
espeon65536
ca724c92ad oot: force itempool to higher settings if required by heart logic 2023-01-13 23:53:13 +01:00
espeon65536
11eebbbd32 Ocarina of Time: 0.3.7 hotfixes round 2 (#1351)
* oot: repair closed forest + dungeon ER

* oot: finally skip triforce pieces in balancing

* oot: fix mq_dungeons_mode set to mq or count

* oot: force 0.3.7 client
hopefully this makes people update

* oot: temp fix for skip-child-zelda crash
eventually I want to decide on a better fix for this though

* oot: remove skip-child-zelda item inside if tree

* oot: fix classification of some thieves hideout locations in tracker

* oot: fix regional shuffle for hideout keys and ganon boss key

* oot: properly attach hints to dungeon locations

* Fix entrance shuffle flag not being set correctly due to new dungeon shuffle option format
2023-01-12 20:20:49 +01:00
Doug Hoskisson
608794cded ZillionClient: fix manual disconnect (#1266) 2023-01-07 10:27:43 +01:00
eudaimonistic
816de5ff02 Docs: code_of_conduct.md (#1350)
Update to point of contact.
2023-01-07 10:24:41 +01:00
Fabian Dill
0b941e2268 LttP: attempt at preventing ghost location checks (#1355) 2023-01-07 10:22:15 +01:00
beauxq
57713cda50 Zillion: minor terrain logic update
standing on a moving walkway requires 2 columns of standing space
2023-01-07 10:12:45 +01:00
Jarno
f56cdd6ec3 Sudoku: Hints will no-longer duplicate (#1371) 2023-01-07 10:10:20 +01:00
t3hf1gm3nt
773c517757 update LTTP player template to add all universal AP options (#1372) 2023-01-07 10:09:33 +01:00
JoshuaEagles
2509b7fa3f SA2B: Add Linux section to setup guide (#1374) 2023-01-07 10:00:19 +01:00
toasterparty
10652d23e0 [OC2] Logic: fixes fails when horde levels/items are excluded from location pool (#1369) 2023-01-05 15:49:50 +01:00
JoshuaEagles
f0bc3d33ac Subnautica: add Linux note to setup guide (#1365) 2023-01-04 15:26:32 +01:00
toasterparty
92d1ed60c6 [OC2] Fix "Moon 1-5" never appearing in level pool (#1366) 2023-01-04 15:21:52 +01:00
Zach Parks
fe2b431821 MultiServer: Remove forced_auto_forfeit (#1363) 2023-01-02 19:26:34 -06:00
Zach Parks
0cc83698f9 Docs: Add special name keywords to docs. (#1353) 2023-01-02 14:42:47 -06:00
Alchav
428f643b07 Pokémon R/B: Fix Pokémon Tower 7F crash (#1362) 2023-01-02 13:29:44 -06:00
Fabian Dill
d4e2b75520 Clients: retry connection with ssl (#1341) 2023-01-02 20:24:54 +01:00
Fabian Dill
96cc7f79dc Subnautica: fix early seaglide 2023-01-02 20:24:14 +01:00
Fabian Dill
bdfbc7e14a Network: allow sending frozenset 2023-01-02 20:23:31 +01:00
Fabian Dill
94c6562f82 Tests: make sure DB overwrite actually takes 2023-01-02 20:23:00 +01:00
alwaysintreble
22fe31a141 Generate: fix default utils options (#1361) 2023-01-02 12:48:31 -06:00
alwaysintreble
72fa19ee1f MultiServer/WebHost: rename all references to forfeit and deprecate it (#1243)
* Webhost: rename all references to forfeit and deprecate it

* needed some renames in multiserver for all the commands to function

* remove forfeit commands

* support forfeit_mode for clients

* rename `forfeit_player` to `release_player`
2023-01-02 12:29:21 -06:00
Zach Parks
d899e918b4 Rogue Legacy: Fix early vendors and architect... again. (#1359) 2023-01-02 12:25:47 -06:00
Zach Parks
33d31c4f0f WebHost: Capitalize Special Range choices to keep consistency. (#1360) 2023-01-02 12:25:33 -06:00
Jarno
9c3c69702a WebHost: Fixed game order by title in Site Map (#1349) 2023-01-02 12:24:08 -06:00
Alchav
dae1a3e0f9 Pokemon R/B: Add Revive to better_shops (#1352) 2023-01-02 12:21:08 -06:00
Alchav
1f1ef10cfe [Pokémon R/B] Fix DeathLink softlock and increment data version (#1348) 2022-12-24 08:25:34 +01:00
Alchav
760af59308 [Pokemon R/B] Fix missing lift key logic 2022-12-23 09:39:21 +01:00
Alchav
3dd7e3e706 [Pokemon R/B] actually implement lose_money_on_blackout 2022-12-23 09:39:21 +01:00
espeon65536
189b129dca oot: repair closed forest + dungeon ER 2022-12-22 06:40:51 +01:00
Fabian Dill
092e8d14ad Core: Make apworlds function mostly before Python 3.10 2022-12-20 17:24:04 +01:00
el-u
4cfc73b582 lufia2ac: rename starting_capsule/starting_party options to default_capsule/default_party 2022-12-19 12:45:49 +01:00
el-u
ff9c11d772 lufia2ac: prevent using party member items if party is full 2022-12-19 12:45:49 +01:00
Alchav
b83aec5c12 [Pokemon R/B] add logic to Fighting Dojo and check for non-vanilla old_man setting for cinnabar gym 2022-12-19 07:04:26 +01:00
Alchav
caf63dd737 [Pokemon R/B] Allow 0 exp setting and add logic rule to Cinnabar Gym to ensure higher level Pokémon are catchable 2022-12-19 07:04:26 +01:00
espeon65536
395d35571c Ocarina of Time 0.3.7 hotfixes (#1336)
- Since multiprocessing and exceptions apparently don't interact well, any exceptions in `generate_output` are handled more gracefully now. The event is always set as well, so the process will no longer hang on an exception there.
- The triforce object file was renamed in 7.0. I didn't change the filename in the code, so it would crash on non-Windows systems.
- Some hint distributions just crash, so I'm temporarily removing them. It will take a while to port the relevant functionality and I'd rather not hold up the next version release.
2022-12-19 07:03:38 +01:00
Fabian Dill
e0be79639c Subnautica: 2.0 compatibility (#1329) 2022-12-17 17:42:02 +01:00
Fabian Dill
37b7f0d32d WebHost: fix LttP tracker crash 2022-12-17 15:22:29 +01:00
el-u
50677ee6a2 lufia2ac: document changes from the vanilla game 2022-12-17 09:26:26 +01:00
PoryGone
f8bc3359c7 SMW: Adjust Butter Bridge 2 - Dragon Coins logic (#1332) 2022-12-17 09:18:23 +01:00
toasterparty
6e537e17e6 [OC2] New Option - Shorter Horde Levels (#1328) 2022-12-16 16:52:15 +01:00
toasterparty
e853fc208b [OC2] Remove DLC items from item pool if vanilla level order (#1323) 2022-12-16 16:48:57 +01:00
toasterparty
1a36da33b4 [OC2] Horde Levels Logically Give 1-Star (#1322) 2022-12-16 16:48:12 +01:00
0rganics
56fc614588 SC2: Bugfix for web tracker (#1324) 2022-12-13 07:35:12 +01:00
espeon65536
47f1fcf382 OoT: Fix region_has_shortcuts crash when boss shuffle is on (#1325) 2022-12-13 07:34:17 +01:00
el-u
51c6be047f Lufia II Ancient Cave: implement new game (#1218)
Co-authored-by: wordfcuk <greili1985@gmail.com>
2022-12-12 02:36:18 +01:00
Fabian Dill
2c46c48ba9 WebHost: reduce tracker refresh delay (#1290) 2022-12-12 00:30:43 +01:00
Alchav
32820ba653 Pokemon R/B: Bug fixes and add trap weights (#1319)
* [Pokemon R/B] Move type chart rando to generate_early and add trap weights

* [Pokemon R/B] Update patching process on client to verify hash
2022-12-11 13:51:28 -06:00
Fabian Dill
6173bc6e03 Core: move create_playthrough under Spoiler as method (#1310)
Core: split create_playthrough, allowing skipping of paths
2022-12-11 13:48:26 -06:00
Magnemania
e71ea94fe5 SC2: Various bugfixes (#1267)
* SC2: Fixed nondeterminism resulting from early unit placement

* SC2: Renamed "world" arguments to "multiworld"

* SC2: Fixed All-In Ground including anti-air in defense score, fixed error in beats_protoss_deathball

* SC2: Fixed No Logic using logic on Beat events

* SC2: Fixed /unfinished command failing when All-In available
2022-12-11 13:46:24 -06:00
0rganics
e3f169b4c3 WebHost: Add SC2WoL game specific tracker (#1270) 2022-12-11 13:43:31 -06:00
kindasneaki
e4e74074f0 Docs: change connecting to archipelago in RoR2 setup guide (#1320)
* Change archipelago mod download page

* Docs: change connecting to archipelago in RoR2 setup guide
2022-12-11 13:38:11 -06:00
Fabian Dill
149630d532 Docs: add remote_start_inventory info in generate_output (#1316) 2022-12-11 14:14:27 +01:00
Fabian Dill
2dcfbff751 Tests: now autoload tests from /worlds/*/test (#1318)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-12-11 13:15:23 +01:00
PoryGone
ec45479c52 SMW: Fix some stages walking Mario onto invalid tile on recomplete (#1317)
If you beat a stage that was already beaten, Mario would sometimes walk onto a tile that he shouldn't.
2022-12-11 04:14:04 +01:00
espeon65536
aee0df5359 Ocarina of Time 7.0 (#1277)
## What is this fixing or adding?
- Adds the majority of OoTR 7.0 features:
  - Pot shuffle, Freestanding item shuffle, Crate shuffle, Beehive shuffle
  - Key rings mode
  - Dungeon shortcuts to speed up dungeons
  - "Regional" shuffle for dungeon items
  - New options for shop pricing in shopsanity
  - Expanded Ganon's Boss Key shuffle options
  - Pre-planted beans
  - Improved Chest Appearance Matches Contents mode
  - Blue Fire Arrows
  - Bonk self-damage
  - Finer control over MQ dungeons and spawn position randomization
- Several bugfixes as a result of the update:
  - Items recognized by the server and valid starting items are now in a 1-to-1 correspondence. In particular, starting with keys is now supported.
  - Entrance randomization success rate improved. Hopefully it is now at 100%. 

Co-authored-by: Zach Parks <zach@alliware.com>
2022-12-11 04:11:40 +01:00
Fabian Dill
2cdd03f786 Network: implement 0.4 marked compatibility removals (#757)
* world remote items handling
* players list when connecting
2022-12-11 02:59:17 +01:00
Zach Parks
ce42fda85f Rogue Legacy: Fix early vendors and architect not being added to pool. (#1314) 2022-12-10 17:24:05 -06:00
Fabian Dill
78a18dee4e Subnautica: give Early Seaglide a display name (#1313) 2022-12-10 11:00:18 -06:00
beauxq
b7d46004e2 Zillion: fix invalid slot data from race condition 2022-12-10 11:15:19 +01:00
Jarno
c3fe341736 Docs: slot_data typing (#1300)
* Docs: slot_data typing

* Properly escaped brackets [ ]
2022-12-09 10:24:08 +01:00
Fabian Dill
79bb43b77c Core: embed custom datapackage into .archipelago (#1288)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-12-08 21:23:31 +01:00
Zach Parks
bedc78d335 Rogue Legacy: Remove relative imports and move to .apworld. (#1304)
* Remove relative import and remove `set_rule` usage.

* Set Rogue Legacy to be .apworld.
2022-12-08 08:54:49 -06:00
t3hf1gm3nt
1b582e5b09 docs: update pokemon and oot setup guides about changing lua options (#1303)
- Pokemon guide was missing a point about changing the Lua option in Bizhawk.
- The same point in the OoT guide was missing a step about restarting Bizhawk after changing the Lua option, so updated that as well.
2022-12-08 10:52:52 +01:00
Alchav
f278dd95c5 [Pokémon R/B] Major bug fix + dialogue change (#1305)
* [Pokemon R/B] Major bug fixes

* [Pokemon R/B] Dialogue updates
2022-12-08 10:51:01 +01:00
PoryGone
92f75f3e03 SA2B: Add missing Whistle location (#1306) 2022-12-08 10:47:39 +01:00
Jarno
f5adc7bdc5 docs: world api fixed link (#1299) 2022-12-08 02:57:49 +01:00
Fabian Dill
78d4da53a7 Tests: verify and fix host.yaml/Utils.py match (#1302) 2022-12-08 02:06:34 +01:00
Alchav
e206c065bf Pokémon Red and Blue: Version 2 (#1282)
Adds Trainersanity option (Each non-scripted trainer has a location check, adding 317 locations)
Adds Randomize Pokedex option. It is required to obtain items from Oak's Aides.
Adds option to add all normal shop items to all normal shops.
Adds DeathLink option.
Adds traps.
Improves Type Chart randomization.
Items can be received during battle.
Stores start inventory in ROM. Only requests remote start inventory if patch is from v1.
Fixes logic bugs.
Various other improvements.
2022-12-08 00:38:34 +01:00
Fabian Dill
5273812039 Core: default distribute Factorio and Subnautica as .apworld (#1260) 2022-12-06 23:40:30 -06:00
Fabian Dill
7c3af68e59 ItemLinks: allow linking replacement items as well (#1274) 2022-12-06 23:37:47 -06:00
PoryGone
449973687b SA2B: v2.0 Content Update (#1294)
Changelog:

Features:
- Completely reworked mission progression system
  - Control of which mission types can be active per-gameplay-style
  - Control of how many missions are active per-gameplay-style
  - Mission order shuffle
- Two new Chaos Emerald Hunt goals
  - `Chaos Emerald Hunt` involves finding the seven Chaos Emeralds and beating Green Hill
  - `FinalHazard Chaos Emerald Hunt` is the same, but with the FinalHazard fight at the end of Green Hill
- New optional Location Checks
  - Keysanity (Chao Containers)
  - Whistlesanity (Animal Pipes and hidden whistle spots)
  - Beetlesanity (Destroying Gold Beetles)
- Option to require clearing all active Cannon's Core Missions for access to the Biolizard fight in `Biolizard` goal
- Hard Logic option
- More Music Options
  - Option to use SADX music
  - New `Singularity` music shuffle option
- Option to choose the Narrator theme 
- New Traps
  - Tiny Trap is now permanent within a level
  - Gravity Trap
  - Exposition Trap
  
Quality of Life:
- Significant revamp to Stage Select screen information conveyance
  - Icons are displayed for:
    - Relevant character's upgrades
    - Which location checks are active/checked
    - Chaos Emeralds found (if relevant)
    - Gate and Cannon's Core emblem costs
  - The above stage-specific info can also be viewed when paused in-level
    - The current mission is also displayed when paused
- Emblem Symbol on Mission Select subscreen now only displays if a high enough rank has been gotten on that mission to send the location check
- Hints including SA2B locations will now specify which Gate that level is located in
- Save file now stores slot name to help prevent false location checks in the case of one player having multiple SA2B slots in the same seed
- Chao Intermediate and Expert race sets are now swapped, per player feedback
  - Intermediate now includes Beginner + Challenge + Hero + Dark
  - Expert now includes Beginner + Challenge + Hero + Dark + Jewel
- New mod config option for the color of the Message Queue text

Bug Fixes:
- Fixed bug where game stops properly tracking items after 127 have been received.
- Several logic fixes
- Game now refers to `Knuckles - Shovel Claws` correctly
- Minor AP World code cleanup
2022-12-07 06:20:02 +01:00
Fabian Dill
f5638552cc Docs: add ClassVar marker to World class (#1224) 2022-12-06 23:12:56 -06:00
Fabian Dill
78ee19de51 Pokemon R/B: always bind file handling to client (#1261) 2022-12-06 23:02:54 -06:00
Zach Parks
82444229be Rogue Legacy: More refactoring and clean up. (#1297)
* Rogue Legacy: More refactoring and clean up.

* Also marked Blacksmith as Progression as it's used in a rule.

* Remove extra newline.

* Prevent divide by zero type error.

* Scratch last commit, got the math mixed in my head.

* Clarified name of rule regarding percentage of stat upgrades.

* Move early vendors/architect creation into `create_items` logic.

* Rename parameter in `create_region`.

* Rename local var in `create_region`.

* Removed accidental links in Markdown docs.

* Refactor `create_region` signature and caller.

* Remove redundant if-else.

* Revert change to if-else, and moved item_pool to function instead of obj var.

* Rename LegacyLogic to RLLogic.

* Remove LogicMixin for rules.
2022-12-06 20:49:55 -06:00
alwaysintreble
2cc03d003a Core: fix bug that caused world option overrides to fail (#1293)
* core: fix bug that caused world option overrides to fail

* copy paste sliver's better code that works as intended

* Fix whitespace

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-12-06 00:50:11 +01:00
recklesscoder
0e4fa378dd WebHost: Detect confusion of settings zip and seed zip (#1227) 2022-12-06 00:40:51 +01:00
Fabian Dill
ffc000ec91 Network: remove deprecated IgnoreGame tag 2022-12-05 23:20:19 +01:00
Fabian Dill
32b8f9f9f3 WebHost: restore old silent ignore of mimetypes in json getting (#1292)
* WebHost: restore old silent ignore of mimetypes in json getting of /api/generate

* Tests: add tests for /api/generate
2022-12-05 22:27:15 +01:00
Yoshi348
4412434976 meta.yaml: update progression balancing (#1283)
* Update meta.yaml to numeric progression balancing

Instead of the old on/off system

* normal/disabled
2022-12-05 22:26:44 +01:00
espeon65536
9bdbced51f Hylics 2: create victory location earlier to ensure it is cached correctly (#1291)
Fixes generation issues where the victory location could not be found from MultiWorld.get_locations
2022-12-04 21:04:01 -06:00
Fabian Dill
bd574ef261 WebHost: save datatables state (#1145)
* WebHost: save datatables state

* WebHost: Fix DataTables local storage keys.

Co-authored-by: recklesscoder <57289227+recklesscoder@users.noreply.github.com>
2022-12-04 20:39:07 -06:00
BordynConfused
45719eb7e0 Hylics2:Logic Fixes (#1281)
Added Juice Ranch and Worm Pod to logic

Extra parenthesis removed

* Delete log.txt
Not used

Transitioned all whitespace characters to Linux /n from Windows /r/n

Updated rules for Rescue Blerol 1 and 2 to avoid impossible seed generation.  Fixed member requirements for Vault Bomb. Added Juice Ranch: Fridge for consistency with other checks behind combat. Added party_shuffle rules to vault medallions, removed jail key requirement (not needed)
2022-12-05 02:25:16 +01:00
kindasneaki
d81fd280fa RoR2: Change risk of rain mod download page link (#1269) 2022-12-04 19:16:03 -06:00
Fabian Dill
6b57275859 Server: attempt to make nothing found message clearer (#1289) 2022-12-04 19:06:13 -06:00
Jarno
63f012cce7 Timespinner: Enter Sandman flag (#1263) 2022-12-04 22:02:46 +01:00
Fabian Dill
679cb3e197 Factorio: fix revealed tech tree hinting old location names 2022-12-04 21:29:35 +01:00
Fabian Dill
38b5a90c07 WebHost: update modules 2022-12-04 21:29:02 +01:00
Fabian Dill
203f17f0f6 Subnautica: prompt users to update 2022-12-04 21:28:44 +01:00
Fabian Dill
65995cd586 Network: implement read_only datastore keys: hints and slot_data (#1286)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2022-12-03 23:29:33 +01:00
PoryGone
64e2d55e92 SMW: Bug+logic fixes (#1279)
* Allow levels to scroll vertically without climb or run

* Account for needing Yoshi for VB2 Dragon Coins

* Don't allow messages on intro level
2022-12-02 06:25:02 +01:00
lordlou
ef66f64030 SMZ3: shop check fix (#1257) 2022-12-01 03:51:29 +01:00
NewSoupVi
e641c3ca1b The Witness: Fix Expert PP2 Access Logic 2022-12-01 03:46:11 +01:00
alwaysintreble
111c3186bd Core: change signatures in autoworld from world to multiworld (#1273) 2022-12-01 03:45:22 +01:00
toasterparty
f0e9080108 [OC2] Difficulty Adjustment/Documentation (#1278)
* Slightly relax difficulty of final level

* Table to help translate player skill to yaml setting
2022-12-01 03:44:08 +01:00
Jarno Westhof
fd8867c782 [Witness, Docs] Updated link to repository owner, as repository was transferred 2022-12-01 03:42:49 +01:00
Fabian Dill
f81d2653e0 Subnautica: implement swim_rule option and skip goal prog balancing (#1258) 2022-11-28 07:43:04 +01:00
Fabian Dill
1288f15e45 Core: Fill fix local logic conflict (#1271) 2022-11-28 07:03:09 +01:00
Fabian Dill
cde2a6e754 Core: log additional system info that may be relevant to errors (#1255) 2022-11-27 19:52:36 -06:00
Jarno
81dd1e359b Sudoku: Updated text about duplicated hints now the client has been improved (#1264) 2022-11-27 19:30:35 -06:00
alwaysintreble
8dffd87bee LttP: fix open pyramid settings parsing (#1253)
* lttp: fix open pyramid settings prasing

* accidentally left default changed when committing
2022-11-27 19:29:18 -06:00
Fabian Dill
67be80e59d Docs: datapackage typing (#1229)
* Docs: add ClassVar marker to World class

* Docs: add typing to network_data_package
2022-11-27 19:25:53 -06:00
recklesscoder
ff1f5569e7 Factorio: Option descriptions, peaceful mode setup, docs (#1233)
* Docs/Factorio: Flesh out lacking option descriptions.

* Docs/Factorio: Added instructions on peaceful mode.

* Docs/Factorio: Use subheadings for each "other setting".

* Docs/Factorio: Mention that `tech_tree_information: full` grants hints.

* Docs/Factorio: Instructions to use .yaml checker after editing your .yaml manually.
2022-11-27 04:01:10 +01:00
NewSoupVi
8b9b482972 The Witness: Logic Fix (Broken Seed reported)
Jungle Discard requires Arrows, not Triangles
2022-11-27 02:08:56 +01:00
tctompk
d0ce44cd38 Pokemon RB: Fix broken link in Pokemon setup instructions (#1254)
The setup_en.md file had an un-escaped URL that was breaking a link; this fixes the link by escaping the spaces in the URL.
2022-11-24 21:11:46 +01:00
PoryGone
aae78a8a12 Core: Add MultiServer command to check a specific location (#1242) 2022-11-20 22:38:34 +01:00
Fabian Dill
7a5e11e8d4 Core: streamline Multiworld.get_*_locations calls (#1234) 2022-11-20 20:50:32 +01:00
Zach Parks
a9ab53cb8b WebHost/Core: Defer creating slots that have patch files until after multidata is loaded and remove redundant code. (#1250)
* WebHost: On uploads, infer player name if missing in file name.

* Remove conditional logic for not including player name in file name.

* quick readability tweak to "fix"

* Refactored `upload_zip_to_db` to clean up redundancies and fix issues

* Rename `patches` to `files`

* fix comment
2022-11-20 13:39:52 -06:00
Fabian Dill
5ed8c2e1c0 Core: datetime-tag log files and delete old ones 2022-11-20 18:43:44 +01:00
Fabian Dill
67128ece38 LttP: make xxtea only required for race generation 2022-11-20 18:43:26 +01:00
Rome Reginelli
8aed24151f SNIClient: fix SNI not launching with old host.yaml (#1249)
This happens when switching from Appimage 0.3.5 to Appimage 0.3.6 without deleting the user's host.yaml
2022-11-20 11:14:13 +01:00
872 changed files with 119882 additions and 29376 deletions

View File

@@ -2,10 +2,20 @@
name: Build
on: workflow_dispatch
on:
push:
paths:
- '.github/workflows/build.yml'
- 'setup.py'
- 'requirements.txt'
pull_request:
paths:
- '.github/workflows/build.yml'
- 'setup.py'
- 'requirements.txt'
workflow_dispatch:
env:
SNI_VERSION: v0.0.84
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
@@ -15,21 +25,18 @@ jobs:
build-win-py38: # RCs will still be built and signed by hand
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Install python
uses: actions/setup-python@v3
uses: actions/setup-python@v4
with:
python-version: '3.8'
- name: Download run-time dependencies
run: |
Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/${Env:SNI_VERSION}/sni-${Env:SNI_VERSION}-windows-amd64.zip -OutFile sni.zip
Expand-Archive -Path sni.zip -DestinationPath SNI -Force
Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip
Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force
- name: Build
run: |
python -m pip install --upgrade pip setuptools
pip install -r requirements.txt
python -m pip install --upgrade pip
python setup.py build_exe --yes
$NAME="$(ls build)".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
@@ -39,7 +46,7 @@ jobs:
Rename-Item exe.$NAME Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
- name: Store 7z
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
@@ -49,14 +56,14 @@ jobs:
runs-on: ubuntu-18.04
steps:
# - copy code below to release.yml -
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v3
uses: actions/setup-python@v4
with:
python-version: '3.9'
- name: Install build-time dependencies
@@ -69,19 +76,15 @@ jobs:
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz
rm sni-*.tar.xz
mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
run: |
# pygobject is an optional dependency for kivy that's not in requirements
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
# charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
pip install -r requirements.txt
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"
@@ -91,14 +94,18 @@ jobs:
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
- name: Build Again
run: |
source venv/bin/activate
python setup.py build_exe --yes
- name: Store AppImage
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
retention-days: 7
- name: Store .tar.gz
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}

View File

@@ -14,9 +14,17 @@ name: "CodeQL"
on:
push:
branches: [ main ]
paths:
- '**.py'
- '**.js'
- '.github/workflows/codeql-analysis.yml'
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
paths:
- '**.py'
- '**.js'
- '.github/workflows/codeql-analysis.yml'
schedule:
- cron: '44 8 * * 1'
@@ -35,11 +43,11 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v2
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -50,7 +58,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -64,4 +72,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
uses: github/codeql-action/analyze@v2

View File

@@ -3,23 +3,29 @@
name: lint
on: [push, pull_request]
on:
push:
paths:
- '**.py'
pull_request:
paths:
- '**.py'
jobs:
build:
flake8:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up Python 3.9
uses: actions/setup-python@v1
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Install dependencies
run: |
python -m pip install --upgrade pip wheel
pip install flake8 pytest pytest-subtests
pip install flake8
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Lint with flake8
run: |

View File

@@ -8,7 +8,6 @@ on:
- '*.*.*'
env:
SNI_VERSION: v0.0.84
ENEMIZER_VERSION: 7.1
APPIMAGETOOL_VERSION: 13
@@ -36,14 +35,14 @@ jobs:
- name: Set env
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
# - code below copied from build.yml -
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Install base dependencies
run: |
sudo apt update
sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0
sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below
- name: Get a recent python
uses: actions/setup-python@v3
uses: actions/setup-python@v4
with:
python-version: '3.9'
- name: Install build-time dependencies
@@ -56,20 +55,16 @@ jobs:
chmod a+rx appimagetool
- name: Download run-time dependencies
run: |
wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz
tar xf sni-*.tar.xz
rm sni-*.tar.xz
mv sni-* SNI
wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z
7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z
- name: Build
run: |
# pygobject is an optional dependency for kivy that's not in requirements
"${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools
# charset-normalizer was somehow incomplete in the github runner
"${{ env.PYTHON }}" -m venv venv
source venv/bin/activate
pip install -r requirements.txt
python setup.py build --yes bdist_appimage --yes
"${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer
python setup.py build_exe --yes bdist_appimage --yes
echo -e "setup.py build output:\n `ls build`"
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..

View File

@@ -3,7 +3,25 @@
name: unittests
on: [push, pull_request]
on:
push:
paths:
- '**'
- '!docs/**'
- '!setup.py'
- '!*.iss'
- '!.gitignore'
- '!.github/workflows/**'
- '.github/workflows/unittests.yml'
pull_request:
paths:
- '**'
- '!docs/**'
- '!setup.py'
- '!*.iss'
- '!.gitignore'
- '!.github/workflows/**'
- '.github/workflows/unittests.yml'
jobs:
build:
@@ -23,18 +41,20 @@ jobs:
os: windows-latest
- python: {version: '3.10'} # current
os: windows-latest
- python: {version: '3.10'} # current
os: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v1
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip wheel
pip install flake8 pytest pytest-subtests
python -m pip install --upgrade pip
pip install pytest pytest-subtests
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Unittests
run: |
pytest test
pytest

10
.gitignore vendored
View File

@@ -4,9 +4,11 @@
*_Spoiler.txt
*.bmbp
*.apbp
*.apl2ac
*.apm3
*.apmc
*.apz5
*.aptloz
*.pyc
*.pyd
*.sfc
@@ -24,6 +26,7 @@
*multisave
*.archipelago
*.apsave
*.BIN
build
bundle/components.wxs
@@ -48,7 +51,9 @@ Output Logs/
/freeze_requirements.txt
/Archipelago.zip
/setup.ini
/installdelete.iss
/data/user.kv
/datapackage
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -135,6 +140,8 @@ venv/
ENV/
env.bak/
venv.bak/
.code-workspace
shell.nix
# Spyder project settings
.spyderproject
@@ -164,6 +171,7 @@ cython_debug/
jdk*/
minecraft*/
minecraft_versions.json
!worlds/minecraft/
# pyenv
.python-version

516
AdventureClient.py Normal file
View File

@@ -0,0 +1,516 @@
import asyncio
import hashlib
import json
import time
import os
import bsdiff4
import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter, CancelledError
from typing import List
import Utils
from NetUtils import ClientStatus
from Utils import async_start
from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \
get_base_parser
from worlds.adventure import AdventureDeltaPatch
from worlds.adventure.Locations import base_location_id
from worlds.adventure.Rom import AdventureForeignItemInfo, AdventureAutoCollectLocation, BatNoTouchLocation
from worlds.adventure.Items import base_adventure_item_id, standard_item_max, item_table
from worlds.adventure.Offsets import static_item_element_size, connector_port_offset
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = \
"Connection timing out. Please restart your emulator, then restart adventure_connector.lua"
CONNECTION_REFUSED_STATUS = \
"Connection Refused. Please start your emulator and make sure adventure_connector.lua is running"
CONNECTION_RESET_STATUS = \
"Connection was reset. Please restart your emulator, then restart adventure_connector.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
SCRIPT_VERSION = 1
class AdventureCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
super().__init__(ctx)
def _cmd_2600(self):
"""Check 2600 Connection State"""
if isinstance(self.ctx, AdventureContext):
logger.info(f"2600 Status: {self.ctx.atari_status}")
def _cmd_aconnect(self):
"""Discard current atari 2600 connection state"""
if isinstance(self.ctx, AdventureContext):
self.ctx.atari_sync_task.cancel()
class AdventureContext(CommonContext):
command_processor = AdventureCommandProcessor
game = 'Adventure'
lua_connector_port: int = 17242
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.freeincarnates_used: int = -1
self.freeincarnate_pending: int = 0
self.foreign_items: [AdventureForeignItemInfo] = []
self.autocollect_items: [AdventureAutoCollectLocation] = []
self.atari_streams: (StreamReader, StreamWriter) = None
self.atari_sync_task = None
self.messages = {}
self.locations_array = None
self.atari_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
self.deathlink_pending = False
self.set_deathlink = False
self.client_compatibility_mode = 0
self.items_handling = 0b111
self.checked_locations_sent: bool = False
self.port_offset = 0
self.bat_no_touch_locations: [BatNoTouchLocation] = []
self.local_item_locations = {}
self.dragon_speed_info = {}
options = Utils.get_options()
self.display_msgs = options["adventure_options"]["display_msgs"]
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(AdventureContext, self).server_auth(password_requested)
if not self.auth:
self.auth = self.player_name
if not self.auth:
self.awaiting_rom = True
logger.info('Awaiting connection to adventure_connector to get Player information')
return
await self.send_connect()
def _set_message(self, msg: str, msg_id: int):
if self.display_msgs:
self.messages[(time.time(), msg_id)] = msg
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if Utils.get_options()["adventure_options"].get("death_link", False):
self.set_deathlink = True
async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo":
self.seed_name = args['seed_name']
elif cmd == 'Print':
msg = args['text']
if ': !' not in msg:
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "ReceivedItems":
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
elif cmd == "Retrieved":
self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"]
if self.freeincarnates_used is None:
self.freeincarnates_used = 0
self.freeincarnates_used += self.freeincarnate_pending
self.send_pending_freeincarnates()
elif cmd == "SetReply":
if args["key"] == f"adventure_{self.auth}_freeincarnates_used":
self.freeincarnates_used = args["value"]
if self.freeincarnates_used is None:
self.freeincarnates_used = 0
self.freeincarnates_used += self.freeincarnate_pending
self.send_pending_freeincarnates()
def on_deathlink(self, data: dict):
self.deathlink_pending = True
super().on_deathlink(data)
def run_gui(self):
from kvui import GameManager
class AdventureManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Adventure Client"
self.ui = AdventureManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def get_freeincarnates_used(self):
if self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "SetNotify", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}])
await self.send_msgs([{"cmd": "Get", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}])
def send_pending_freeincarnates(self):
if self.freeincarnate_pending > 0:
async_start(self.send_pending_freeincarnates_impl(self.freeincarnate_pending))
self.freeincarnate_pending = 0
async def send_pending_freeincarnates_impl(self, send_val: int) -> None:
await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used",
"default": 0, "want_reply": False,
"operations": [{"operation": "add", "value": send_val}]}])
async def used_freeincarnate(self) -> None:
if self.server and not self.server.socket.closed:
await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used",
"default": 0, "want_reply": True,
"operations": [{"operation": "add", "value": 1}]}])
else:
self.freeincarnate_pending = self.freeincarnate_pending + 1
def convert_item_id(ap_item_id: int):
static_item_index = ap_item_id - base_adventure_item_id
return static_item_index * static_item_element_size
def get_payload(ctx: AdventureContext):
current_time = time.time()
items = []
dragon_speed_update = {}
diff_a_locked = ctx.diff_a_mode > 0
diff_b_locked = ctx.diff_b_mode > 0
freeincarnate_count = 0
for item in ctx.items_received:
item_id_str = str(item.item)
if base_adventure_item_id < item.item <= standard_item_max:
items.append(convert_item_id(item.item))
elif item_id_str in ctx.dragon_speed_info:
if item.item in dragon_speed_update:
last_index = len(ctx.dragon_speed_info[item_id_str]) - 1
dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][last_index]
else:
dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][0]
elif item.item == item_table["Left Difficulty Switch"].id:
diff_a_locked = False
elif item.item == item_table["Right Difficulty Switch"].id:
diff_b_locked = False
elif item.item == item_table["Freeincarnate"].id:
freeincarnate_count = freeincarnate_count + 1
freeincarnates_available = 0
if ctx.freeincarnates_used >= 0:
freeincarnates_available = freeincarnate_count - (ctx.freeincarnates_used + ctx.freeincarnate_pending)
ret = json.dumps(
{
"items": items,
"messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items()
if key[0] > current_time - 10},
"deathlink": ctx.deathlink_pending,
"dragon_speeds": dragon_speed_update,
"difficulty_a_locked": diff_a_locked,
"difficulty_b_locked": diff_b_locked,
"freeincarnates_available": freeincarnates_available,
"bat_logic": ctx.bat_logic
}
)
ctx.deathlink_pending = False
return ret
async def parse_locations(data: List, ctx: AdventureContext):
locations = data
# for loc_name, loc_data in location_table.items():
# if flags["EventFlag"][280] & 1 and not ctx.finished_game:
# await ctx.send_msgs([
# {"cmd": "StatusUpdate",
# "status": 30}
# ])
# ctx.finished_game = True
if locations == ctx.locations_array:
return
ctx.locations_array = locations
if locations is not None:
await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}])
def send_ap_foreign_items(adventure_context):
foreign_item_json_list = []
autocollect_item_json_list = []
bat_no_touch_locations_json_list = []
for fi in adventure_context.foreign_items:
foreign_item_json_list.append(fi.get_dict())
for fi in adventure_context.autocollect_items:
autocollect_item_json_list.append(fi.get_dict())
for ntl in adventure_context.bat_no_touch_locations:
bat_no_touch_locations_json_list.append(ntl.get_dict())
payload = json.dumps(
{
"foreign_items": foreign_item_json_list,
"autocollect_items": autocollect_item_json_list,
"local_item_locations": adventure_context.local_item_locations,
"bat_no_touch_locations": bat_no_touch_locations_json_list
}
)
print("sending foreign items")
msg = payload.encode()
(reader, writer) = adventure_context.atari_streams
writer.write(msg)
writer.write(b'\n')
def send_checked_locations_if_needed(adventure_context):
if not adventure_context.checked_locations_sent and adventure_context.checked_locations is not None:
if len(adventure_context.checked_locations) == 0:
return
checked_short_ids = []
for location in adventure_context.checked_locations:
checked_short_ids.append(location - base_location_id)
print("Sending checked locations")
payload = json.dumps(
{
"checked_locations": checked_short_ids,
}
)
msg = payload.encode()
(reader, writer) = adventure_context.atari_streams
writer.write(msg)
writer.write(b'\n')
adventure_context.checked_locations_sent = True
async def atari_sync_task(ctx: AdventureContext):
logger.info("Starting Atari 2600 connector. Use /2600 for status information")
while not ctx.exit_event.is_set():
try:
error_status = None
if ctx.atari_streams:
(reader, writer) = ctx.atari_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 1+ fields
# 1. A keepalive response of the Players Name (always)
# 2. romhash field with sha256 hash of the ROM memory region
# 3. locations, messages, and deathLink
# 4. freeincarnate, to indicate a freeincarnate was used
data = await asyncio.wait_for(reader.readline(), timeout=5)
data_decoded = json.loads(data.decode())
if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
msg = "You are connecting with an incompatible Lua script version. Ensure your connector " \
"Lua and AdventureClient are from the same Archipelago installation."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
if ctx.seed_name and bytes(ctx.seed_name, encoding='ASCII') != ctx.seed_name_from_data:
msg = "The server is running a different multiworld than your client is. " \
"(invalid seed_name)"
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
if 'romhash' in data_decoded:
if ctx.rom_hash.upper() != data_decoded['romhash'].upper():
msg = "The rom hash does not match the client rom hash data"
print("got " + data_decoded['romhash'])
print("expected " + str(ctx.rom_hash))
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
if ctx.auth is None:
ctx.auth = ctx.player_name
if ctx.awaiting_rom:
await ctx.server_auth(False)
if 'locations' in data_decoded and ctx.game and ctx.atari_status == CONNECTION_CONNECTED_STATUS \
and not error_status and ctx.auth:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx))
if 'deathLink' in data_decoded and data_decoded['deathLink'] > 0 and 'DeathLink' in ctx.tags:
dragon_name = "a dragon"
if data_decoded['deathLink'] == 1:
dragon_name = "Rhindle"
elif data_decoded['deathLink'] == 2:
dragon_name = "Yorgle"
elif data_decoded['deathLink'] == 3:
dragon_name = "Grundle"
print (ctx.auth + " has been eaten by " + dragon_name )
await ctx.send_death(ctx.auth + " has been eaten by " + dragon_name)
# TODO - also if player reincarnates with a dragon onscreen ' dies to avoid being eaten by '
if 'victory' in data_decoded and not ctx.finished_game:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
if 'freeincarnate' in data_decoded:
await ctx.used_freeincarnate()
if ctx.set_deathlink:
await ctx.update_death_link(True)
send_checked_locations_if_needed(ctx)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.atari_streams = None
except ConnectionResetError as e:
logger.debug("Read failed due to Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.atari_streams = None
except TimeoutError:
logger.debug("Connection Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
writer.close()
ctx.atari_streams = None
except ConnectionResetError:
logger.debug("Connection Lost, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.atari_streams = None
except CancelledError:
logger.debug("Connection Cancelled, Reconnecting")
error_status = CONNECTION_RESET_STATUS
writer.close()
ctx.atari_streams = None
pass
except Exception as e:
print("unknown exception " + e)
raise
if ctx.atari_status == CONNECTION_TENTATIVE_STATUS:
if not error_status:
logger.info("Successfully Connected to 2600")
ctx.atari_status = CONNECTION_CONNECTED_STATUS
ctx.checked_locations_sent = False
send_ap_foreign_items(ctx)
send_checked_locations_if_needed(ctx)
else:
ctx.atari_status = f"Was tentatively connected but error occurred: {error_status}"
elif error_status:
ctx.atari_status = error_status
logger.info("Lost connection to 2600 and attempting to reconnect. Use /2600 for status updates")
else:
try:
port = ctx.lua_connector_port + ctx.port_offset
logger.debug(f"Attempting to connect to 2600 on port {port}")
print(f"Attempting to connect to 2600 on port {port}")
ctx.atari_streams = await asyncio.wait_for(
asyncio.open_connection("localhost",
port),
timeout=10)
ctx.atari_status = CONNECTION_TENTATIVE_STATUS
except TimeoutError:
logger.debug("Connection Timed Out, Trying Again")
ctx.atari_status = CONNECTION_TIMING_OUT_STATUS
continue
except ConnectionRefusedError:
logger.debug("Connection Refused, Trying Again")
ctx.atari_status = CONNECTION_REFUSED_STATUS
continue
except CancelledError:
pass
except CancelledError:
pass
print("exiting atari sync task")
async def run_game(romfile):
auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_options()["adventure_options"].get("rom_args")
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif os.path.isfile(auto_start):
open_args = [auto_start, romfile]
if rom_args is not None:
open_args.insert(1, rom_args)
subprocess.Popen(open_args,
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def patch_and_run_game(patch_file, ctx):
base_name = os.path.splitext(patch_file)[0]
comp_path = base_name + '.a26'
try:
base_rom = AdventureDeltaPatch.get_source_data()
except Exception as msg:
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
with open(Utils.user_path("data", "adventure_basepatch.bsdiff4"), "rb") as file:
basepatch = bytes(file.read())
base_patched_rom_data = bsdiff4.patch(base_rom, basepatch)
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
if not AdventureDeltaPatch.check_version(patch_archive):
logger.error("apadvn version doesn't match this client. Make sure your generator and client are the same")
raise Exception("apadvn version doesn't match this client.")
ctx.foreign_items = AdventureDeltaPatch.read_foreign_items(patch_archive)
ctx.autocollect_items = AdventureDeltaPatch.read_autocollect_items(patch_archive)
ctx.local_item_locations = AdventureDeltaPatch.read_local_item_locations(patch_archive)
ctx.dragon_speed_info = AdventureDeltaPatch.read_dragon_speed_info(patch_archive)
ctx.seed_name_from_data, ctx.player_name = AdventureDeltaPatch.read_rom_info(patch_archive)
ctx.diff_a_mode, ctx.diff_b_mode = AdventureDeltaPatch.read_difficulty_switch_info(patch_archive)
ctx.bat_logic = AdventureDeltaPatch.read_bat_logic(patch_archive)
ctx.bat_no_touch_locations = AdventureDeltaPatch.read_bat_no_touch(patch_archive)
ctx.rom_deltas = AdventureDeltaPatch.read_rom_deltas(patch_archive)
ctx.auth = ctx.player_name
patched_rom_data = AdventureDeltaPatch.apply_rom_deltas(base_patched_rom_data, ctx.rom_deltas)
rom_hash = hashlib.sha256()
rom_hash.update(patched_rom_data)
ctx.rom_hash = rom_hash.hexdigest()
ctx.port_offset = patched_rom_data[connector_port_offset]
with open(comp_path, "wb") as patched_rom_file:
patched_rom_file.write(patched_rom_data)
async_start(run_game(comp_path))
if __name__ == '__main__':
Utils.init_logging("AdventureClient")
async def main():
parser = get_base_parser()
parser.add_argument('patch_file', default="", type=str, nargs="?",
help='Path to an ADVNTURE.BIN rom file')
parser.add_argument('port', default=17242, type=int, nargs="?",
help='port for adventure_connector connection')
args = parser.parse_args()
ctx = AdventureContext(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.atari_sync_task = asyncio.create_task(atari_sync_task(ctx), name="Adventure Sync")
if args.patch_file:
ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower()
if ext == "apadvn":
logger.info("apadvn file supplied, beginning patching process...")
async_start(patch_and_run_game(args.patch_file, ctx))
else:
logger.warning(f"Unknown patch file extension {ext}")
if args.port is int:
ctx.lua_connector_port = args.port
await ctx.exit_event.wait()
ctx.server_address = None
await ctx.shutdown()
if ctx.atari_sync_task:
await ctx.atari_sync_task
print("finished atari_sync_task (main)")
import colorama
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -1,20 +1,19 @@
from __future__ import annotations
from argparse import Namespace
import copy
from enum import unique, IntEnum, IntFlag
import logging
import json
import functools
from collections import OrderedDict, Counter, deque
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
import typing # this can go away when Python 3.8 support is dropped
import secrets
import logging
import random
import secrets
import typing # this can go away when Python 3.8 support is dropped
from argparse import Namespace
from collections import OrderedDict, Counter, deque, ChainMap
from enum import IntEnum, IntFlag
from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple
import NetUtils
import Options
import Utils
import NetUtils
class Group(TypedDict, total=False):
@@ -26,6 +25,21 @@ class Group(TypedDict, total=False):
replacement_items: Dict[int, Optional[str]]
local_items: Set[str]
non_local_items: Set[str]
link_replacement: bool
class ThreadBarrierProxy():
"""Passes through getattr while passthrough is True"""
def __init__(self, obj: Any):
self.passthrough = True
self.obj = obj
def __getattr__(self, item):
if self.passthrough:
return getattr(self.obj, item)
else:
raise RuntimeError("You are in a threaded context and global random state was removed for your safety. "
"Please use multiworld.per_slot_randoms[player] or randomize ahead of output.")
class MultiWorld():
@@ -47,6 +61,7 @@ class MultiWorld():
precollected_items: Dict[int, List[Item]]
state: CollectionState
plando_options: PlandoOptions
accessibility: Dict[int, Options.Accessibility]
early_items: Dict[int, Dict[str, int]]
local_early_items: Dict[int, Dict[str, int]]
@@ -56,9 +71,17 @@ class MultiWorld():
completion_condition: Dict[int, Callable[[CollectionState], bool]]
indirect_connections: Dict[Region, Set[Entrance]]
exclude_locations: Dict[int, Options.ExcludeLocations]
priority_locations: Dict[int, Options.PriorityLocations]
start_inventory: Dict[int, Options.StartInventory]
start_hints: Dict[int, Options.StartHints]
start_location_hints: Dict[int, Options.StartLocationHints]
item_links: Dict[int, Options.ItemLinks]
game: Dict[int, str]
random: random.Random
per_slot_randoms: Dict[int, random.Random]
class AttributeProxy():
def __init__(self, rule):
self.rule = rule
@@ -67,7 +90,8 @@ class MultiWorld():
return self.rule(player)
def __init__(self, players: int):
self.random = random.Random() # world-local random state is saved for multiple generations running concurrently
# world-local random state is saved for multiple generations running concurrently
self.random = ThreadBarrierProxy(random.Random())
self.players = players
self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids}
self.glitch_triforce = False
@@ -158,9 +182,10 @@ class MultiWorld():
set_player_attr('completion_condition', lambda state: True)
self.custom_data = {}
self.worlds = {}
self.slot_seeds = {}
self.per_slot_randoms = {}
self.plando_options = PlandoOptions.none
def get_all_ids(self):
def get_all_ids(self) -> Tuple[int, ...]:
return self.player_ids + tuple(self.groups)
def add_group(self, name: str, game: str, players: Set[int] = frozenset()) -> Tuple[int, Group]:
@@ -203,8 +228,8 @@ class MultiWorld():
else:
self.random.seed(self.seed)
self.seed_name = name if name else str(self.seed)
self.slot_seeds = {player: random.Random(self.random.getrandbits(64)) for player in
range(1, self.players + 1)}
self.per_slot_randoms = {player: random.Random(self.random.getrandbits(64)) for player in
range(1, self.players + 1)}
def set_options(self, args: Namespace) -> None:
for option_key in Options.common_options:
@@ -222,27 +247,32 @@ class MultiWorld():
def set_item_links(self):
item_links = {}
replacement_prio = [False, True, None]
for player in self.player_ids:
for item_link in self.item_links[player].value:
if item_link["name"] in item_links:
if item_links[item_link["name"]]["game"] != self.game[player]:
raise Exception(f"Cannot ItemLink across games. Link: {item_link['name']}")
item_links[item_link["name"]]["players"][player] = item_link["replacement_item"]
item_links[item_link["name"]]["item_pool"] &= set(item_link["item_pool"])
item_links[item_link["name"]]["exclude"] |= set(item_link.get("exclude", []))
item_links[item_link["name"]]["local_items"] &= set(item_link.get("local_items", []))
item_links[item_link["name"]]["non_local_items"] &= set(item_link.get("non_local_items", []))
current_link = item_links[item_link["name"]]
current_link["players"][player] = item_link["replacement_item"]
current_link["item_pool"] &= set(item_link["item_pool"])
current_link["exclude"] |= set(item_link.get("exclude", []))
current_link["local_items"] &= set(item_link.get("local_items", []))
current_link["non_local_items"] &= set(item_link.get("non_local_items", []))
current_link["link_replacement"] = min(current_link["link_replacement"],
replacement_prio.index(item_link["link_replacement"]))
else:
if item_link["name"] in self.player_name.values():
raise Exception(f"Cannot name a ItemLink group the same as a player ({item_link['name']}) ({self.get_player_name(player)}).")
raise Exception(f"Cannot name a ItemLink group the same as a player ({item_link['name']}) "
f"({self.get_player_name(player)}).")
item_links[item_link["name"]] = {
"players": {player: item_link["replacement_item"]},
"item_pool": set(item_link["item_pool"]),
"exclude": set(item_link.get("exclude", [])),
"game": self.game[player],
"local_items": set(item_link.get("local_items", [])),
"non_local_items": set(item_link.get("non_local_items", []))
"non_local_items": set(item_link.get("non_local_items", [])),
"link_replacement": replacement_prio.index(item_link["link_replacement"]),
}
for name, item_link in item_links.items():
@@ -267,10 +297,12 @@ class MultiWorld():
for group_name, item_link in item_links.items():
game = item_link["game"]
group_id, group = self.add_group(group_name, game, set(item_link["players"]))
group["item_pool"] = item_link["item_pool"]
group["replacement_items"] = item_link["players"]
group["local_items"] = item_link["local_items"]
group["non_local_items"] = item_link["non_local_items"]
group["link_replacement"] = replacement_prio[item_link["link_replacement"]]
# intended for unittests
def set_default_common_options(self):
@@ -281,15 +313,15 @@ class MultiWorld():
self.state = CollectionState(self)
def secure(self):
self.random = secrets.SystemRandom()
self.random = ThreadBarrierProxy(secrets.SystemRandom())
self.is_race = True
@functools.cached_property
def player_ids(self):
def player_ids(self) -> Tuple[int, ...]:
return tuple(range(1, self.players + 1))
@functools.lru_cache()
def get_game_players(self, game_name: str):
def get_game_players(self, game_name: str) -> Tuple[int, ...]:
return tuple(player for player in self.player_ids if self.game[player] == game_name)
@functools.lru_cache()
@@ -304,14 +336,11 @@ class MultiWorld():
return self.player_name[player]
def get_file_safe_player_name(self, player: int) -> str:
return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*')
return Utils.get_file_safe_name(self.get_player_name(player))
def get_out_file_name_base(self, player: int) -> str:
""" the base name (without file extension) for each player's output file for a seed """
return f"AP_{self.seed_name}_P{player}" \
+ (f"_{self.get_file_safe_player_name(player).replace(' ', '_')}"
if (self.player_name[player] != f"Player{player}")
else '')
return f"AP_{self.seed_name}_P{player}_{self.get_file_safe_player_name(player).replace(' ', '_')}"
def initialize_regions(self, regions=None):
for region in regions if regions else self.regions:
@@ -386,7 +415,12 @@ class MultiWorld():
def get_items(self) -> List[Item]:
return [loc.item for loc in self.get_filled_locations()] + self.itempool
def find_item_locations(self, item, player: int) -> List[Location]:
def find_item_locations(self, item, player: int, resolve_group_locations: bool = False) -> List[Location]:
if resolve_group_locations:
player_groups = self.get_player_groups(player)
return [location for location in self.get_locations() if
location.item and location.item.name == item and location.player not in player_groups and
(location.item.player == player or location.item.player in player_groups)]
return [location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player]
@@ -394,7 +428,12 @@ class MultiWorld():
return next(location for location in self.get_locations() if
location.item and location.item.name == item and location.item.player == player)
def find_items_in_locations(self, items: Set[str], player: int) -> List[Location]:
def find_items_in_locations(self, items: Set[str], player: int, resolve_group_locations: bool = False) -> List[Location]:
if resolve_group_locations:
player_groups = self.get_player_groups(player)
return [location for location in self.get_locations() if
location.item and location.item.name in items and location.player not in player_groups and
(location.item.player == player or location.item.player in player_groups)]
return [location for location in self.get_locations() if
location.item and location.item.name in items and location.item.player == player]
@@ -427,46 +466,35 @@ class MultiWorld():
state.can_reach(Region) in the Entrance's traversal condition, as opposed to pure transition logic."""
self.indirect_connections.setdefault(region, set()).add(entrance)
def get_locations(self) -> List[Location]:
def get_locations(self, player: Optional[int] = None) -> List[Location]:
if self._cached_locations is None:
self._cached_locations = [location for region in self.regions for location in region.locations]
if player is not None:
return [location for location in self._cached_locations if location.player == player]
return self._cached_locations
def clear_location_cache(self):
self._cached_locations = None
def get_unfilled_locations(self, player: Optional[int] = None) -> List[Location]:
if player is not None:
return [location for location in self.get_locations() if
location.player == player and not location.item]
return [location for location in self.get_locations() if not location.item]
def get_unfilled_dungeon_locations(self):
return [location for location in self.get_locations() if not location.item and location.parent_region.dungeon]
return [location for location in self.get_locations(player) if location.item is None]
def get_filled_locations(self, player: Optional[int] = None) -> List[Location]:
if player is not None:
return [location for location in self.get_locations() if
location.player == player and location.item is not None]
return [location for location in self.get_locations() if location.item is not None]
return [location for location in self.get_locations(player) if location.item is not None]
def get_reachable_locations(self, state: Optional[CollectionState] = None, player: Optional[int] = None) -> List[Location]:
if state is None:
state = self.state
return [location for location in self.get_locations() if
(player is None or location.player == player) and location.can_reach(state)]
state: CollectionState = state if state else self.state
return [location for location in self.get_locations(player) if location.can_reach(state)]
def get_placeable_locations(self, state=None, player=None) -> List[Location]:
if state is None:
state = self.state
return [location for location in self.get_locations() if
(player is None or location.player == player) and location.item is None and location.can_reach(state)]
state: CollectionState = state if state else self.state
return [location for location in self.get_locations(player) if location.item is None and location.can_reach(state)]
def get_unfilled_locations_for_players(self, locations: List[str], players: Iterable[int]):
def get_unfilled_locations_for_players(self, location_names: List[str], players: Iterable[int]):
for player in players:
if len(locations) == 0:
locations = [location.name for location in self.get_unfilled_locations(player)]
for location_name in locations:
if not location_names:
location_names = [location.name for location in self.get_unfilled_locations(player)]
for location_name in location_names:
location = self._location_cache.get((location_name, player), None)
if location is not None and location.item is None:
yield location
@@ -736,169 +764,9 @@ class CollectionState():
found += self.prog_items[item_name, player]
return found
def can_buy_unlimited(self, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for
shop in self.multiworld.shops)
def can_buy(self, item: str, player: int) -> bool:
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for
shop in self.multiworld.shops)
def item_count(self, item: str, player: int) -> int:
return self.prog_items[item, player]
def has_triforce_pieces(self, count: int, player: int) -> bool:
return self.item_count('Triforce Piece', player) + self.item_count('Power Star', player) >= count
def has_crystals(self, count: int, player: int) -> bool:
found: int = 0
for crystalnumber in range(1, 8):
found += self.prog_items[f"Crystal {crystalnumber}", player]
if found >= count:
return True
return False
def can_lift_rocks(self, player: int):
return self.has('Power Glove', player) or self.has('Titans Mitts', player)
def bottle_count(self, player: int) -> int:
return min(self.multiworld.difficulty_requirements[player].progressive_bottle_limit,
self.count_group("Bottles", player))
def has_hearts(self, player: int, count: int) -> int:
# Warning: This only considers items that are marked as advancement items
return self.heart_count(player) >= count
def heart_count(self, player: int) -> int:
# Warning: This only considers items that are marked as advancement items
diff = self.multiworld.difficulty_requirements[player]
return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \
+ self.item_count('Sanctuary Heart Container', player) \
+ min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \
+ 3 # starting hearts
def can_lift_heavy_rocks(self, player: int) -> bool:
return self.has('Titans Mitts', player)
def can_extend_magic(self, player: int, smallmagic: int = 16,
fullrefill: bool = False): # This reflects the total magic Link has, not the total extra he has.
basemagic = 8
if self.has('Magic Upgrade (1/4)', player):
basemagic = 32
elif self.has('Magic Upgrade (1/2)', player):
basemagic = 16
if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player):
if self.multiworld.item_functionality[player] == 'hard' and not fullrefill:
basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player))
elif self.multiworld.item_functionality[player] == 'expert' and not fullrefill:
basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player))
else:
basemagic = basemagic + basemagic * self.bottle_count(player)
return basemagic >= smallmagic
def can_kill_most_things(self, player: int, enemies: int = 5) -> bool:
return (self.has_melee_weapon(player)
or self.has('Cane of Somaria', player)
or (self.has('Cane of Byrna', player) and (enemies < 6 or self.can_extend_magic(player)))
or self.can_shoot_arrows(player)
or self.has('Fire Rod', player)
or (self.has('Bombs (10)', player) and enemies < 6))
def can_shoot_arrows(self, player: int) -> bool:
if self.multiworld.retro_bow[player]:
return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player)
return self.has('Bow', player) or self.has('Silver Bow', player)
def can_get_good_bee(self, player: int) -> bool:
cave = self.multiworld.get_region('Good Bee Cave', player)
return (
self.has_group("Bottles", player) and
self.has('Bug Catching Net', player) and
(self.has('Pegasus Boots', player) or (self.has_sword(player) and self.has('Quake', player))) and
cave.can_reach(self) and
self.is_not_bunny(cave, player)
)
def can_retrieve_tablet(self, player: int) -> bool:
return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or
(self.multiworld.swordless[player] and
self.has("Hammer", player)))
def has_sword(self, player: int) -> bool:
return self.has('Fighter Sword', player) \
or self.has('Master Sword', player) \
or self.has('Tempered Sword', player) \
or self.has('Golden Sword', player)
def has_beam_sword(self, player: int) -> bool:
return self.has('Master Sword', player) or self.has('Tempered Sword', player) or self.has('Golden Sword',
player)
def has_melee_weapon(self, player: int) -> bool:
return self.has_sword(player) or self.has('Hammer', player)
def has_fire_source(self, player: int) -> bool:
return self.has('Fire Rod', player) or self.has('Lamp', player)
def can_melt_things(self, player: int) -> bool:
return self.has('Fire Rod', player) or \
(self.has('Bombos', player) and
(self.multiworld.swordless[player] or
self.has_sword(player)))
def can_avoid_lasers(self, player: int) -> bool:
return self.has('Mirror Shield', player) or self.has('Cane of Byrna', player) or self.has('Cape', player)
def is_not_bunny(self, region: Region, player: int) -> bool:
if self.has('Moon Pearl', player):
return True
return region.is_light_world if self.multiworld.mode[player] != 'inverted' else region.is_dark_world
def can_reach_light_world(self, player: int) -> bool:
if True in [i.is_light_world for i in self.reachable_regions[player]]:
return True
return False
def can_reach_dark_world(self, player: int) -> bool:
if True in [i.is_dark_world for i in self.reachable_regions[player]]:
return True
return False
def has_misery_mire_medallion(self, player: int) -> bool:
return self.has(self.multiworld.required_medallions[player][0], player)
def has_turtle_rock_medallion(self, player: int) -> bool:
return self.has(self.multiworld.required_medallions[player][1], player)
def can_boots_clip_lw(self, player: int) -> bool:
if self.multiworld.mode[player] == 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player)
def can_boots_clip_dw(self, player: int) -> bool:
if self.multiworld.mode[player] != 'inverted':
return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player)
return self.has('Pegasus Boots', player)
def can_get_glitched_speed_lw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.multiworld.mode[player] == 'inverted':
rules.append(self.has('Moon Pearl', player))
return all(rules)
def can_superbunny_mirror_with_sword(self, player: int) -> bool:
return self.has('Magic Mirror', player) and self.has_sword(player)
def can_get_glitched_speed_dw(self, player: int) -> bool:
rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])]
if self.multiworld.mode[player] != 'inverted':
rules.append(self.has('Moon Pearl', player))
return all(rules)
def can_bomb_clip(self, region: Region, player: int) -> bool:
return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player)
def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool:
if location:
self.locations_checked.add(location)
@@ -925,45 +793,23 @@ class CollectionState():
self.stale[item.player] = True
@unique
class RegionType(IntEnum):
Generic = 0
LightWorld = 1
DarkWorld = 2
Cave = 3 # Also includes Houses
Dungeon = 4
@property
def is_indoors(self) -> bool:
"""Shorthand for checking if Cave or Dungeon"""
return self in (RegionType.Cave, RegionType.Dungeon)
class Region:
name: str
type: RegionType
hint_text: str
_hint_text: str
player: int
multiworld: Optional[MultiWorld]
entrances: List[Entrance]
exits: List[Entrance]
locations: List[Location]
dungeon: Optional[Dungeon] = None
shop: Optional = None
# LttP specific. TODO: move to a LttPRegion
# will be set after making connections.
is_light_world: bool = False
is_dark_world: bool = False
def __init__(self, name: str, type_: RegionType, hint: str, player: int, world: Optional[MultiWorld] = None):
def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None):
self.name = name
self.type = type_
self.entrances = []
self.exits = []
self.locations = []
self.multiworld = world
self.hint_text = hint
self.multiworld = multiworld
self._hint_text = hint
self.player = player
def can_reach(self, state: CollectionState) -> bool:
@@ -979,6 +825,10 @@ class Region:
return True
return False
@property
def hint_text(self) -> str:
return self._hint_text if self._hint_text else self.name
def get_connecting_entrance(self, is_main_entrance: typing.Callable[[Entrance], bool]) -> Entrance:
for entrance in self.entrances:
if is_main_entrance(entrance):
@@ -1116,7 +966,7 @@ class Location:
self.parent_region = parent
def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool:
return (self.always_allow(state, item)
return ((self.always_allow(state, item) and item.name not in state.multiworld.non_local_items[item.player])
or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful))
and self.item_rule(item)
and (not check_access or self.can_reach(state))))
@@ -1248,13 +1098,9 @@ class Spoiler():
self.multiworld = world
self.hashes = {}
self.entrances = OrderedDict()
self.medallions = {}
self.playthrough = {}
self.unreachables = set()
self.locations = {}
self.paths = {}
self.shops = []
self.bosses = OrderedDict()
def set_entrance(self, entrance: str, exit_: str, direction: str, player: int):
if self.multiworld.players == 1:
@@ -1264,146 +1110,163 @@ class Spoiler():
self.entrances[(entrance, direction, player)] = OrderedDict(
[('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)])
def parse_data(self):
self.medallions = OrderedDict()
for player in self.multiworld.get_game_players("A Link to the Past"):
self.medallions[f'Misery Mire ({self.multiworld.get_player_name(player)})'] = \
self.multiworld.required_medallions[player][0]
self.medallions[f'Turtle Rock ({self.multiworld.get_player_name(player)})'] = \
self.multiworld.required_medallions[player][1]
def create_playthrough(self, create_paths: bool = True):
"""Destructive to the world while it is run, damage gets repaired afterwards."""
from itertools import chain
# get locations containing progress items
multiworld = self.multiworld
prog_locations = {location for location in multiworld.get_filled_locations() if location.item.advancement}
state_cache = [None]
collection_spheres: List[Set[Location]] = []
state = CollectionState(multiworld)
sphere_candidates = set(prog_locations)
logging.debug('Building up collection spheres.')
while sphere_candidates:
self.locations = OrderedDict()
listed_locations = set()
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
lw_locations = [loc for loc in self.multiworld.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.LightWorld and loc.show_in_spoiler]
self.locations['Light World'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
lw_locations])
listed_locations.update(lw_locations)
sphere = {location for location in sphere_candidates if state.can_reach(location)}
dw_locations = [loc for loc in self.multiworld.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.DarkWorld and loc.show_in_spoiler]
self.locations['Dark World'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
dw_locations])
listed_locations.update(dw_locations)
for location in sphere:
state.collect(location.item, True, location)
cave_locations = [loc for loc in self.multiworld.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.type == RegionType.Cave and loc.show_in_spoiler]
self.locations['Caves'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
cave_locations])
listed_locations.update(cave_locations)
sphere_candidates -= sphere
collection_spheres.append(sphere)
state_cache.append(state.copy())
for dungeon in self.multiworld.dungeons.values():
dungeon_locations = [loc for loc in self.multiworld.get_locations() if
loc not in listed_locations and loc.parent_region and loc.parent_region.dungeon == dungeon and loc.show_in_spoiler]
self.locations[str(dungeon)] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
dungeon_locations])
listed_locations.update(dungeon_locations)
logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres),
len(sphere),
len(prog_locations))
if not sphere:
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
location.item.name, location.item.player, location.name, location.player) for location in
sphere_candidates])
if any([multiworld.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
f'Something went terribly wrong here.')
else:
self.unreachables = sphere_candidates
break
other_locations = [loc for loc in self.multiworld.get_locations() if
loc not in listed_locations and loc.show_in_spoiler]
if other_locations:
self.locations['Other Locations'] = OrderedDict(
[(str(location), str(location.item) if location.item is not None else 'Nothing') for location in
other_locations])
listed_locations.update(other_locations)
# 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 = {}
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
to_delete = set()
for location in sphere:
# we remove the item at location and check if 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]):
to_delete.add(location)
restore_later[location] = old_item
else:
# still required, got to keep it around
location.item = old_item
self.shops = []
from worlds.alttp.Shops import ShopType, price_type_display_name, price_rate_display
for shop in self.multiworld.shops:
if not shop.custom:
continue
shopdata = {
'location': str(shop.region),
'type': 'Take Any' if shop.type == ShopType.TakeAny else 'Shop'
}
for index, item in enumerate(shop.inventory):
if item is None:
continue
my_price = item['price'] // price_rate_display.get(item['price_type'], 1)
shopdata['item_{}'.format(
index)] = f"{item['item']}{my_price} {price_type_display_name[item['price_type']]}"
# cull entries in spheres for spoiler walkthrough at end
sphere -= to_delete
if item['player'] > 0:
shopdata['item_{}'.format(index)] = shopdata['item_{}'.format(index)].replace('',
'(Player {}) — '.format(
item['player']))
if item['max'] == 0:
continue
shopdata['item_{}'.format(index)] += " x {}".format(item['max'])
if item['replacement'] is None:
continue
shopdata['item_{}'.format(
index)] += f", {item['replacement']} - {item['replacement_price']} {price_type_display_name[item['replacement_price_type']]}"
self.shops.append(shopdata)
for player in self.multiworld.get_game_players("A Link to the Past"):
self.bosses[str(player)] = OrderedDict()
self.bosses[str(player)]["Eastern Palace"] = self.multiworld.get_dungeon("Eastern Palace", player).boss.name
self.bosses[str(player)]["Desert Palace"] = self.multiworld.get_dungeon("Desert Palace", player).boss.name
self.bosses[str(player)]["Tower Of Hera"] = self.multiworld.get_dungeon("Tower of Hera", player).boss.name
self.bosses[str(player)]["Hyrule Castle"] = "Agahnim"
self.bosses[str(player)]["Palace Of Darkness"] = self.multiworld.get_dungeon("Palace of Darkness",
player).boss.name
self.bosses[str(player)]["Swamp Palace"] = self.multiworld.get_dungeon("Swamp Palace", player).boss.name
self.bosses[str(player)]["Skull Woods"] = self.multiworld.get_dungeon("Skull Woods", player).boss.name
self.bosses[str(player)]["Thieves Town"] = self.multiworld.get_dungeon("Thieves Town", player).boss.name
self.bosses[str(player)]["Ice Palace"] = self.multiworld.get_dungeon("Ice Palace", player).boss.name
self.bosses[str(player)]["Misery Mire"] = self.multiworld.get_dungeon("Misery Mire", player).boss.name
self.bosses[str(player)]["Turtle Rock"] = self.multiworld.get_dungeon("Turtle Rock", player).boss.name
if self.multiworld.mode[player] != 'inverted':
self.bosses[str(player)]["Ganons Tower Basement"] = \
self.multiworld.get_dungeon('Ganons Tower', player).bosses['bottom'].name
self.bosses[str(player)]["Ganons Tower Middle"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
'middle'].name
self.bosses[str(player)]["Ganons Tower Top"] = self.multiworld.get_dungeon('Ganons Tower', player).bosses[
'top'].name
# second phase, sphere 0
removed_precollected = []
for item in (i for i in chain.from_iterable(multiworld.precollected_items.values()) if i.advancement):
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
multiworld.precollected_items[item.player].remove(item)
multiworld.state.remove(item)
if not multiworld.can_beat_game():
multiworld.push_precollected(item)
else:
self.bosses[str(player)]["Ganons Tower Basement"] = \
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['bottom'].name
self.bosses[str(player)]["Ganons Tower Middle"] = \
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['middle'].name
self.bosses[str(player)]["Ganons Tower Top"] = \
self.multiworld.get_dungeon('Inverted Ganons Tower', player).bosses['top'].name
removed_precollected.append(item)
self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2"
self.bosses[str(player)]["Ganon"] = "Ganon"
# we are now down to just the required progress items in collection_spheres. Unfortunately
# the previous pruning stage could potentially have made certain items dependant on others
# in the same or later sphere (because the location had 2 ways to access but the item originally
# used to access it was deemed not required.) So we need to do one final sphere collection pass
# to build up the correct spheres
def to_json(self):
self.parse_data()
out = OrderedDict()
out['Entrances'] = list(self.entrances.values())
out.update(self.locations)
out['Special'] = self.medallions
if self.hashes:
out['Hashes'] = self.hashes
if self.shops:
out['Shops'] = self.shops
out['playthrough'] = self.playthrough
out['paths'] = self.paths
out['Bosses'] = self.bosses
required_locations = {item for sphere in collection_spheres for item in sphere}
state = CollectionState(multiworld)
collection_spheres = []
while required_locations:
state.sweep_for_events(key_only=True)
return json.dumps(out)
sphere = set(filter(state.can_reach, required_locations))
for location in sphere:
state.collect(location.item, True, location)
required_locations -= sphere
collection_spheres.append(sphere)
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
len(sphere), len(required_locations))
if not sphere:
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
# we can finally output our playthrough
self.playthrough = {"0": sorted([self.multiworld.get_name_string_for_object(item) for item in
chain.from_iterable(multiworld.precollected_items.values())
if item.advancement])}
for i, sphere in enumerate(collection_spheres):
self.playthrough[str(i + 1)] = {
str(location): str(location.item) for location in sorted(sphere)}
if create_paths:
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)
def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]):
from itertools import zip_longest
multiworld = self.multiworld
def flist_to_iter(node):
while node:
value, node = node
yield value
def get_path(state, region):
reversed_path_as_flist = state.path.get(region, (region, None))
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
# Now we combine the flat string list into (region, exit) pairs
pathsiter = iter(string_path_flat)
pathpairs = zip_longest(pathsiter, pathsiter)
return list(pathpairs)
self.paths = {}
topology_worlds = (player for player in multiworld.player_ids if multiworld.worlds[player].topology_present)
for player in topology_worlds:
self.paths.update(
{str(location): get_path(state, location.parent_region)
for sphere in collection_spheres for location in sphere
if location.player == player})
if player in multiworld.get_game_players("A Link to the Past"):
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
# Maybe move the big bomb over to the Event system instead?
if any(exit_path == 'Pyramid Fairy' for path in self.paths.values()
for (_, exit_path) in path):
if multiworld.mode[player] != 'inverted':
self.paths[str(multiworld.get_region('Big Bomb Shop', player))] = \
get_path(state, multiworld.get_region('Big Bomb Shop', player))
else:
self.paths[str(multiworld.get_region('Inverted Big Bomb Shop', player))] = \
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
def to_file(self, filename: str):
self.parse_data()
def bool_to_text(variable: Union[bool, str]) -> str:
if type(variable) == str:
return variable
return 'Yes' if variable else 'No'
def write_option(option_key: str, option_obj: type(Options.Option)):
res = getattr(self.multiworld, option_key)[player]
display_name = getattr(option_obj, "display_name", option_key)
try:
outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n')
outfile.write(f'{display_name + ":":33}{res.current_option_name}\n')
except:
raise Exception
@@ -1413,52 +1276,20 @@ class Spoiler():
Utils.__version__, self.multiworld.seed))
outfile.write('Filling Algorithm: %s\n' % self.multiworld.algorithm)
outfile.write('Players: %d\n' % self.multiworld.players)
outfile.write(f'Plando Options: {self.multiworld.plando_options}\n')
AutoWorld.call_stage(self.multiworld, "write_spoiler_header", outfile)
for player in range(1, self.multiworld.players + 1):
if self.multiworld.players > 1:
outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player)))
outfile.write('Game: %s\n' % self.multiworld.game[player])
for f_option, option in Options.per_game_common_options.items():
options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions)
for f_option, option in options.items():
write_option(f_option, option)
options = self.multiworld.worlds[player].option_definitions
if options:
for f_option, option in options.items():
write_option(f_option, option)
AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile)
if player in self.multiworld.get_game_players("A Link to the Past"):
outfile.write('%s%s\n' % ('Hash: ', self.hashes[player]))
outfile.write('Logic: %s\n' % self.multiworld.logic[player])
outfile.write('Dark Room Logic: %s\n' % self.multiworld.dark_room_logic[player])
outfile.write('Mode: %s\n' % self.multiworld.mode[player])
outfile.write('Goal: %s\n' % self.multiworld.goal[player])
if "triforce" in self.multiworld.goal[player]: # triforce hunt
outfile.write("Pieces available for Triforce: %s\n" %
self.multiworld.triforce_pieces_available[player])
outfile.write("Pieces required for Triforce: %s\n" %
self.multiworld.triforce_pieces_required[player])
outfile.write('Difficulty: %s\n' % self.multiworld.difficulty[player])
outfile.write('Item Functionality: %s\n' % self.multiworld.item_functionality[player])
outfile.write('Entrance Shuffle: %s\n' % self.multiworld.shuffle[player])
if self.multiworld.shuffle[player] != "vanilla":
outfile.write('Entrance Shuffle Seed %s\n' % self.multiworld.worlds[player].er_seed)
outfile.write('Shop inventory shuffle: %s\n' %
bool_to_text("i" in self.multiworld.shop_shuffle[player]))
outfile.write('Shop price shuffle: %s\n' %
bool_to_text("p" in self.multiworld.shop_shuffle[player]))
outfile.write('Shop upgrade shuffle: %s\n' %
bool_to_text("u" in self.multiworld.shop_shuffle[player]))
outfile.write('New Shop inventory: %s\n' %
bool_to_text("g" in self.multiworld.shop_shuffle[player] or
"f" in self.multiworld.shop_shuffle[player]))
outfile.write('Custom Potion Shop: %s\n' %
bool_to_text("w" in self.multiworld.shop_shuffle[player]))
outfile.write('Enemy health: %s\n' % self.multiworld.enemy_health[player])
outfile.write('Enemy damage: %s\n' % self.multiworld.enemy_damage[player])
outfile.write('Prize shuffle %s\n' %
self.multiworld.shuffle_prizes[player])
if self.entrances:
outfile.write('\n\nEntrances:\n\n')
outfile.write('\n'.join(['%s%s %s %s' % (f'{self.multiworld.get_player_name(entry["player"])}: '
@@ -1467,30 +1298,14 @@ class Spoiler():
'<=' if entry['direction'] == 'exit' else '=>',
entry['exit']) for entry in self.entrances.values()]))
if self.medallions:
outfile.write('\n\nMedallions:\n')
for dungeon, medallion in self.medallions.items():
outfile.write(f'\n{dungeon}: {medallion}')
AutoWorld.call_all(self.multiworld, "write_spoiler", outfile)
locations = [(str(location), str(location.item) if location.item is not None else "Nothing")
for location in self.multiworld.get_locations() if location.show_in_spoiler]
outfile.write('\n\nLocations:\n\n')
outfile.write('\n'.join(
['%s: %s' % (location, item) for grouping in self.locations.values() for (location, item) in
grouping.items()]))
['%s: %s' % (location, item) for location, item in locations]))
if self.shops:
outfile.write('\n\nShops:\n\n')
outfile.write('\n'.join("{} [{}]\n {}".format(shop['location'], shop['type'], "\n ".join(
item for item in [shop.get('item_0', None), shop.get('item_1', None), shop.get('item_2', None)] if
item)) for shop in self.shops))
for player in self.multiworld.get_game_players("A Link to the Past"):
if self.multiworld.boss_shuffle[player] != 'none':
bossmap = self.bosses[str(player)] if self.multiworld.players > 1 else self.bosses
outfile.write(
f'\n\nBosses{(f" ({self.multiworld.get_player_name(player)})" if self.multiworld.players > 1 else "")}:\n')
outfile.write(' ' + '\n '.join([f'{x}: {y}' for x, y in bossmap.items()]))
outfile.write('\n\nPlaythrough:\n\n')
outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join(
[' %s: %s' % (location, item) for (location, item) in sphere.items()] if sphere_nr != '0' else [
@@ -1529,6 +1344,45 @@ class Tutorial(NamedTuple):
authors: List[str]
class PlandoOptions(IntFlag):
none = 0b0000
items = 0b0001
connections = 0b0010
texts = 0b0100
bosses = 0b1000
@classmethod
def from_option_string(cls, option_string: str) -> PlandoOptions:
result = cls(0)
for part in option_string.split(","):
part = part.strip().lower()
if part:
result = cls._handle_part(part, result)
return result
@classmethod
def from_set(cls, option_set: Set[str]) -> PlandoOptions:
result = cls(0)
for part in option_set:
result = cls._handle_part(part, result)
return result
@classmethod
def _handle_part(cls, part: str, base: PlandoOptions) -> PlandoOptions:
try:
part = cls[part]
except Exception as e:
raise KeyError(f"{part} is not a recognized name for a plando module. "
f"Known options: {', '.join(flag.name for flag in cls)}") from e
else:
return base | part
def __str__(self) -> str:
if self.value:
return ", ".join(flag.name for flag in PlandoOptions if self.value & flag.value)
return "None"
seeddigits = 20

View File

@@ -63,7 +63,7 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_received(self) -> bool:
"""List all received items"""
logger.info(f'{len(self.ctx.items_received)} received items:')
self.output(f'{len(self.ctx.items_received)} received items:')
for index, item in enumerate(self.ctx.items_received, 1):
self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}")
return True
@@ -134,8 +134,9 @@ class CommonContext:
tags: typing.Set[str] = {"AP"}
game: typing.Optional[str] = None
items_handling: typing.Optional[int] = None
want_slot_data: bool = True # should slot_data be retrieved via Connect
# datapackage
# data package
# Contents in flux until connection to server is made, to download correct data for this multiworld.
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
@@ -192,7 +193,7 @@ class CommonContext:
self.hint_cost = None
self.slot_info = {}
self.permissions = {
"forfeit": "disabled",
"release": "disabled",
"collect": "disabled",
"remaining": "disabled",
}
@@ -222,7 +223,7 @@ class CommonContext:
self.watcher_event = asyncio.Event()
self.jsontotextparser = JSONtoTextParser(self)
self.update_datapackage(network_data_package)
self.update_data_package(network_data_package)
# execution
self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy")
@@ -259,7 +260,7 @@ class CommonContext:
self.server_task = None
self.hint_cost = None
self.permissions = {
"forfeit": "disabled",
"release": "disabled",
"collect": "disabled",
"remaining": "disabled",
}
@@ -309,7 +310,7 @@ class CommonContext:
'cmd': 'Connect',
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
'tags': self.tags, 'items_handling': self.items_handling,
'uuid': Utils.get_unique_identifier(), 'game': self.game
'uuid': Utils.get_unique_identifier(), 'game': self.game, "slot_data": self.want_slot_data,
}
if kwargs:
payload.update(kwargs)
@@ -340,6 +341,11 @@ class CommonContext:
return self.slot in self.slot_info[slot].group_members
return False
def is_echoed_chat(self, print_json_packet: dict) -> bool:
return print_json_packet.get("type", "") == "Chat" \
and print_json_packet.get("team", None) == self.team \
and print_json_packet.get("slot", None) == self.slot
def is_uninteresting_item_send(self, print_json_packet: dict) -> bool:
"""Helper function for filtering out ItemSend prints that do not concern the local player."""
return print_json_packet.get("type", "") == "ItemSend" \
@@ -393,32 +399,40 @@ class CommonContext:
self.input_task.cancel()
# DataPackage
async def prepare_datapackage(self, relevant_games: typing.Set[str],
remote_datepackage_versions: typing.Dict[str, int]):
async def prepare_data_package(self, relevant_games: typing.Set[str],
remote_date_package_versions: typing.Dict[str, int],
remote_data_package_checksums: typing.Dict[str, str]):
"""Validate that all data is present for the current multiworld.
Download, assimilate and cache missing data from the server."""
# by documentation any game can use Archipelago locations/items -> always relevant
relevant_games.add("Archipelago")
cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {})
needed_updates: typing.Set[str] = set()
for game in relevant_games:
if game not in remote_datepackage_versions:
if game not in remote_date_package_versions and game not in remote_data_package_checksums:
continue
remote_version: int = remote_datepackage_versions[game]
if remote_version == 0: # custom datapackage for this game
remote_version: int = remote_date_package_versions.get(game, 0)
remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game)
if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game
needed_updates.add(game)
continue
local_version: int = network_data_package["games"].get(game, {}).get("version", 0)
local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum")
# no action required if local version is new enough
if remote_version > local_version:
cache_version: int = cache_package.get(game, {}).get("version", 0)
if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \
or remote_checksum != local_checksum:
cached_game = Utils.load_data_package_for_checksum(game, remote_checksum)
cache_version: int = cached_game.get("version", 0)
cache_checksum: typing.Optional[str] = cached_game.get("checksum")
# download remote version if cache is not new enough
if remote_version > cache_version:
if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \
or remote_checksum != cache_checksum:
needed_updates.add(game)
else:
self.update_game(cache_package[game])
self.update_game(cached_game)
if needed_updates:
await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}])
@@ -428,15 +442,17 @@ class CommonContext:
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[location_id] = location_name
def update_datapackage(self, data_package: dict):
for game, gamedata in data_package["games"].items():
self.update_game(gamedata)
def update_data_package(self, data_package: dict):
for game, game_data in data_package["games"].items():
self.update_game(game_data)
def consume_network_datapackage(self, data_package: dict):
self.update_datapackage(data_package)
def consume_network_data_package(self, data_package: dict):
self.update_data_package(data_package)
current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {})
current_cache.update(data_package["games"])
Utils.persistent_store("datapackage", "games", current_cache)
for game, game_data in data_package["games"].items():
Utils.store_data_package_for_checksum(game, game_data)
# DeathLink hooks
@@ -493,7 +509,7 @@ class CommonContext:
self._messagebox.open()
return self._messagebox
def _handle_connection_loss(self, msg: str) -> None:
def handle_connection_loss(self, msg: str) -> None:
"""Helper for logging and displaying a loss of connection. Must be called from an except block."""
exc_info = sys.exc_info()
logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True})
@@ -579,14 +595,22 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None)
for msg in decode(data):
await process_server_cmd(ctx, msg)
logger.warning(f"Disconnected from multiworld server{reconnect_hint()}")
except websockets.InvalidMessage:
# probably encrypted
if address.startswith("ws://"):
await server_loop(ctx, "ws" + address[1:])
else:
ctx.handle_connection_loss(f"Lost connection to the multiworld server due to InvalidMessage"
f"{reconnect_hint()}")
except ConnectionRefusedError:
ctx._handle_connection_loss("Connection refused by the server. May not be running Archipelago on that address or port.")
ctx.handle_connection_loss("Connection refused by the server. "
"May not be running Archipelago on that address or port.")
except websockets.InvalidURI:
ctx._handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
ctx.handle_connection_loss("Failed to connect to the multiworld server (invalid URI)")
except OSError:
ctx._handle_connection_loss("Failed to connect to the multiworld server")
ctx.handle_connection_loss("Failed to connect to the multiworld server")
except Exception:
ctx._handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
ctx.handle_connection_loss(f"Lost connection to the multiworld server{reconnect_hint()}")
finally:
await ctx.connection_closed()
if ctx.server_address and ctx.username and not ctx.disconnected_intentionally:
@@ -647,14 +671,16 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
current_team = network_player.team
logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot))
# update datapackage
await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"])
# update data package
data_package_versions = args.get("datapackage_versions", {})
data_package_checksums = args.get("datapackage_checksums", {})
await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums)
await ctx.server_auth(args['password'])
elif cmd == 'DataPackage':
logger.info("Got new ID/Name DataPackage")
ctx.consume_network_datapackage(args['data'])
ctx.consume_network_data_package(args['data'])
elif cmd == 'ConnectionRefused':
errors = args["errors"]
@@ -798,9 +824,10 @@ if __name__ == '__main__':
# Text Mode to use !hint and such with games that have no text entry
class TextContext(CommonContext):
tags = {"AP", "IgnoreGame", "TextOnly"}
tags = {"AP", "TextOnly"}
game = "" # empty matches any game since 0.3.2
items_handling = 0b111 # receive all items for /received
want_slot_data = False # Can't use game specific slot_data
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -811,6 +838,10 @@ if __name__ == '__main__':
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
async def disconnect(self, allow_autoreconnect: bool = False):
self.game = ""
await super().disconnect(allow_autoreconnect)
async def main(args):

View File

@@ -109,9 +109,10 @@ class FactorioContext(CommonContext):
def on_print_json(self, args: dict):
if self.rcon_client:
if not self.filter_item_sends or not self.is_uninteresting_item_send(args):
if (not self.filter_item_sends or not self.is_uninteresting_item_send(args)) \
and not self.is_echoed_chat(args):
text = self.factorio_json_text_parser(copy.deepcopy(args["data"]))
if not text.startswith(self.player_names[self.slot] + ":"):
if not text.startswith(self.player_names[self.slot] + ":"): # TODO: Remove string heuristic in the future.
self.print_to_game(text)
super(FactorioContext, self).on_print_json(args)

90
Fill.py
View File

@@ -23,14 +23,27 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item]
def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location],
itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None) -> None:
item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False,
swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None,
allow_partial: bool = False, allow_excluded: bool = False) -> None:
"""
:param world: Multiworld to be filled.
:param base_state: State assumed before fill.
:param locations: Locations to be filled with item_pool
:param item_pool: Items to fill into the locations
:param single_player_placement: if true, can speed up placement if everything belongs to a single player
:param lock: locations are set to locked as they are filled
:param swap: if true, swaps of already place items are done in the event of a dead end
:param on_place: callback that is called when a placement happens
:param allow_partial: only place what is possible. Remaining items will be in the item_pool list.
:param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations
"""
unplaced_items: typing.List[Item] = []
placements: typing.List[Location] = []
swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter()
reachable_items: typing.Dict[int, typing.Deque[Item]] = {}
for item in itempool:
for item in item_pool:
reachable_items.setdefault(item.player, deque()).append(item)
while any(reachable_items.values()) and locations:
@@ -38,9 +51,9 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
items_to_place = [items.pop()
for items in reachable_items.values() if items]
for item in items_to_place:
itempool.remove(item)
item_pool.remove(item)
maximum_exploration_state = sweep_from_pool(
base_state, itempool + unplaced_items)
base_state, item_pool + unplaced_items)
has_beaten_game = world.has_beaten_game(maximum_exploration_state)
@@ -110,7 +123,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
reachable_items[placed_item.player].appendleft(
placed_item)
itempool.append(placed_item)
item_pool.append(placed_item)
break
@@ -132,7 +145,22 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
if on_place:
on_place(spot_to_fill)
if len(unplaced_items) > 0 and len(locations) > 0:
if allow_excluded:
# check if partial fill is the result of excluded locations, in which case retry
excluded_locations = [
location for location in locations
if location.progress_type == location.progress_type.EXCLUDED and not location.item
]
if excluded_locations:
for location in excluded_locations:
location.progress_type = location.progress_type.DEFAULT
fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock,
swap, on_place, allow_partial, False)
for location in excluded_locations:
if not location.item:
location.progress_type = location.progress_type.EXCLUDED
if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0:
# There are leftover unplaceable items and locations that won't accept them
if world.can_beat_game():
logging.warning(
@@ -141,7 +169,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. '
f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}')
itempool.extend(unplaced_items)
item_pool.extend(unplaced_items)
def remaining_fill(world: MultiWorld,
@@ -252,11 +280,12 @@ def distribute_early_items(world: MultiWorld,
fill_locations: typing.List[Location],
itempool: typing.List[Item]) -> typing.Tuple[typing.List[Location], typing.List[Item]]:
""" returns new fill_locations and itempool """
early_items_count: typing.Dict[typing.Tuple[str, int], int] = {}
early_items_count: typing.Dict[typing.Tuple[str, int], typing.List[int]] = {}
for player in world.player_ids:
items = itertools.chain(world.early_items[player], world.local_early_items[player])
for item in items:
early_items_count[(item, player)] = [world.early_items[player].get(item, 0), world.local_early_items[player].get(item, 0)]
early_items_count[item, player] = [world.early_items[player].get(item, 0),
world.local_early_items[player].get(item, 0)]
if early_items_count:
early_locations: typing.List[Location] = []
early_priority_locations: typing.List[Location] = []
@@ -280,42 +309,50 @@ def distribute_early_items(world: MultiWorld,
for i, item in enumerate(itempool):
if (item.name, item.player) in early_items_count:
if item.advancement:
if early_items_count[(item.name, item.player)][1]:
if early_items_count[item.name, item.player][1]:
early_local_prog_items[item.player].append(item)
early_items_count[(item.name, item.player)][1] -= 1
early_items_count[item.name, item.player][1] -= 1
else:
early_prog_items.append(item)
early_items_count[(item.name, item.player)][0] -= 1
early_items_count[item.name, item.player][0] -= 1
else:
if early_items_count[(item.name, item.player)][1]:
if early_items_count[item.name, item.player][1]:
early_local_rest_items[item.player].append(item)
early_items_count[(item.name, item.player)][1] -= 1
early_items_count[item.name, item.player][1] -= 1
else:
early_rest_items.append(item)
early_items_count[(item.name, item.player)][0] -= 1
early_items_count[item.name, item.player][0] -= 1
item_indexes_to_remove.add(i)
if early_items_count[(item.name, item.player)] == [0, 0]:
del early_items_count[(item.name, item.player)]
if early_items_count[item.name, item.player] == [0, 0]:
del early_items_count[item.name, item.player]
if len(early_items_count) == 0:
break
itempool = [item for i, item in enumerate(itempool) if i not in item_indexes_to_remove]
for player in world.player_ids:
player_local = early_local_rest_items[player]
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
early_local_rest_items[player], lock=True)
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True)
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_rest_items.extend(early_local_rest_items[player])
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True)
fill_restrictive(world, base_state, early_locations, early_rest_items, lock=True, allow_partial=True)
early_locations += early_priority_locations
for player in world.player_ids:
player_local = early_local_prog_items[player]
fill_restrictive(world, base_state,
[loc for loc in early_locations if loc.player == player],
early_local_prog_items[player], lock=True)
[loc for loc in early_locations if loc.player == player],
player_local, lock=True, allow_partial=True)
if player_local:
logging.warning(f"Could not fulfill rules of early items: {player_local}")
early_prog_items.extend(player_local)
early_locations = [loc for loc in early_locations if not loc.item]
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True)
fill_restrictive(world, base_state, early_locations, early_prog_items, lock=True, allow_partial=True)
unplaced_early_items = early_rest_items + early_prog_items
if unplaced_early_items:
logging.warning("Ran out of early locations for early items. Failed to place "
f"{len(unplaced_early_items)} items early.")
f"{unplaced_early_items} early.")
itempool += unplaced_early_items
fill_locations.extend(early_locations)
@@ -830,8 +867,7 @@ def distribute_planned(world: MultiWorld) -> None:
maxcount = placement['count']['target']
from_pool = placement['from_pool']
candidates = list(location for location in world.get_unfilled_locations_for_players(locations,
worlds))
candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds)))
world.random.shuffle(candidates)
world.random.shuffle(items)
count = 0

View File

@@ -2,14 +2,13 @@ from __future__ import annotations
import argparse
import logging
import random
import urllib.request
import urllib.parse
from typing import Set, Dict, Tuple, Callable, Any, Union
import os
from collections import Counter, ChainMap
import random
import string
import enum
import urllib.parse
import urllib.request
from collections import Counter, ChainMap
from typing import Dict, Tuple, Callable, Any, Union
import ModuleUpdate
@@ -18,52 +17,17 @@ ModuleUpdate.update()
import Utils
from worlds.alttp import Options as LttPOptions
from worlds.generic import PlandoConnection
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, local_path, user_path
from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, user_path
from worlds.alttp.EntranceRandomizer import parse_arguments
from Main import main as ERmain
from BaseClasses import seeddigits, get_seed
from BaseClasses import seeddigits, get_seed, PlandoOptions
import Options
from worlds.alttp.Text import TextTable
from worlds.AutoWorld import AutoWorldRegister
import copy
class PlandoSettings(enum.IntFlag):
items = 0b0001
connections = 0b0010
texts = 0b0100
bosses = 0b1000
@classmethod
def from_option_string(cls, option_string: str) -> PlandoSettings:
result = cls(0)
for part in option_string.split(","):
part = part.strip().lower()
if part:
result = cls._handle_part(part, result)
return result
@classmethod
def from_set(cls, option_set: Set[str]) -> PlandoSettings:
result = cls(0)
for part in option_set:
result = cls._handle_part(part, result)
return result
@classmethod
def _handle_part(cls, part: str, base: PlandoSettings) -> PlandoSettings:
try:
part = cls[part]
except Exception as e:
raise KeyError(f"{part} is not a recognized name for a plando module. "
f"Known options: {', '.join(flag.name for flag in cls)}") from e
else:
return base | part
def __str__(self) -> str:
if self.value:
return ", ".join(flag.name for flag in PlandoSettings if self.value & flag.value)
return "Off"
def mystery_argparse():
@@ -97,7 +61,7 @@ def mystery_argparse():
args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path)
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoSettings = PlandoSettings.from_option_string(args.plando)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
return args, options
@@ -143,7 +107,7 @@ def main(args=None, callback=ERmain):
player_files = {}
for file in os.scandir(args.player_files_path):
fname = file.name
if file.is_file() and not file.name.startswith(".") and \
if file.is_file() and not fname.startswith(".") and \
os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}:
path = os.path.join(args.player_files_path, fname)
try:
@@ -170,6 +134,7 @@ def main(args=None, callback=ERmain):
f"A mix is also permitted.")
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
erargs.plando_options = args.plando
erargs.glitch_triforce = options["generator"]["glitch_triforce_room"]
erargs.spoiler = args.spoiler
erargs.race = args.race
@@ -226,7 +191,7 @@ def main(args=None, callback=ERmain):
elif not erargs.name[player]: # if name was not specified, generate it from 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
except Exception as e:
raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e
@@ -443,7 +408,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict:
return weights
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoSettings):
def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoOptions):
if option_key in game_weights:
try:
if not option.supports_weighting:
@@ -459,7 +424,7 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random"
def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses):
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
if "linked_options" in weights:
weights = roll_linked_options(weights)
@@ -472,7 +437,7 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
if tuplize_version(version) > version_tuple:
raise Exception(f"Settings reports required version of generator is at least {version}, "
f"however generator is of version {__version__}")
required_plando_options = PlandoSettings.from_option_string(requirements.get("plando", ""))
required_plando_options = PlandoOptions.from_option_string(requirements.get("plando", ""))
if required_plando_options not in plando_options:
if required_plando_options:
raise Exception(f"Settings reports required plando module {str(required_plando_options)}, "
@@ -503,14 +468,15 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
handle_option(ret, game_weights, option_key, option, plando_options)
for option_key, option in Options.per_game_common_options.items():
# skip setting this option if already set from common_options, defaulting to root option
if not (option_key in Options.common_options and option_key not in game_weights):
if option_key not in world_type.option_definitions and \
(option_key not in Options.common_options or option_key in game_weights):
handle_option(ret, game_weights, option_key, option, plando_options)
if PlandoSettings.items in plando_options:
if PlandoOptions.items in plando_options:
ret.plando_items = game_weights.get("plando_items", [])
if ret.game == "Minecraft" or ret.game == "Ocarina of Time":
# bad hardcoded behavior to make this work for now
ret.plando_connections = []
if PlandoSettings.connections in plando_options:
if PlandoOptions.connections in plando_options:
options = game_weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice("percentage", placement, 100)):
@@ -625,7 +591,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
raise Exception(f"unknown Medallion {medallion} for {'misery mire' if index == 0 else 'turtle rock'}")
ret.plando_texts = {}
if PlandoSettings.texts in plando_options:
if PlandoOptions.texts in plando_options:
tt = TextTable()
tt.removeUnwantedText()
options = weights.get("plando_texts", [])
@@ -637,7 +603,7 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options):
ret.plando_texts[at] = str(get_choice_legacy("text", placement))
ret.plando_connections = []
if PlandoSettings.connections in plando_options:
if PlandoOptions.connections in plando_options:
options = weights.get("plando_connections", [])
for placement in options:
if roll_percentage(get_choice_legacy("percentage", placement, 100)):

906
KH2Client.py Normal file
View File

@@ -0,0 +1,906 @@
import os
import asyncio
import ModuleUpdate
import json
import Utils
from pymem import pymem
from worlds.kh2.Items import exclusionItem_table, CheckDupingItems
from worlds.kh2 import all_locations, item_dictionary_table, exclusion_table
from worlds.kh2.WorldLocations import *
from worlds import network_data_package
if __name__ == "__main__":
Utils.init_logging("KH2Client", exception_logger="Client")
from NetUtils import ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop
ModuleUpdate.update()
kh2_loc_name_to_id = network_data_package["games"]["Kingdom Hearts 2"]["location_name_to_id"]
# class KH2CommandProcessor(ClientCommandProcessor):
class KH2Context(CommonContext):
# command_processor: int = KH2CommandProcessor
game = "Kingdom Hearts 2"
items_handling = 0b101 # Indicates you get items sent from other worlds.
def __init__(self, server_address, password):
super(KH2Context, self).__init__(server_address, password)
self.kh2LocalItems = None
self.ability = None
self.growthlevel = None
self.KH2_sync_task = None
self.syncing = False
self.kh2connected = False
self.serverconneced = False
self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()}
self.location_name_to_data = {name: data for name, data, in all_locations.items()}
self.lookup_id_to_item: typing.Dict[int, str] = {data.code: item_name for item_name, data in
item_dictionary_table.items() if data.code}
self.lookup_id_to_Location: typing.Dict[int, str] = {data.code: item_name for item_name, data in
all_locations.items() if data.code}
self.location_name_to_worlddata = {name: data for name, data, in all_world_locations.items()}
self.location_table = {}
self.collectible_table = {}
self.collectible_override_flags_address = 0
self.collectible_offsets = {}
self.sending = []
# flag for if the player has gotten their starting inventory from the server
self.hasStartingInvo = False
# list used to keep track of locations+items player has. Used for disoneccting
self.kh2seedsave = {"checked_locations": {"0": []},
"starting_inventory": self.hasStartingInvo,
# Character: [back of invo, front of invo]
"SoraInvo": [0x25CC, 0x2546],
"DonaldInvo": [0x2678, 0x2658],
"GoofyInvo": [0x278E, 0x276C],
"AmountInvo": {
"ServerItems": {
"Ability": {},
"Amount": {},
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, "Aerial Dodge": 0,
"Glide": 0},
"Bitmask": [],
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
"Equipment": [],
"Magic": {},
"StatIncrease": {},
"Boost": {},
},
"LocalItems": {
"Ability": {},
"Amount": {},
"Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0,
"Aerial Dodge": 0, "Glide": 0},
"Bitmask": [],
"Weapon": {"Sora": [], "Donald": [], "Goofy": []},
"Equipment": [],
"Magic": {},
"StatIncrease": {},
"Boost": {},
}},
# 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked
"worldIdChecks": {
"1": [], # world of darkness (story cutscenes)
"2": [],
"3": [], # destiny island doesn't have checks to ima put tt checks here
"4": [],
"5": [],
"6": [],
"7": [],
"8": [],
"9": [],
"10": [],
"11": [],
# atlantica isn't a supported world. if you go in atlantica it will check dc
"12": [],
"13": [],
"14": [],
"15": [],
# world map, but you only go to the world map while on the way to goa so checking hb
"16": [],
"17": [],
"18": [],
"255": [], # starting screen
},
"Levels": {
"SoraLevel": 0,
"ValorLevel": 0,
"WisdomLevel": 0,
"LimitLevel": 0,
"MasterLevel": 0,
"FinalLevel": 0,
},
"SoldEquipment": [],
"SoldBoosts": {"Power Boost": 0,
"Magic Boost": 0,
"Defense Boost": 0,
"AP Boost": 0}
}
self.slotDataProgressionNames = {}
self.kh2seedname = None
self.kh2slotdata = None
self.itemamount = {}
# sora equipped, valor equipped, master equipped, final equipped
self.keybladeAnchorList = (0x24F0, 0x32F4, 0x339C, 0x33D4)
if "localappdata" in os.environ:
self.game_communication_path = os.path.expandvars(r"%localappdata%\KH2AP")
self.amountOfPieces = 0
# hooked object
self.kh2 = None
self.ItemIsSafe = False
self.game_connected = False
self.finalxemnas = False
self.worldid = {
# 1: {}, # world of darkness (story cutscenes)
2: TT_Checks,
# 3: {}, # destiny island doesn't have checks to ima put tt checks here
4: HB_Checks,
5: BC_Checks,
6: Oc_Checks,
7: AG_Checks,
8: LoD_Checks,
9: HundredAcreChecks,
10: PL_Checks,
11: DC_Checks, # atlantica isn't a supported world. if you go in atlantica it will check dc
12: DC_Checks,
13: TR_Checks,
14: HT_Checks,
15: HB_Checks, # world map, but you only go to the world map while on the way to goa so checking hb
16: PR_Checks,
17: SP_Checks,
18: TWTNW_Checks,
# 255: {}, # starting screen
}
# 0x2A09C00+0x40 is the sve anchor. +1 is the last saved room
self.sveroom = 0x2A09C00 + 0x41
# 0 not in battle 1 in yellow battle 2 red battle #short
self.inBattle = 0x2A0EAC4 + 0x40
self.onDeath = 0xAB9078
# PC Address anchors
self.Now = 0x0714DB8
self.Save = 0x09A70B0
self.Sys3 = 0x2A59DF0
self.Bt10 = 0x2A74880
self.BtlEnd = 0x2A0D3E0
self.Slot1 = 0x2A20C98
self.chest_set = set(exclusion_table["Chests"])
self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"])
self.staff_set = set(CheckDupingItems["Weapons"]["Staffs"])
self.shield_set = set(CheckDupingItems["Weapons"]["Shields"])
self.all_weapons = self.keyblade_set.union(self.staff_set).union(self.shield_set)
self.equipment_categories = CheckDupingItems["Equipment"]
self.armor_set = set(self.equipment_categories["Armor"])
self.accessories_set = set(self.equipment_categories["Accessories"])
self.all_equipment = self.armor_set.union(self.accessories_set)
self.Equipment_Anchor_Dict = {
"Armor": [0x2504, 0x2506, 0x2508, 0x250A],
"Accessories": [0x2514, 0x2516, 0x2518, 0x251A]}
self.AbilityQuantityDict = {}
self.ability_categories = CheckDupingItems["Abilities"]
self.sora_ability_set = set(self.ability_categories["Sora"])
self.donald_ability_set = set(self.ability_categories["Donald"])
self.goofy_ability_set = set(self.ability_categories["Goofy"])
self.all_abilities = self.sora_ability_set.union(self.donald_ability_set).union(self.goofy_ability_set)
self.boost_set = set(CheckDupingItems["Boosts"])
self.stat_increase_set = set(CheckDupingItems["Stat Increases"])
self.AbilityQuantityDict = {item: self.item_name_to_data[item].quantity for item in self.all_abilities}
# Growth:[level 1,level 4,slot]
self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25CE],
"Quick Run": [0x62, 0x65, 0x25D0],
"Dodge Roll": [0x234, 0x237, 0x25D2],
"Aerial Dodge": [0x066, 0x069, 0x25D4],
"Glide": [0x6A, 0x6D, 0x25D6]}
self.boost_to_anchor_dict = {
"Power Boost": 0x24F9,
"Magic Boost": 0x24FA,
"Defense Boost": 0x24FB,
"AP Boost": 0x24F8}
self.AbilityCodeList = [self.item_name_to_data[item].code for item in exclusionItem_table["Ability"]]
self.master_growth = {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}
self.bitmask_item_code = [
0x130000, 0x130001, 0x130002, 0x130003, 0x130004, 0x130005, 0x130006, 0x130007
, 0x130008, 0x130009, 0x13000A, 0x13000B, 0x13000C
, 0x13001F, 0x130020, 0x130021, 0x130022, 0x130023
, 0x13002A, 0x13002B, 0x13002C, 0x13002D]
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(KH2Context, self).server_auth(password_requested)
await self.get_username()
await self.send_connect()
async def connection_closed(self):
self.kh2connected = False
self.serverconneced = False
if self.kh2seedname is not None and self.auth is not None:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).connection_closed()
async def disconnect(self, allow_autoreconnect: bool = False):
self.kh2connected = False
self.serverconneced = False
if self.kh2seedname not in {None} and self.auth not in {None}:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).disconnect()
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
async def shutdown(self):
if self.kh2seedname not in {None} and self.auth not in {None}:
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'w') as f:
f.write(json.dumps(self.kh2seedsave, indent=4))
await super(KH2Context, self).shutdown()
def on_package(self, cmd: str, args: dict):
if cmd in {"RoomInfo"}:
self.kh2seedname = args['seed_name']
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
if not os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'wt') as f:
pass
elif os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"):
with open(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json", 'r') as f:
self.kh2seedsave = json.load(f)
if cmd in {"Connected"}:
for player in args['players']:
if str(player.slot) not in self.kh2seedsave["checked_locations"]:
self.kh2seedsave["checked_locations"].update({str(player.slot): []})
self.kh2slotdata = args['slot_data']
self.serverconneced = True
self.kh2LocalItems = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()}
try:
self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX")
logger.info("You are now auto-tracking")
self.kh2connected = True
except Exception as e:
logger.info("Line 247")
if self.kh2connected:
logger.info("Connection Lost")
self.kh2connected = False
logger.info(e)
if cmd in {"ReceivedItems"}:
start_index = args["index"]
if start_index != len(self.items_received):
for item in args['items']:
# starting invo from server
if item.location in {-2}:
if not self.kh2seedsave["starting_inventory"]:
asyncio.create_task(self.give_item(item.item))
# if location is not already given or is !getitem
elif item.location not in self.kh2seedsave["checked_locations"][str(item.player)] \
or item.location in {-1}:
asyncio.create_task(self.give_item(item.item))
if item.location not in self.kh2seedsave["checked_locations"][str(item.player)] \
and item.location not in {-1, -2}:
self.kh2seedsave["checked_locations"][str(item.player)].append(item.location)
if not self.kh2seedsave["starting_inventory"]:
self.kh2seedsave["starting_inventory"] = True
if cmd in {"RoomUpdate"}:
if "checked_locations" in args:
new_locations = set(args["checked_locations"])
# TODO: make this take locations from other players on the same slot so proper coop happens
# items_to_give = [self.kh2slotdata["LocalItems"][str(location_id)] for location_id in new_locations if
# location_id in self.kh2LocalItems.keys()]
self.checked_locations |= new_locations
async def checkWorldLocations(self):
try:
currentworldint = int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big")
if currentworldint in self.worldid:
curworldid = self.worldid[currentworldint]
for location, data in curworldid.items():
if location not in self.locations_checked \
and (int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex) > 0:
self.locations_checked.add(location)
self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
except Exception as e:
logger.info("Line 285")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def checkLevels(self):
try:
for location, data in SoraLevels.items():
currentLevel = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), "big")
if location not in self.locations_checked \
and currentLevel >= data.bitIndex:
if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel:
self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel
self.locations_checked.add(location)
self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
formDict = {
0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels],
3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels]}
for i in range(5):
for location, data in formDict[i][1].items():
formlevel = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big")
if location not in self.locations_checked \
and formlevel >= data.bitIndex:
if formlevel > self.kh2seedsave["Levels"][formDict[i][0]]:
self.kh2seedsave["Levels"][formDict[i][0]] = formlevel
self.locations_checked.add(location)
self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
except Exception as e:
logger.info("Line 312")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def checkSlots(self):
try:
for location, data in weaponSlots.items():
if location not in self.locations_checked:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") > 0:
self.locations_checked.add(location)
self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
for location, data in formSlots.items():
if location not in self.locations_checked:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex > 0:
self.locations_checked.add(location)
self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))]
except Exception as e:
if self.kh2connected:
logger.info("Line 333")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def verifyChests(self):
try:
currentworld = str(int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big"))
for location in self.kh2seedsave["worldIdChecks"][currentworld]:
locationName = self.lookup_id_to_Location[location]
if locationName in self.chest_set:
if locationName in self.location_name_to_worlddata.keys():
locationData = self.location_name_to_worlddata[locationName]
if int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
"big") & 0x1 << locationData.bitIndex == 0:
roomData = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
1), "big")
self.kh2.write_bytes(self.kh2.base_address + self.Save + locationData.addrObtained,
(roomData | 0x01 << locationData.bitIndex).to_bytes(1, 'big'), 1)
except Exception as e:
if self.kh2connected:
logger.info("Line 350")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
async def verifyLevel(self):
for leveltype, anchor in {"SoraLevel": 0x24FF,
"ValorLevel": 0x32F6,
"WisdomLevel": 0x332E,
"LimitLevel": 0x3366,
"MasterLevel": 0x339E,
"FinalLevel": 0x33D6}.items():
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + anchor, 1), "big") < \
self.kh2seedsave["Levels"][leveltype]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + anchor,
(self.kh2seedsave["Levels"][leveltype]).to_bytes(1, 'big'), 1)
def verifyLocation(self, location):
locationData = self.location_name_to_worlddata[location]
locationName = self.lookup_id_to_Location[location]
isChecked = True
if locationName not in levels_locations:
if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
"big") & 0x1 << locationData.bitIndex) == 0:
isChecked = False
elif locationName in SoraLevels:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1),
"big") < locationData.bitIndex:
isChecked = False
elif int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1),
"big") < locationData.bitIndex:
isChecked = False
return isChecked
async def give_item(self, item, ItemType="ServerItems"):
try:
itemname = self.lookup_id_to_item[item]
itemcode = self.item_name_to_data[itemname]
if itemcode.ability:
abilityInvoType = 0
TwilightZone = 2
if ItemType == "LocalItems":
abilityInvoType = 1
TwilightZone = -2
if itemname in {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}:
self.kh2seedsave["AmountInvo"][ItemType]["Growth"][itemname] += 1
return
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Ability"]:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname] = []
# appending the slot that the ability should be in
if len(self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname]) < \
self.AbilityQuantityDict[itemname]:
if itemname in self.sora_ability_set:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["SoraInvo"][abilityInvoType])
self.kh2seedsave["SoraInvo"][abilityInvoType] -= TwilightZone
elif itemname in self.donald_ability_set:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["DonaldInvo"][abilityInvoType])
self.kh2seedsave["DonaldInvo"][abilityInvoType] -= TwilightZone
else:
self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append(
self.kh2seedsave["GoofyInvo"][abilityInvoType])
self.kh2seedsave["GoofyInvo"][abilityInvoType] -= TwilightZone
elif itemcode.code in self.bitmask_item_code:
if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"]:
self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"].append(itemname)
elif itemcode.memaddr in {0x3594, 0x3595, 0x3596, 0x3597, 0x35CF, 0x35D0}:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Magic"]:
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] = 1
elif itemname in self.all_equipment:
self.kh2seedsave["AmountInvo"][ItemType]["Equipment"].append(itemname)
elif itemname in self.all_weapons:
if itemname in self.keyblade_set:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Sora"].append(itemname)
elif itemname in self.staff_set:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Donald"].append(itemname)
else:
self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Goofy"].append(itemname)
elif itemname in self.boost_set:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Boost"]:
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] = 1
elif itemname in self.stat_increase_set:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"]:
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] = 1
else:
if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Amount"]:
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] += 1
else:
self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] = 1
except Exception as e:
if self.kh2connected:
logger.info("Line 398")
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager
class KH2Manager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago KH2 Client"
self.ui = KH2Manager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def IsInShop(self, sellable, master_boost):
# journal = 0x741230 shop = 0x741320
# if journal=-1 and shop = 5 then in shop
# if journam !=-1 and shop = 10 then journal
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
if (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
# print("your in the shop")
sellable_dict = {}
for itemName in sellable:
itemdata = self.item_name_to_data[itemName]
amount = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
sellable_dict[itemName] = amount
while (journal == -1 and shop == 5) or (journal != -1 and shop == 10):
journal = self.kh2.read_short(self.kh2.base_address + 0x741230)
shop = self.kh2.read_short(self.kh2.base_address + 0x741320)
await asyncio.sleep(0.5)
for item, amount in sellable_dict.items():
itemdata = self.item_name_to_data[item]
afterShop = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big")
if afterShop < amount:
if item in master_boost:
self.kh2seedsave["SoldBoosts"][item] += (amount - afterShop)
else:
self.kh2seedsave["SoldEquipment"].append(item)
async def verifyItems(self):
try:
local_amount = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"].keys())
server_amount = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"].keys())
master_amount = local_amount | server_amount
local_ability = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"].keys())
server_ability = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"].keys())
master_ability = local_ability | server_ability
local_bitmask = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Bitmask"])
server_bitmask = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Bitmask"])
master_bitmask = local_bitmask | server_bitmask
local_keyblade = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Sora"])
local_staff = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Donald"])
local_shield = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Goofy"])
server_keyblade = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Sora"])
server_staff = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Donald"])
server_shield = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Goofy"])
master_keyblade = local_keyblade | server_keyblade
master_staff = local_staff | server_staff
master_shield = local_shield | server_shield
local_equipment = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Equipment"])
server_equipment = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Equipment"])
master_equipment = local_equipment | server_equipment
local_magic = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"].keys())
server_magic = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"].keys())
master_magic = local_magic | server_magic
local_stat = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"].keys())
server_stat = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"].keys())
master_stat = local_stat | server_stat
local_boost = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"].keys())
server_boost = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"].keys())
master_boost = local_boost | server_boost
master_sell = master_equipment | master_staff | master_shield | master_boost
await asyncio.create_task(self.IsInShop(master_sell, master_boost))
for itemName in master_amount:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_amount:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"][itemName]
if itemName in server_amount:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"][itemName]
if itemName == "Torn Page":
# Torn Pages are handled differently because they can be consumed.
# Will check the progression in 100 acre and - the amount of visits
# amountofitems-amount of visits done
for location, data in tornPageLocks.items():
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1),
"big") & 0x1 << data.bitIndex > 0:
amountOfItems -= 1
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems and amountOfItems >= 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_keyblade:
itemData = self.item_name_to_data[itemName]
# if the inventory slot for that keyblade is less than the amount they should have
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x1CFF, 1),
"big") != 13:
# Checking form anchors for the keyblade
if self.kh2.read_short(self.kh2.base_address + self.Save + 0x24F0) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x32F4) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x339C) == itemData.kh2id \
or self.kh2.read_short(self.kh2.base_address + self.Save + 0x33D4) == itemData.kh2id:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(0).to_bytes(1, 'big'), 1)
else:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_staff:
itemData = self.item_name_to_data[itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 \
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2604) != itemData.kh2id \
and itemName not in self.kh2seedsave["SoldEquipment"]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_shield:
itemData = self.item_name_to_data[itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1 \
and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2718) != itemData.kh2id \
and itemName not in self.kh2seedsave["SoldEquipment"]:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_ability:
itemData = self.item_name_to_data[itemName]
ability_slot = []
if itemName in local_ability:
ability_slot += self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"][itemName]
if itemName in server_ability:
ability_slot += self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"][itemName]
for slot in ability_slot:
current = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
ability = current & 0x0FFF
if ability | 0x8000 != (0x8000 + itemData.memaddr):
self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr)
for itemName in self.master_growth:
growthLevel = self.kh2seedsave["AmountInvo"]["ServerItems"]["Growth"][itemName] \
+ self.kh2seedsave["AmountInvo"]["LocalItems"]["Growth"][itemName]
if growthLevel > 0:
slot = self.growth_values_dict[itemName][2]
min_growth = self.growth_values_dict[itemName][0]
max_growth = self.growth_values_dict[itemName][1]
if growthLevel > 4:
growthLevel = 4
current_growth_level = self.kh2.read_short(self.kh2.base_address + self.Save + slot)
ability = current_growth_level & 0x0FFF
# if the player should be getting a growth ability
if ability | 0x8000 != 0x8000 + min_growth - 1 + growthLevel:
# if it should be level one of that growth
if 0x8000 + min_growth - 1 + growthLevel <= 0x8000 + min_growth or ability < min_growth:
self.kh2.write_short(self.kh2.base_address + self.Save + slot, min_growth)
# if it is already in the inventory
elif ability | 0x8000 < (0x8000 + max_growth):
self.kh2.write_short(self.kh2.base_address + self.Save + slot, current_growth_level + 1)
for itemName in master_bitmask:
itemData = self.item_name_to_data[itemName]
itemMemory = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big")
if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") & 0x1 << itemData.bitmask) == 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(itemMemory | 0x01 << itemData.bitmask).to_bytes(1, 'big'), 1)
for itemName in master_equipment:
itemData = self.item_name_to_data[itemName]
isThere = False
if itemName in self.accessories_set:
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Accessories"]
else:
Equipment_Anchor_List = self.Equipment_Anchor_Dict["Armor"]
# Checking form anchors for the equipment
for slot in Equipment_Anchor_List:
if self.kh2.read_short(self.kh2.base_address + self.Save + slot) == itemData.kh2id:
isThere = True
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 0:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(0).to_bytes(1, 'big'), 1)
break
if not isThere and itemName not in self.kh2seedsave["SoldEquipment"]:
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != 1:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(1).to_bytes(1, 'big'), 1)
for itemName in master_magic:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_magic:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"][itemName]
if itemName in server_magic:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"][itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems \
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x741320, 1), "big") in {10, 8}:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_stat:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_stat:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"][itemName]
if itemName in server_stat:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"][itemName]
if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big") != amountOfItems \
and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Slot1 + 0x1B2, 1),
"big") >= 5:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
amountOfItems.to_bytes(1, 'big'), 1)
for itemName in master_boost:
itemData = self.item_name_to_data[itemName]
amountOfItems = 0
if itemName in local_boost:
amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"][itemName]
if itemName in server_boost:
amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"][itemName]
amountOfBoostsInInvo = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1),
"big")
amountOfUsedBoosts = int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + self.boost_to_anchor_dict[itemName], 1),
"big")
# Ap Boots start at +50 for some reason
if itemName == "AP Boost":
amountOfUsedBoosts -= 50
totalBoosts = (amountOfBoostsInInvo + amountOfUsedBoosts)
if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][itemName] and amountOfBoostsInInvo < 255:
self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr,
(amountOfBoostsInInvo + 1).to_bytes(1, 'big'), 1)
except Exception as e:
logger.info("Line 573")
if self.kh2connected:
logger.info("Connection Lost.")
self.kh2connected = False
logger.info(e)
def finishedGame(ctx: KH2Context, message):
if ctx.kh2slotdata['FinalXemnas'] == 1:
if 0x1301ED in message[0]["locations"]:
ctx.finalxemnas = True
# three proofs
if ctx.kh2slotdata['Goal'] == 0:
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, 1), "big") > 0 \
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, 1), "big") > 0 \
and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, 1), "big") > 0:
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
elif ctx.kh2slotdata['Goal'] == 1:
if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x3641, 1), "big") >= \
ctx.kh2slotdata['LuckyEmblemsRequired']:
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
elif ctx.kh2slotdata['Goal'] == 2:
for boss in ctx.kh2slotdata["hitlist"]:
if boss in message[0]["locations"]:
ctx.amountOfPieces += 1
if ctx.amountOfPieces >= ctx.kh2slotdata["BountyRequired"]:
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1)
ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1)
if ctx.kh2slotdata['FinalXemnas'] == 1:
if ctx.finalxemnas:
return True
else:
return False
else:
return True
else:
return False
async def kh2_watcher(ctx: KH2Context):
while not ctx.exit_event.is_set():
try:
if ctx.kh2connected and ctx.serverconneced:
ctx.sending = []
await asyncio.create_task(ctx.checkWorldLocations())
await asyncio.create_task(ctx.checkLevels())
await asyncio.create_task(ctx.checkSlots())
await asyncio.create_task(ctx.verifyChests())
await asyncio.create_task(ctx.verifyItems())
await asyncio.create_task(ctx.verifyLevel())
message = [{"cmd": 'LocationChecks', "locations": ctx.sending}]
if finishedGame(ctx, message):
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
location_ids = []
location_ids = [location for location in message[0]["locations"] if location not in location_ids]
for location in location_ids:
currentWorld = int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + 0x0714DB8, 1), "big")
if location not in ctx.kh2seedsave["worldIdChecks"][str(currentWorld)]:
ctx.kh2seedsave["worldIdChecks"][str(currentWorld)].append(location)
if location in ctx.kh2LocalItems:
item = ctx.kh2slotdata["LocalItems"][str(location)]
await asyncio.create_task(ctx.give_item(item, "LocalItems"))
await ctx.send_msgs(message)
elif not ctx.kh2connected and ctx.serverconneced:
logger.info("Game is not open. Disconnecting from Server.")
await ctx.disconnect()
except Exception as e:
logger.info("Line 661")
if ctx.kh2connected:
logger.info("Connection Lost.")
ctx.kh2connected = False
logger.info(e)
await asyncio.sleep(0.5)
if __name__ == '__main__':
async def main(args):
ctx = KH2Context(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
progression_watcher = asyncio.create_task(
kh2_watcher(ctx), name="KH2ProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
await progression_watcher
await ctx.shutdown()
import colorama
parser = get_base_parser(description="KH2 Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -14,10 +14,11 @@ import itertools
import shlex
import subprocess
import sys
from enum import Enum, auto
from os.path import isfile
from shutil import which
from typing import Iterable, Sequence, Callable, Union, Optional
from typing import Sequence, Union, Optional
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier
if __name__ == "__main__":
import ModuleUpdate
@@ -70,99 +71,12 @@ def browse_files():
webbrowser.open(file)
# noinspection PyArgumentList
class Type(Enum):
TOOL = auto()
FUNC = auto() # not a real component
CLIENT = auto()
ADJUSTER = auto()
class SuffixIdentifier:
suffixes: Iterable[str]
def __init__(self, *args: str):
self.suffixes = args
def __call__(self, path: str):
if isinstance(path, str):
for suffix in self.suffixes:
if path.endswith(suffix):
return True
return False
class Component:
display_name: str
type: Optional[Type]
script_name: Optional[str]
frozen_name: Optional[str]
icon: str # just the name, no suffix
cli: bool
func: Optional[Callable]
file_identifier: Optional[Callable[[str], bool]]
def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None,
cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None,
file_identifier: Optional[Callable[[str], bool]] = None):
self.display_name = display_name
self.script_name = script_name
self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None
self.icon = icon
self.cli = cli
self.type = component_type or \
None if not display_name else \
Type.FUNC if func else \
Type.CLIENT if 'Client' in display_name else \
Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL
self.func = func
self.file_identifier = file_identifier
def handles_file(self, path: str):
return self.file_identifier(path) if self.file_identifier else False
components: Iterable[Component] = (
# Launcher
Component('', 'Launcher'),
# Core
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
Component('Generate', 'Generate', cli=True),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'),
# SNI
Component('SNI Client', 'SNIClient',
file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', '.apsmw')),
Component('LttP Adjuster', 'LttPAdjuster'),
# Factorio
Component('Factorio Client', 'FactorioClient'),
# 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'),
# Pokémon
Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')),
# ChecksFinder
Component('ChecksFinder Client', 'ChecksFinderClient'),
# Starcraft 2
Component('Starcraft 2 Client', 'Starcraft2Client'),
# Zillion
Component('Zillion Client', 'ZillionClient',
file_identifier=SuffixIdentifier('.apzl')),
components.extend([
# Functions
Component('Open host.yaml', func=open_host_yaml),
Component('Open Patch', func=open_patch),
Component('Browse Files', func=browse_files),
)
icon_paths = {
'icon': local_path('data', 'icon.ico' if is_windows else 'icon.png'),
'mcicon': local_path('data', 'mcicon.ico')
}
])
def identify(path: Union[None, str]):

609
LinksAwakeningClient.py Normal file
View File

@@ -0,0 +1,609 @@
import ModuleUpdate
ModuleUpdate.update()
import Utils
if __name__ == "__main__":
Utils.init_logging("LinksAwakeningContext", exception_logger="Client")
import asyncio
import base64
import binascii
import io
import logging
import select
import socket
import time
import typing
import urllib
import colorama
from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger,
server_loop)
from NetUtils import ClientStatus
from worlds.ladx.Common import BASE_ID as LABaseID
from worlds.ladx.GpsTracker import GpsTracker
from worlds.ladx.ItemTracker import ItemTracker
from worlds.ladx.LADXR.checkMetadata import checkMetadataTable
from worlds.ladx.Locations import get_locations_to_id, meta_to_name
from worlds.ladx.Tracker import LocationTracker, MagpieBridge
class GameboyException(Exception):
pass
class RetroArchDisconnectError(GameboyException):
pass
class InvalidEmulatorStateError(GameboyException):
pass
class BadRetroArchResponse(GameboyException):
pass
def magpie_logo():
from kivy.uix.image import CoreImage
binary_data = """
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN
SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA
7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+
MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ
wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW
eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV
ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS
XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII="""
binary_data = base64.b64decode(binary_data)
data = io.BytesIO(binary_data)
return CoreImage(data, ext="png").texture
class LAClientConstants:
# Connector version
VERSION = 0x01
#
# Memory locations of LADXR
ROMGameID = 0x0051 # 4 bytes
SlotName = 0x0134
# Unused
# ROMWorldID = 0x0055
# ROMConnectorVersion = 0x0056
# RO: We should only act if this is higher then 6, as it indicates that the game is running normally
wGameplayType = 0xDB95
# RO: Starts at 0, increases every time an item is received from the server and processed
wLinkSyncSequenceNumber = 0xDDF6
wLinkStatusBits = 0xDDF7 # RW:
# Bit0: wLinkGive* contains valid data, set from script cleared from ROM.
wLinkHealth = 0xDB5A
wLinkGiveItem = 0xDDF8 # RW
wLinkGiveItemFrom = 0xDDF9 # RW
# All of these six bytes are unused, we can repurpose
# wLinkSendItemRoomHigh = 0xDDFA # RO
# wLinkSendItemRoomLow = 0xDDFB # RO
# wLinkSendItemTarget = 0xDDFC # RO
# wLinkSendItemItem = 0xDDFD # RO
# wLinkSendShopItem = 0xDDFE # RO, which item to send (1 based, order of the shop items)
# RO, which player to send to, but it's just the X position of the NPC used, so 0x18 is player 0
# wLinkSendShopTarget = 0xDDFF
wRecvIndex = 0xDDFE # 0xDB58
wCheckAddress = 0xC0FF - 0x4
WRamCheckSize = 0x4
WRamSafetyValue = bytearray([0]*WRamCheckSize)
MinGameplayValue = 0x06
MaxGameplayValue = 0x1A
VictoryGameplayAndSub = 0x0102
class RAGameboy():
cache = []
cache_start = 0
cache_size = 0
last_cache_read = None
socket = None
def __init__(self, address, port) -> None:
self.address = address
self.port = port
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
assert (self.socket)
self.socket.setblocking(False)
def get_retroarch_version(self):
self.send(b'VERSION\n')
select.select([self.socket], [], [])
response_str, addr = self.socket.recvfrom(16)
return response_str.rstrip()
def get_retroarch_status(self, timeout):
self.send(b'GET_STATUS\n')
select.select([self.socket], [], [], timeout)
response_str, addr = self.socket.recvfrom(1000, )
return response_str.rstrip()
def set_cache_limits(self, cache_start, cache_size):
self.cache_start = cache_start
self.cache_size = cache_size
def send(self, b):
if type(b) is str:
b = b.encode('ascii')
self.socket.sendto(b, (self.address, self.port))
def recv(self):
select.select([self.socket], [], [])
response, _ = self.socket.recvfrom(4096)
return response
async def async_recv(self):
response = await asyncio.get_event_loop().sock_recv(self.socket, 4096)
return response
async def check_safe_gameplay(self, throw=True):
async def check_wram():
check_values = await self.async_read_memory(LAClientConstants.wCheckAddress, LAClientConstants.WRamCheckSize)
if check_values != LAClientConstants.WRamSafetyValue:
if throw:
raise InvalidEmulatorStateError()
return False
return True
if not await check_wram():
if throw:
raise InvalidEmulatorStateError()
return False
gameplay_value = await self.async_read_memory(LAClientConstants.wGameplayType)
gameplay_value = gameplay_value[0]
# In gameplay or credits
if not (LAClientConstants.MinGameplayValue <= gameplay_value <= LAClientConstants.MaxGameplayValue) and gameplay_value != 0x1:
if throw:
logger.info("invalid emu state")
raise InvalidEmulatorStateError()
return False
if not await check_wram():
return False
return True
# We're sadly unable to update the whole cache at once
# as RetroArch only gives back some number of bytes at a time
# So instead read as big as chunks at a time as we can manage
async def update_cache(self):
# First read the safety address - if it's invalid, bail
self.cache = []
if not await self.check_safe_gameplay():
return
cache = []
remaining_size = self.cache_size
while remaining_size:
block = await self.async_read_memory(self.cache_start + len(cache), remaining_size)
remaining_size -= len(block)
cache += block
if not await self.check_safe_gameplay():
return
self.cache = cache
self.last_cache_read = time.time()
async def read_memory_cache(self, addresses):
# TODO: can we just update once per frame?
if not self.last_cache_read or self.last_cache_read + 0.1 < time.time():
await self.update_cache()
if not self.cache:
return None
assert (len(self.cache) == self.cache_size)
for address in addresses:
assert self.cache_start <= address <= self.cache_start + self.cache_size
r = {address: self.cache[address - self.cache_start]
for address in addresses}
return r
async def async_read_memory_safe(self, address, size=1):
# whenever we do a read for a check, we need to make sure that we aren't reading
# garbage memory values - we also need to protect against reading a value, then the emulator resetting
#
# ...actually, we probably _only_ need the post check
# Check before read
if not await self.check_safe_gameplay():
return None
# Do read
r = await self.async_read_memory(address, size)
# Check after read
if not await self.check_safe_gameplay():
return None
return r
def read_memory(self, address, size=1):
command = "READ_CORE_MEMORY"
self.send(f'{command} {hex(address)} {size}\n')
response = self.recv()
splits = response.decode().split(" ", 2)
assert (splits[0] == command)
# Ignore the address for now
# TODO: transform to bytes
if splits[2][:2] == "-1" or splits[0] != "READ_CORE_MEMORY":
raise BadRetroArchResponse()
return bytearray.fromhex(splits[2])
async def async_read_memory(self, address, size=1):
command = "READ_CORE_MEMORY"
self.send(f'{command} {hex(address)} {size}\n')
response = await self.async_recv()
response = response[:-1]
splits = response.decode().split(" ", 2)
assert (splits[0] == command)
# Ignore the address for now
# TODO: transform to bytes
return bytearray.fromhex(splits[2])
def write_memory(self, address, bytes):
command = "WRITE_CORE_MEMORY"
self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}')
select.select([self.socket], [], [])
response, _ = self.socket.recvfrom(4096)
splits = response.decode().split(" ", 3)
assert (splits[0] == command)
if splits[2] == "-1":
logger.info(splits[3])
class LinksAwakeningClient():
socket = None
gameboy = None
tracker = None
auth = None
game_crc = None
pending_deathlink = False
deathlink_debounce = True
recvd_checks = {}
def msg(self, m):
logger.info(m)
s = f"SHOW_MSG {m}\n"
self.gameboy.send(s)
def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355):
self.gameboy = RAGameboy(retroarch_address, retroarch_port)
async def wait_for_retroarch_connection(self):
logger.info("Waiting on connection to Retroarch...")
while True:
try:
version = self.gameboy.get_retroarch_version()
NO_CONTENT = b"GET_STATUS CONTENTLESS"
status = NO_CONTENT
core_type = None
GAME_BOY = b"game_boy"
while status == NO_CONTENT or core_type != GAME_BOY:
try:
status = self.gameboy.get_retroarch_status(0.1)
if status.count(b" ") < 2:
await asyncio.sleep(1.0)
continue
GET_STATUS, PLAYING, info = status.split(b" ", 2)
if status.count(b",") < 2:
await asyncio.sleep(1.0)
continue
core_type, rom_name, self.game_crc = info.split(b",", 2)
if core_type != GAME_BOY:
logger.info(
f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?")
await asyncio.sleep(1.0)
continue
except (BlockingIOError, TimeoutError) as e:
await asyncio.sleep(0.1)
pass
logger.info(f"Connected to Retroarch {version} {info}")
self.gameboy.read_memory(0x1000)
return
except ConnectionResetError:
await asyncio.sleep(1.0)
pass
def reset_auth(self):
auth = binascii.hexlify(self.gameboy.read_memory(0x0134, 12)).decode()
if self.auth:
assert (auth == self.auth)
self.auth = auth
async def wait_and_init_tracker(self):
await self.wait_for_game_ready()
self.tracker = LocationTracker(self.gameboy)
self.item_tracker = ItemTracker(self.gameboy)
self.gps_tracker = GpsTracker(self.gameboy)
async def recved_item_from_ap(self, item_id, from_player, next_index):
# Don't allow getting an item until you've got your first check
if not self.tracker.has_start_item():
return
# Spin until we either:
# get an exception from a bad read (emu shut down or reset)
# beat the game
# the client handles the last pending item
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
while not (await self.is_victory()) and status & 1 == 1:
time.sleep(0.1)
status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0]
item_id -= LABaseID
# The player name table only goes up to 100, so don't go past that
# Even if it didn't, the remote player _index_ byte is just a byte, so 255 max
if from_player > 100:
from_player = 100
next_index += 1
self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [
item_id, from_player])
status |= 1
status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status])
self.gameboy.write_memory(LAClientConstants.wRecvIndex, [next_index])
async def wait_for_game_ready(self):
logger.info("Waiting on game to be in valid state...")
while not await self.gameboy.check_safe_gameplay(throw=False):
pass
logger.info("Ready!")
last_index = 0
async def is_victory(self):
return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1
async def main_tick(self, item_get_cb, win_cb, deathlink_cb):
await self.tracker.readChecks(item_get_cb)
await self.item_tracker.readItems()
await self.gps_tracker.read_location()
next_index = self.gameboy.read_memory(LAClientConstants.wRecvIndex)[0]
if next_index != self.last_index:
self.last_index = next_index
# logger.info(f"Got new index {next_index}")
current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth]
if self.deathlink_debounce and current_health != 0:
self.deathlink_debounce = False
elif not self.deathlink_debounce and current_health == 0:
# logger.info("YOU DIED.")
await deathlink_cb()
self.deathlink_debounce = True
if self.pending_deathlink:
logger.info("Got a deathlink")
self.gameboy.write_memory(LAClientConstants.wLinkHealth, [0])
self.pending_deathlink = False
self.deathlink_debounce = True
if await self.is_victory():
await win_cb()
recv_index = (await self.gameboy.async_read_memory_safe(LAClientConstants.wRecvIndex))[0]
# Play back one at a time
if recv_index in self.recvd_checks:
item = self.recvd_checks[recv_index]
await self.recved_item_from_ap(item.item, item.player, recv_index)
all_tasks = set()
def create_task_log_exception(awaitable) -> asyncio.Task:
async def _log_exception(awaitable):
try:
return await awaitable
except Exception as e:
logger.exception(e)
pass
finally:
all_tasks.remove(task)
task = asyncio.create_task(_log_exception(awaitable))
all_tasks.add(task)
class LinksAwakeningContext(CommonContext):
tags = {"AP"}
game = "Links Awakening DX"
items_handling = 0b101
want_slot_data = True
la_task = None
client = None
# TODO: does this need to re-read on reset?
found_checks = []
last_resend = time.time()
magpie = MagpieBridge()
magpie_task = None
won = False
def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None:
self.client = LinksAwakeningClient()
super().__init__(server_address, password)
def run_gui(self) -> None:
import webbrowser
import kvui
from kvui import Button, GameManager
from kivy.uix.image import Image
class LADXManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("Tracker", "Tracker"),
]
base_title = "Archipelago Links Awakening DX Client"
def build(self):
b = super().build()
button = Button(text="", size=(30, 30), size_hint_x=None,
on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1'))
image = Image(size=(16, 16), texture=magpie_logo())
button.add_widget(image)
def set_center(_, center):
image.center = center
button.bind(center=set_center)
self.connect_layout.add_widget(button)
return b
self.ui = LADXManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
async def send_checks(self):
message = [{"cmd": 'LocationChecks', "locations": self.found_checks}]
await self.send_msgs(message)
ENABLE_DEATHLINK = False
async def send_deathlink(self):
if self.ENABLE_DEATHLINK:
message = [{"cmd": 'Deathlink',
'time': time.time(),
'cause': 'Had a nightmare',
# 'source': self.slot_info[self.slot].name,
}]
await self.send_msgs(message)
async def send_victory(self):
if not self.won:
message = [{"cmd": "StatusUpdate",
"status": ClientStatus.CLIENT_GOAL}]
logger.info("victory!")
await self.send_msgs(message)
self.won = True
async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None:
if self.ENABLE_DEATHLINK:
self.client.pending_deathlink = True
def new_checks(self, item_ids, ladxr_ids):
self.found_checks += item_ids
create_task_log_exception(self.send_checks())
create_task_log_exception(self.magpie.send_new_checks(ladxr_ids))
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(LinksAwakeningContext, self).server_auth(password_requested)
self.auth = self.client.auth
await self.get_username()
await self.send_connect()
def on_package(self, cmd: str, args: dict):
if cmd == "Connected":
self.game = self.slot_info[self.slot].game
# TODO - use watcher_event
if cmd == "ReceivedItems":
for index, item in enumerate(args["items"], args["index"]):
self.client.recvd_checks[index] = item
item_id_lookup = get_locations_to_id()
async def run_game_loop(self):
def on_item_get(ladxr_checks):
checks = [self.item_id_lookup[meta_to_name(
checkMetadataTable[check.id])] for check in ladxr_checks]
self.new_checks(checks, [check.id for check in ladxr_checks])
async def victory():
await self.send_victory()
async def deathlink():
await self.send_deathlink()
self.magpie_task = asyncio.create_task(self.magpie.serve())
# yield to allow UI to start
await asyncio.sleep(0)
while True:
try:
# TODO: cancel all client tasks
logger.info("(Re)Starting game loop")
self.found_checks.clear()
await self.client.wait_for_retroarch_connection()
self.client.reset_auth()
await self.client.wait_and_init_tracker()
while True:
await self.client.main_tick(on_item_get, victory, deathlink)
await asyncio.sleep(0.1)
now = time.time()
if self.last_resend + 5.0 < now:
self.last_resend = now
await self.send_checks()
self.magpie.set_checks(self.client.tracker.all_checks)
await self.magpie.set_item_tracker(self.client.item_tracker)
await self.magpie.send_gps(self.client.gps_tracker)
except GameboyException:
time.sleep(1.0)
pass
async def main():
parser = get_base_parser(description="Link's Awakening Client.")
parser.add_argument("--url", help="Archipelago connection url")
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a .apladx Archipelago Binary Patch file')
args = parser.parse_args()
logger.info(args)
if args.diff_file:
import Patch
logger.info("patch file was supplied - creating rom...")
meta, rom_file = Patch.create_rom_file(args.diff_file)
if "server" in meta:
args.url = meta["server"]
logger.info(f"wrote rom file to {rom_file}")
if args.url:
url = urllib.parse.urlparse(args.url)
args.connect = url.netloc
if url.password:
args.password = urllib.parse.unquote(url.password)
ctx = LinksAwakeningContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
# TODO: nothing about the lambda about has to be in a lambda
ctx.la_task = create_task_log_exception(ctx.run_game_loop())
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
await ctx.exit_event.wait()
await ctx.shutdown()
if __name__ == '__main__':
colorama.init()
asyncio.run(main())
colorama.deinit()

View File

@@ -35,7 +35,7 @@ class AdjusterWorld(object):
def __init__(self, sprite_pool):
import random
self.sprite_pool = {1: sprite_pool}
self.slot_seeds = {1: random}
self.per_slot_randoms = {1: random}
class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):

238
Main.py
View File

@@ -1,5 +1,4 @@
import collections
from itertools import zip_longest, chain
import logging
import os
import time
@@ -10,11 +9,12 @@ import tempfile
import zipfile
from typing import Dict, List, Tuple, Optional, Set
from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
from worlds.alttp.Items import item_name_groups
from BaseClasses import Item, MultiWorld, CollectionState, Region, LocationProgressType, Location
import worlds
from worlds.alttp.SubClasses import LTTPRegionType
from worlds.alttp.Regions import is_main_entrance
from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned
from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots
from worlds.alttp.Shops import FillDisabledShopSlots
from Utils import output_path, get_options, __version__, version_tuple
from worlds.generic.Rules import locality_rules, exclusion_rules
from worlds import AutoWorld
@@ -38,7 +38,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world = MultiWorld(args.multi)
logger = logging.getLogger()
world.set_seed(seed, args.race, str(args.outputname if args.outputname else world.seed))
world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None)
world.plando_options = args.plando_options
world.shuffle = args.shuffle.copy()
world.logic = args.logic.copy()
@@ -52,7 +53,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.enemy_damage = args.enemy_damage.copy()
world.beemizer_total_chance = args.beemizer_total_chance.copy()
world.beemizer_trap_chance = args.beemizer_trap_chance.copy()
world.timer = args.timer.copy()
world.countdown_start_time = args.countdown_start_time.copy()
world.red_clock_time = args.red_clock_time.copy()
world.blue_clock_time = args.blue_clock_time.copy()
@@ -78,7 +78,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
world.state = CollectionState(world)
logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed)
logger.info("Found World Types:")
logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:")
longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types)
max_item = 0
@@ -116,26 +116,23 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for _ in range(count):
world.push_precollected(world.create_item(item_name, player))
for player in world.player_ids:
if player in world.get_game_players("A Link to the Past"):
# enforce pre-defined local items.
if world.goal[player] in ["localtriforcehunt", "localganontriforcehunt"]:
world.local_items[player].value.add('Triforce Piece')
# Not possible to place pendants/crystals outside boss prizes yet.
world.non_local_items[player].value -= item_name_groups['Pendants']
world.non_local_items[player].value -= item_name_groups['Crystals']
# items can't be both local and non-local, prefer local
world.non_local_items[player].value -= world.local_items[player].value
logger.info('Creating World.')
AutoWorld.call_all(world, "create_regions")
logger.info('Creating Items.')
AutoWorld.call_all(world, "create_items")
# All worlds should have finished creating all regions, locations, and entrances.
# Recache to ensure that they are all visible for locality rules.
world._recache()
logger.info('Calculating Access Rules.')
for player in world.player_ids:
# items can't be both local and non-local, prefer local
world.non_local_items[player].value -= world.local_items[player].value
world.non_local_items[player].value -= set(world.local_early_items[player])
if world.players > 1:
locality_rules(world)
else:
@@ -194,7 +191,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
new_item.classification |= classifications[item_name]
new_itempool.append(new_item)
region = Region("Menu", RegionType.Generic, "ItemLink", group_id, world)
region = Region("Menu", group_id, world, "ItemLink")
world.regions.append(region)
locations = region.locations = []
for item in world.itempool:
@@ -217,11 +214,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
while itemcount > len(world.itempool):
items_to_add = []
for player in group["players"]:
if group["link_replacement"]:
item_player = group_id
else:
item_player = player
if group["replacement_items"][player]:
items_to_add.append(AutoWorld.call_single(world, "create_item", player,
items_to_add.append(AutoWorld.call_single(world, "create_item", item_player,
group["replacement_items"][player]))
else:
items_to_add.append(AutoWorld.call_single(world, "create_filler", player))
items_to_add.append(AutoWorld.call_single(world, "create_filler", item_player))
world.random.shuffle(items_to_add)
world.itempool.extend(items_to_add[:itemcount - len(world.itempool)])
@@ -250,6 +251,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
balance_multiworld_progression(world)
logger.info(f'Beginning output...')
# we're about to output using multithreading, so we're removing the global random state to prevent accidental use
world.random.passthrough = False
outfilebase = 'AP_' + world.seed_name
output = tempfile.TemporaryDirectory()
@@ -285,37 +290,16 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
'Inverted Ganons Tower': 'Ganons Tower'} \
.get(location.parent_region.dungeon.name, location.parent_region.dungeon.name)
checks_in_area[location.player][dungeonname].append(location.address)
elif location.parent_region.type == RegionType.LightWorld:
elif location.parent_region.type == LTTPRegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif location.parent_region.type == RegionType.DarkWorld:
elif location.parent_region.type == LTTPRegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.LightWorld:
elif main_entrance.parent_region.type == LTTPRegionType.LightWorld:
checks_in_area[location.player]["Light World"].append(location.address)
elif main_entrance.parent_region.type == RegionType.DarkWorld:
elif main_entrance.parent_region.type == LTTPRegionType.DarkWorld:
checks_in_area[location.player]["Dark World"].append(location.address)
checks_in_area[location.player]["Total"] += 1
oldmancaves = []
takeanyregions = ["Old Man Sword Cave", "Take-Any #1", "Take-Any #2", "Take-Any #3", "Take-Any #4"]
for index, take_any in enumerate(takeanyregions):
for region in [world.get_region(take_any, player) for player in
world.get_game_players("A Link to the Past") if world.retro_caves[player]]:
item = world.create_item(
region.shop.inventory[(0 if take_any == "Old Man Sword Cave" else 1)]['item'],
region.player)
player = region.player
location_id = SHOP_ID_START + total_shop_slots + index
main_entrance = region.get_connecting_entrance(is_main_entrance)
if main_entrance.parent_region.type == RegionType.LightWorld:
checks_in_area[player]["Light World"].append(location_id)
else:
checks_in_area[player]["Dark World"].append(location_id)
checks_in_area[player]["Total"] += 1
er_hint_data[player][location_id] = main_entrance.name
oldmancaves.append(((location_id, player), (item.code, player)))
FillDisabledShopSlots(world)
def write_multidata():
@@ -371,16 +355,17 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for player in world.groups.get(location.item.player, {}).get("players", [])]):
precollect_hint(location)
# embedded data package
data_package = {
game_world.game: worlds.network_data_package["games"][game_world.game]
for game_world in world.worlds.values()
}
multidata = {
"slot_data": slot_data,
"slot_info": slot_info,
"names": names, # TODO: remove around 0.2.5 in favor of slot_info
"games": games, # TODO: remove around 0.2.5 in favor of slot_info
"names": names, # TODO: remove after 0.3.9
"connect_names": {name: (0, player) for player, name in world.player_name.items()},
"remote_items": {player for player in world.player_ids if
world.worlds[player].remote_items},
"remote_start_inventory": {player for player in world.player_ids if
world.worlds[player].remote_start_inventory},
"locations": locations_data,
"checks_in_area": checks_in_area,
"server_options": baked_server_options,
@@ -390,7 +375,8 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
"version": tuple(version_tuple),
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": world.seed_name
"seed_name": world.seed_name,
"datapackage": data_package,
}
AutoWorld.call_all(world, "modify_multidata", multidata)
@@ -416,7 +402,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
if args.spoiler > 1:
logger.info('Calculating playthrough.')
create_playthrough(world)
world.spoiler.create_playthrough(create_paths=args.spoiler > 2)
if args.spoiler:
world.spoiler.to_file(os.path.join(temp_dir, '%s_Spoiler.txt' % outfilebase))
@@ -430,143 +416,3 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
logger.info('Done. Enjoy. Total Time: %s', time.perf_counter() - start)
return world
def create_playthrough(world):
"""Destructive to the world while it is run, damage gets repaired afterwards."""
# get locations containing progress items
prog_locations = {location for location in world.get_filled_locations() if location.item.advancement}
state_cache = [None]
collection_spheres = []
state = CollectionState(world)
sphere_candidates = set(prog_locations)
logging.debug('Building up collection spheres.')
while sphere_candidates:
# build up spheres of collection radius.
# Everything in each sphere is independent from each other in dependencies and only depends on lower spheres
sphere = {location for location in sphere_candidates if state.can_reach(location)}
for location in sphere:
state.collect(location.item, True, location)
sphere_candidates -= sphere
collection_spheres.append(sphere)
state_cache.append(state.copy())
logging.debug('Calculated sphere %i, containing %i of %i progress items.', len(collection_spheres), len(sphere),
len(prog_locations))
if not sphere:
logging.debug('The following items could not be reached: %s', ['%s (Player %d) at %s (Player %d)' % (
location.item.name, location.item.player, location.name, location.player) for location in
sphere_candidates])
if any([world.accessibility[location.item.player] != 'minimal' for location in sphere_candidates]):
raise RuntimeError(f'Not all progression items reachable ({sphere_candidates}). '
f'Something went terribly wrong here.')
else:
world.spoiler.unreachables = sphere_candidates
break
# 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 = {}
for num, sphere in reversed(tuple(enumerate(collection_spheres))):
to_delete = set()
for location in sphere:
# we remove the item at location and check if 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 world.can_beat_game(state_cache[num]):
to_delete.add(location)
restore_later[location] = old_item
else:
# still required, got to keep it around
location.item = old_item
# cull entries in spheres for spoiler walkthrough at end
sphere -= to_delete
# second phase, sphere 0
removed_precollected = []
for item in (i for i in chain.from_iterable(world.precollected_items.values()) if i.advancement):
logging.debug('Checking if %s (Player %d) is required to beat the game.', item.name, item.player)
world.precollected_items[item.player].remove(item)
world.state.remove(item)
if not world.can_beat_game():
world.push_precollected(item)
else:
removed_precollected.append(item)
# we are now down to just the required progress items in collection_spheres. Unfortunately
# the previous pruning stage could potentially have made certain items dependant on others
# in the same or later sphere (because the location had 2 ways to access but the item originally
# used to access it was deemed not required.) So we need to do one final sphere collection pass
# to build up the correct spheres
required_locations = {item for sphere in collection_spheres for item in sphere}
state = CollectionState(world)
collection_spheres = []
while required_locations:
state.sweep_for_events(key_only=True)
sphere = set(filter(state.can_reach, required_locations))
for location in sphere:
state.collect(location.item, True, location)
required_locations -= sphere
collection_spheres.append(sphere)
logging.debug('Calculated final sphere %i, containing %i of %i progress items.', len(collection_spheres),
len(sphere), len(required_locations))
if not sphere:
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
def flist_to_iter(node):
while node:
value, node = node
yield value
def get_path(state, region):
reversed_path_as_flist = state.path.get(region, (region, None))
string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist))))
# Now we combine the flat string list into (region, exit) pairs
pathsiter = iter(string_path_flat)
pathpairs = zip_longest(pathsiter, pathsiter)
return list(pathpairs)
world.spoiler.paths = {}
topology_worlds = (player for player in world.player_ids if world.worlds[player].topology_present)
for player in topology_worlds:
world.spoiler.paths.update(
{str(location): get_path(state, location.parent_region) for sphere in collection_spheres for location in
sphere if location.player == player})
if player in world.get_game_players("A Link to the Past"):
# If Pyramid Fairy Entrance needs to be reached, also path to Big Bomb Shop
# Maybe move the big bomb over to the Event system instead?
if any(exit_path == 'Pyramid Fairy' for path in world.spoiler.paths.values() for (_, exit_path) in path):
if world.mode[player] != 'inverted':
world.spoiler.paths[str(world.get_region('Big Bomb Shop', player))] = \
get_path(state, world.get_region('Big Bomb Shop', player))
else:
world.spoiler.paths[str(world.get_region('Inverted Big Bomb Shop', player))] = \
get_path(state, world.get_region('Inverted Big Bomb Shop', player))
# we can finally output our playthrough
world.spoiler.playthrough = {"0": sorted([str(item) for item in
chain.from_iterable(world.precollected_items.values())
if item.advancement])}
for i, sphere in enumerate(collection_spheres):
world.spoiler.playthrough[str(i + 1)] = {str(location): str(location.item) for location in sorted(sphere)}
# repair the world again
for location, item in restore_later.items():
location.item = item
for item in removed_precollected:
world.push_precollected(item)

View File

@@ -77,49 +77,34 @@ def read_apmc_file(apmc_file):
return json.loads(b64decode(f.read()))
def update_mod(forge_dir, minecraft_version: str, get_prereleases=False):
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)
client_releases_endpoint = "https://api.github.com/repos/KonoTyran/Minecraft_AP_Randomizer/releases"
resp = requests.get(client_releases_endpoint)
if resp.status_code == 200: # OK
try:
latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and
(minecraft_version in release['assets'][0]['name']),
resp.json()))
if ap_randomizer != latest_release['assets'][0]['name']:
logging.info(f"A new release of the Minecraft AP randomizer mod was found: "
f"{latest_release['assets'][0]['name']}")
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 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', latest_release['assets'][0]['name'])
logging.info("Downloading AP randomizer mod. This may take a moment...")
apmod_resp = requests.get(latest_release['assets'][0]['browser_download_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)
except StopIteration:
logging.warning(f"No compatible mod version found for {minecraft_version}.")
if not prompt_yes_no("Run server anyway?"):
sys.exit(0)
os.path.basename(url)
if ap_randomizer is not None:
logging.info(f"Your current mod is {ap_randomizer}.")
else:
logging.error(f"Error checking for randomizer mod updates (status code {resp.status_code}).")
logging.error(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)
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):
@@ -264,8 +249,13 @@ def get_minecraft_versions(version, release_channel="release"):
return next(filter(lambda entry: entry["version"] == version, data[release_channel]))
else:
return resp.json()[release_channel][0]
except StopIteration:
logging.error(f"No compatible mod version found for client version {version}.")
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:
@@ -286,6 +276,8 @@ if __name__ == '__main__':
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
@@ -296,12 +288,12 @@ if __name__ == '__main__':
options = Utils.get_options()
channel = args.channel or options["minecraft_options"]["release_channel"]
apmc_data = None
data_version = 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:
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', '')
@@ -311,6 +303,7 @@ if __name__ == '__main__':
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:
@@ -344,7 +337,7 @@ if __name__ == '__main__':
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, f"MC{forge_version.split('-')[0]}", channel != "release")
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)

View File

@@ -1,7 +1,7 @@
import os
import sys
import subprocess
import pkg_resources
import warnings
local_dir = os.path.dirname(__file__)
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
@@ -21,24 +21,58 @@ if not update_ran:
requirements_files.add(req_file)
def check_pip():
# detect if pip is available
try:
import pip # noqa: F401
except ImportError:
raise RuntimeError("pip not available. Please install pip.")
def confirm(msg: str):
try:
input(f"\n{msg}")
except KeyboardInterrupt:
print("\nAborting")
sys.exit(1)
def update_command():
check_pip()
for file in requirements_files:
subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade'])
subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--upgrade"])
def install_pkg_resources(yes=False):
try:
import pkg_resources # noqa: F401
except ImportError:
check_pip()
if not yes:
confirm("pkg_resources not found, press enter to install it")
subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"])
def update(yes=False, force=False):
global update_ran
if not update_ran:
update_ran = True
if force:
update_command()
return
install_pkg_resources(yes=yes)
import pkg_resources
for req_file in requirements_files:
path = os.path.join(os.path.dirname(sys.argv[0]), req_file)
if not os.path.exists(path):
path = os.path.join(os.path.dirname(__file__), req_file)
with open(path) as requirementsfile:
for line in requirementsfile:
if not line or line[0] == "#":
continue # ignore comments
if line.startswith(("https://", "git+https://")):
# extract name and version for url
rest = line.split('/')[-1]
@@ -46,8 +80,10 @@ def update(yes=False, force=False):
if "#egg=" in rest:
# from egg info
rest, egg = rest.split("#egg=", 1)
egg = egg.split(";", 1)[0]
egg = egg.split(";", 1)[0].rstrip()
if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")):
warnings.warn(f"Specifying version as #egg={egg} will become unavailable in pip 25.0. "
"Use name @ url#version instead.", DeprecationWarning)
line = egg
else:
egg = ""
@@ -58,16 +94,23 @@ def update(yes=False, force=False):
rest = rest.replace(".zip", "-").replace(".tar.gz", "-")
name, version, _ = rest.split("-", 2)
line = f'{egg or name}=={version}'
elif "@" in line and "#" in line:
# PEP 508 does not allow us to specify a version, so we use custom syntax
# name @ url#version ; marker
name, rest = line.split("@", 1)
version = rest.split("#", 1)[1].split(";", 1)[0].rstrip()
line = f"{name.rstrip()}=={version}"
if ";" in rest: # keep marker
line += rest[rest.find(";"):]
requirements = pkg_resources.parse_requirements(line)
for requirement in requirements:
requirement = str(requirement)
for requirement in map(str, requirements):
try:
pkg_resources.require(requirement)
except pkg_resources.ResolutionError:
if not yes:
import traceback
traceback.print_exc()
input(f'Requirement {requirement} is not satisfied, press enter to install it')
confirm(f"Requirement {requirement} is not satisfied, press enter to install it")
update_command()
return

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,7 @@ class SlotType(enum.IntFlag):
@property
def always_goal(self) -> bool:
"""Mark this slot has having reached its goal instantly."""
"""Mark this slot as having reached its goal instantly."""
return self.value != 0b01
@@ -43,7 +43,7 @@ class Permission(enum.IntFlag):
disabled = 0b000 # 0, completely disables access
enabled = 0b001 # 1, allows manual use
goal = 0b010 # 2, allows manual use after goal completion
auto = 0b110 # 6, forces use after goal completion, only works for forfeit
auto = 0b110 # 6, forces use after goal completion, only works for release
auto_enabled = 0b111 # 7, forces use after goal completion, allows manual use any time
@staticmethod
@@ -86,7 +86,7 @@ def _scan_for_TypedTuples(obj: typing.Any) -> typing.Any:
data = obj._asdict()
data["class"] = obj.__class__.__name__
return data
if isinstance(obj, (tuple, list, set)):
if isinstance(obj, (tuple, list, set, frozenset)):
return tuple(_scan_for_TypedTuples(o) for o in obj)
if isinstance(obj, dict):
return {key: _scan_for_TypedTuples(value) for key, value in obj.items()}
@@ -109,7 +109,7 @@ def get_any_version(data: dict) -> Version:
return Version(int(data["major"]), int(data["minor"]), int(data["build"]))
whitelist = {
allowlist = {
"NetworkPlayer": NetworkPlayer,
"NetworkItem": NetworkItem,
"NetworkSlot": NetworkSlot
@@ -125,7 +125,7 @@ def _object_hook(o: typing.Any) -> typing.Any:
hook = custom_hooks.get(o.get("class", None), None)
if hook:
return hook(o)
cls = whitelist.get(o.get("class", None), None)
cls = allowlist.get(o.get("class", None), None)
if cls:
for key in tuple(o):
if key not in cls._fields:

View File

@@ -3,6 +3,7 @@ import argparse
import logging
import random
import os
import zipfile
from itertools import chain
from BaseClasses import MultiWorld
@@ -196,7 +197,7 @@ def set_icon(window):
def adjust(args):
# Create a fake world and OOTWorld to use as a base
world = MultiWorld(1)
world.slot_seeds = {1: random}
world.per_slot_randoms = {1: random}
ootworld = OOTWorld(world, 1)
# Set options in the fake OOTWorld
for name, option in chain(cosmetic_options.items(), sfx_options.items()):
@@ -217,13 +218,18 @@ def adjust(args):
# Load up the ROM
rom = Rom(file=args.rom, force_use=True)
delete_zootdec = True
elif os.path.splitext(args.rom)[-1] == '.apz5':
elif os.path.splitext(args.rom)[-1] in ['.apz5', '.zpf']:
# Load vanilla ROM
rom = Rom(file=args.vanilla_rom, force_use=True)
apz5_file = args.rom
base_name = os.path.splitext(apz5_file)[0]
# Patch file
apply_patch_file(rom, args.rom)
apply_patch_file(rom, apz5_file,
sub_file=(os.path.basename(base_name) + '.zpf'
if zipfile.is_zipfile(apz5_file)
else None))
else:
raise Exception("Invalid file extension; requires .n64, .z64, .apz5")
raise Exception("Invalid file extension; requires .n64, .z64, .apz5, .zpf")
# Call patch_cosmetics
try:
patch_cosmetics(ootworld, rom)

View File

@@ -3,6 +3,7 @@ import json
import os
import multiprocessing
import subprocess
import zipfile
from asyncio import StreamReader, StreamWriter
# CommonClient import first to trigger ModuleUpdater
@@ -50,7 +51,7 @@ deathlink_sent_this_death: we interacted with the multiworld on this death, wait
oot_loc_name_to_id = network_data_package["games"]["Ocarina of Time"]["location_name_to_id"]
script_version: int = 2
script_version: int = 3
def get_item_value(ap_id):
return ap_id - 66000
@@ -85,6 +86,9 @@ class OoTContext(CommonContext):
self.n64_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.location_table = {}
self.collectible_table = {}
self.collectible_override_flags_address = 0
self.collectible_offsets = {}
self.deathlink_enabled = False
self.deathlink_pending = False
self.deathlink_sent_this_death = False
@@ -117,6 +121,13 @@ class OoTContext(CommonContext):
self.ui = OoTManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def on_package(self, cmd, args):
if cmd == 'Connected':
slot_data = args.get('slot_data', None)
if slot_data:
self.collectible_override_flags_address = slot_data.get('collectible_override_flags', 0)
self.collectible_offsets = slot_data.get('collectible_flag_offsets', {})
def get_payload(ctx: OoTContext):
if ctx.deathlink_enabled and ctx.deathlink_pending:
@@ -125,11 +136,14 @@ def get_payload(ctx: OoTContext):
else:
trigger_death = False
return json.dumps({
payload = json.dumps({
"items": [get_item_value(item.item) for item in ctx.items_received],
"playerNames": [name for (i, name) in ctx.player_names.items() if i != 0],
"triggerDeath": trigger_death
"triggerDeath": trigger_death,
"collectibleOverrides": ctx.collectible_override_flags_address,
"collectibleOffsets": ctx.collectible_offsets
})
return payload
async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
@@ -141,6 +155,7 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
ctx.deathlink_client_override = False
ctx.finished_game = False
ctx.location_table = {}
ctx.collectible_table = {}
ctx.deathlink_pending = False
ctx.deathlink_sent_this_death = False
ctx.auth = payload['playerName']
@@ -161,11 +176,17 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
ctx.finished_game = True
# Locations handling
if ctx.location_table != payload['locations']:
ctx.location_table = payload['locations']
locations = payload['locations']
collectibles = payload['collectibles']
if ctx.location_table != locations or ctx.collectible_table != collectibles:
ctx.location_table = locations
ctx.collectible_table = collectibles
locs1 = [oot_loc_name_to_id[loc] for loc, b in ctx.location_table.items() if b]
locs2 = [int(loc) for loc, b in ctx.collectible_table.items() if b]
await ctx.send_msgs([{
"cmd": "LocationChecks",
"locations": [oot_loc_name_to_id[loc] for loc in ctx.location_table if ctx.location_table[loc]]
"locations": locs1 + locs2
}])
# Deathlink handling
@@ -191,13 +212,6 @@ async def n64_sync_task(ctx: OoTContext):
try:
await asyncio.wait_for(writer.drain(), timeout=1.5)
try:
# Data will return a dict with up to six fields:
# 1. str: player name (always)
# 2. int: script version (always)
# 3. bool: deathlink active (always)
# 4. dict[str, bool]: checked locations
# 5. bool: whether Link is currently at 0 HP
# 6. bool: whether the game currently registers as complete
data = await asyncio.wait_for(reader.readline(), timeout=10)
data_decoded = json.loads(data.decode())
reported_version = data_decoded.get('scriptVersion', 0)
@@ -270,12 +284,19 @@ async def run_game(romfile):
async def patch_and_run_game(apz5_file):
apz5_file = os.path.abspath(apz5_file)
base_name = os.path.splitext(apz5_file)[0]
decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
apply_patch_file(rom, apz5_file)
rom_file_name = Utils.get_options()["oot_options"]["rom_file"]
if not os.path.exists(rom_file_name):
rom_file_name = Utils.user_path(rom_file_name)
rom = Rom(rom_file_name)
apply_patch_file(rom, apz5_file,
sub_file=(os.path.basename(base_name) + '.zpf'
if zipfile.is_zipfile(apz5_file)
else None))
rom.write_to_file(decomp_path)
os.chdir(data_path("Compress"))
compress_rom_file(decomp_path, comp_path)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import abc
import logging
from copy import deepcopy
import math
import numbers
@@ -9,6 +10,10 @@ import random
from schema import Schema, And, Or, Optional
from Utils import get_fuzzy_results
if typing.TYPE_CHECKING:
from BaseClasses import PlandoOptions
from worlds.AutoWorld import World
class AssembleOptions(abc.ABCMeta):
def __new__(mcs, name, bases, attrs):
@@ -79,9 +84,6 @@ class AssembleOptions(abc.ABCMeta):
return super(AssembleOptions, mcs).__new__(mcs, name, bases, attrs)
@abc.abstractclassmethod
def from_any(cls, value: typing.Any) -> "Option[typing.Any]": ...
T = typing.TypeVar('T')
@@ -98,11 +100,11 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
supports_weighting = True
# filled by AssembleOptions:
name_lookup: typing.Dict[int, str]
name_lookup: typing.Dict[T, str]
options: typing.Dict[str, int]
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.get_current_option_name()})"
return f"{self.__class__.__name__}({self.current_option_name})"
def __hash__(self) -> int:
return hash(self.value)
@@ -112,7 +114,14 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
return self.name_lookup[self.value]
def get_current_option_name(self) -> str:
"""For display purposes."""
"""Deprecated. use current_option_name instead. TODO remove around 0.4"""
logging.warning(DeprecationWarning(f"get_current_option_name for {self.__class__.__name__} is deprecated."
f" use current_option_name instead. Worlds should use {self}.current_key"))
return self.current_option_name
@property
def current_option_name(self) -> str:
"""For display purposes. Worlds should be using current_key."""
return self.get_option_name(self.value)
@classmethod
@@ -129,21 +138,19 @@ class Option(typing.Generic[T], metaclass=AssembleOptions):
return bool(self.value)
@classmethod
@abc.abstractmethod
def from_any(cls, data: typing.Any) -> Option[T]:
raise NotImplementedError
...
if typing.TYPE_CHECKING:
from Generate import PlandoSettings
from worlds.AutoWorld import World
def verify(self, world: World, player_name: str, plando_options: PlandoSettings) -> None:
def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None:
pass
else:
def verify(self, *args, **kwargs) -> None:
pass
class FreeText(Option):
class FreeText(Option[str]):
"""Text option that allows users to enter strings.
Needs to be validated by the world or option definition."""
@@ -164,11 +171,11 @@ class FreeText(Option):
return cls.from_text(str(data))
@classmethod
def get_option_name(cls, value: T) -> str:
def get_option_name(cls, value: str) -> str:
return value
class NumericOption(Option[int], numbers.Integral):
class NumericOption(Option[int], numbers.Integral, abc.ABC):
default = 0
# note: some of the `typing.Any`` here is a result of unresolved issue in python standards
# `int` is not a `numbers.Integral` according to the official typestubs
@@ -426,6 +433,7 @@ class Choice(NumericOption):
class TextChoice(Choice):
"""Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string"""
value: typing.Union[str, int]
def __init__(self, value: typing.Union[str, int]):
assert isinstance(value, str) or isinstance(value, int), \
@@ -436,8 +444,7 @@ class TextChoice(Choice):
def current_key(self) -> str:
if isinstance(self.value, str):
return self.value
else:
return self.name_lookup[self.value]
return super().current_key
@classmethod
def from_text(cls, text: str) -> TextChoice:
@@ -452,7 +459,7 @@ class TextChoice(Choice):
def get_option_name(cls, value: T) -> str:
if isinstance(value, str):
return value
return cls.name_lookup[value]
return super().get_option_name(value)
def __eq__(self, other: typing.Any):
if isinstance(other, self.__class__):
@@ -575,12 +582,11 @@ class PlandoBosses(TextChoice, metaclass=BossMeta):
def valid_location_name(cls, value: str) -> bool:
return value in cls.locations
def verify(self, world, player_name: str, plando_options) -> None:
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if isinstance(self.value, int):
return
from Generate import PlandoSettings
if not(PlandoSettings.bosses & plando_options):
import logging
from BaseClasses import PlandoOptions
if not(PlandoOptions.bosses & plando_options):
# plando is disabled but plando options were given so pull the option and change it to an int
option = self.value.split(";")[-1]
self.value = self.options[option]
@@ -718,7 +724,7 @@ class VerifyKeys:
value: typing.Any
@classmethod
def verify_keys(cls, data):
def verify_keys(cls, data: typing.List[str]):
if cls.valid_keys:
data = set(data)
dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data)
@@ -727,12 +733,17 @@ class VerifyKeys:
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls.valid_keys}.")
def verify(self, world, player_name: str, plando_options) -> None:
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
if self.convert_name_groups and self.verify_item_name:
new_value = type(self.value)() # empty container of whatever value is
for item_name in self.value:
new_value |= world.item_name_groups.get(item_name, {item_name})
self.value = new_value
elif self.convert_name_groups and self.verify_location_name:
new_value = type(self.value)()
for loc_name in self.value:
new_value |= world.location_name_groups.get(loc_name, {loc_name})
self.value = new_value
if self.verify_item_name:
for item_name in self.value:
if item_name not in world.item_names:
@@ -832,7 +843,9 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys):
return item in self.value
local_objective = Toggle # local triforce pieces, local dungeon prizes etc.
class ItemSet(OptionSet):
verify_item_name = True
convert_name_groups = True
class Accessibility(Choice):
@@ -868,11 +881,6 @@ common_options = {
}
class ItemSet(OptionSet):
verify_item_name = True
convert_name_groups = True
class LocalItems(ItemSet):
"""Forces these items to be in their native world."""
display_name = "Local Items"
@@ -894,22 +902,23 @@ class StartHints(ItemSet):
display_name = "Start Hints"
class StartLocationHints(OptionSet):
class LocationSet(OptionSet):
verify_location_name = True
class StartLocationHints(LocationSet):
"""Start with these locations and their item prefilled into the !hint command"""
display_name = "Start Location Hints"
verify_location_name = True
class ExcludeLocations(OptionSet):
class ExcludeLocations(LocationSet):
"""Prevent these locations from having an important item"""
display_name = "Excluded Locations"
verify_location_name = True
class PriorityLocations(OptionSet):
class PriorityLocations(LocationSet):
"""Prevent these locations from having an unimportant item"""
display_name = "Priority Locations"
verify_location_name = True
class DeathLink(Toggle):
@@ -927,7 +936,8 @@ class ItemLinks(OptionList):
Optional("exclude"): [And(str, len)],
"replacement_item": Or(And(str, len), None),
Optional("local_items"): [And(str, len)],
Optional("non_local_items"): [And(str, len)]
Optional("non_local_items"): [And(str, len)],
Optional("link_replacement"): Or(None, bool),
}
])
@@ -949,7 +959,8 @@ class ItemLinks(OptionList):
pool |= {item_name}
return pool
def verify(self, world, player_name: str, plando_options) -> None:
def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None:
link: dict
super(ItemLinks, self).verify(world, player_name, plando_options)
existing_links = set()
for link in self.value:
@@ -974,7 +985,9 @@ class ItemLinks(OptionList):
intersection = local_items.intersection(non_local_items)
if intersection:
raise Exception(f"item_link {link['name']} has {intersection} items in both its local_items and non_local_items pool.")
raise Exception(f"item_link {link['name']} has {intersection} "
f"items in both its local_items and non_local_items pool.")
link.setdefault("link_replacement", None)
per_game_common_options = {

View File

@@ -15,8 +15,9 @@ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandP
get_base_parser
from worlds.pokemon_rb.locations import location_data
from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}}
location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}}
location_bytes_bits = {}
for location in location_data:
if location.ram_address is not None:
@@ -39,6 +40,8 @@ CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
SCRIPT_VERSION = 3
class GBCommandProcessor(ClientCommandProcessor):
def __init__(self, ctx: CommonContext):
@@ -53,7 +56,6 @@ class GBCommandProcessor(ClientCommandProcessor):
class GBContext(CommonContext):
command_processor = GBCommandProcessor
game = 'Pokemon Red and Blue'
items_handling = 0b101
def __init__(self, server_address, password):
super().__init__(server_address, password)
@@ -64,6 +66,12 @@ class GBContext(CommonContext):
self.gb_status = CONNECTION_INITIAL_STATUS
self.awaiting_rom = False
self.display_msgs = True
self.deathlink_pending = False
self.set_deathlink = False
self.client_compatibility_mode = 0
self.items_handling = 0b001
self.sent_release = False
self.sent_collect = False
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
@@ -82,6 +90,8 @@ class GBContext(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if 'death_link' in args['slot_data'] and args['slot_data']['death_link']:
self.set_deathlink = True
elif cmd == "RoomInfo":
self.seed_name = args['seed_name']
elif cmd == 'Print':
@@ -92,6 +102,10 @@ class GBContext(CommonContext):
msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}"
self._set_message(msg, SYSTEM_MESSAGE_ID)
def on_deathlink(self, data: dict):
self.deathlink_pending = True
super().on_deathlink(data)
def run_gui(self):
from kvui import GameManager
@@ -107,28 +121,29 @@ class GBContext(CommonContext):
def get_payload(ctx: GBContext):
current_time = time.time()
return json.dumps(
ret = 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}
if key[0] > current_time - 10},
"deathlink": ctx.deathlink_pending,
"options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled'))
}
)
ctx.deathlink_pending = False
return ret
async def parse_locations(data: List, ctx: GBContext):
locations = []
flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20],
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], "Rod": data[0x140 + 0x20 + 0x0E:]}
"Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E],
"Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]}
# Check for clear problems
if len(flags['Rod']) > 1:
return
if flags["EventFlag"][1] + flags["EventFlag"][8] + flags["EventFlag"][9] + flags["EventFlag"][12] \
+ flags["EventFlag"][61] + flags["EventFlag"][62] + flags["EventFlag"][63] + flags["EventFlag"][64] \
+ flags["EventFlag"][65] + flags["EventFlag"][66] + flags["EventFlag"][67] + flags["EventFlag"][68] \
+ flags["EventFlag"][69] + flags["EventFlag"][70] != 0:
return
if len(data) > 0x140 + 0x20 + 0x0E + 0x01:
flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:]
else:
flags["DexSanityFlag"] = [0] * 19
for flag_type, loc_map in location_map.items():
for flag, loc_id in loc_map.items():
@@ -168,8 +183,15 @@ async def gb_sync_task(ctx: GBContext):
# 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 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION:
msg = "You are connecting with an incompatible Lua script version. Ensure your connector Lua " \
"and PokemonClient are from the same Archipelago installation."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
ctx.client_compatibility_mode = data_decoded['clientCompatibilityVersion']
if ctx.client_compatibility_mode == 0:
ctx.items_handling = 0b101 # old patches will not have local start inventory, must be requested
if ctx.seed_name and ctx.seed_name != ''.join([chr(i) for i in data_decoded['seedName'] if i != 0]):
msg = "The server is running a different multiworld than your client is. (invalid seed_name)"
logger.info(msg, extra={'compact_gui': True})
@@ -179,13 +201,30 @@ async def gb_sync_task(ctx: GBContext):
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.")
msg = "Invalid ROM detected. No player name built into the ROM."
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
error_status = CONNECTION_RESET_STATUS
if ctx.awaiting_rom:
await ctx.server_auth(False)
if 'locations' in data_decoded and ctx.game and ctx.gb_status == CONNECTION_CONNECTED_STATUS \
and not error_status and ctx.auth:
# Not just a keep alive ping, parse
async_start(parse_locations(data_decoded['locations'], ctx))
if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags:
await ctx.send_death(ctx.auth + " is out of usable Pokémon! " + ctx.auth + " blacked out!")
if 'options' in data_decoded:
msgs = []
if data_decoded['options'] & 4 and not ctx.sent_release:
ctx.sent_release = True
msgs.append({"cmd": "Say", "text": "!release"})
if data_decoded['options'] & 8 and not ctx.sent_collect:
ctx.sent_collect = True
msgs.append({"cmd": "Say", "text": "!collect"})
if msgs:
await ctx.send_msgs(msgs)
if ctx.set_deathlink:
await ctx.update_death_link(True)
except asyncio.TimeoutError:
logger.debug("Read Timed Out, Reconnecting")
error_status = CONNECTION_TIMING_OUT_STATUS
@@ -243,8 +282,16 @@ async def run_game(romfile):
async def patch_and_run_game(game_version, patch_file, ctx):
base_name = os.path.splitext(patch_file)[0]
comp_path = base_name + '.gb'
with open(Utils.local_path(Utils.get_options()["pokemon_rb_options"][f"{game_version}_rom_file"]), "rb") as stream:
base_rom = bytes(stream.read())
if game_version == "blue":
delta_patch = BlueDeltaPatch
else:
delta_patch = RedDeltaPatch
try:
base_rom = delta_patch.get_source_data()
except Exception as msg:
logger.info(msg, extra={'compact_gui': True})
ctx.gui_error('Error', msg)
with zipfile.ZipFile(patch_file, 'r') as patch_archive:
with patch_archive.open('delta.bsdiff4', 'r') as stream:

View File

@@ -33,6 +33,16 @@ Currently, the following games are supported:
* Hylics 2
* Overcooked! 2
* Zillion
* Lufia II Ancient Cave
* Blasphemous
* Wargroove
* Stardew Valley
* The Legend of Zelda
* The Messenger
* Kingdom Hearts 2
* The Legend of Zelda: Link's Awakening DX
* Clique
* Adventure
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

@@ -56,7 +56,9 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
"""Connect to a snes. Optionally include network address of a snes to connect to,
otherwise show available devices; and a SNES device number if more than one SNES is detected.
Examples: "/snes", "/snes 1", "/snes localhost:23074 1" """
if self.ctx.snes_state in {SNESState.SNES_ATTACHED, SNESState.SNES_CONNECTED, SNESState.SNES_CONNECTING}:
self.output("Already connected to SNES. Disconnecting first.")
self._cmd_snes_close()
return self.connect_to_snes(snes_options)
def connect_to_snes(self, snes_options: str = "") -> bool:
@@ -84,7 +86,7 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
"""Close connection to a currently connected snes"""
self.ctx.snes_reconnect_address = None
self.ctx.cancel_snes_autoreconnect()
if self.ctx.snes_socket is not None and not self.ctx.snes_socket.closed:
if self.ctx.snes_socket and not self.ctx.snes_socket.closed:
async_start(self.ctx.snes_socket.close())
return True
else:
@@ -113,8 +115,8 @@ class SNIClientCommandProcessor(ClientCommandProcessor):
class SNIContext(CommonContext):
command_processor: typing.Type[SNIClientCommandProcessor] = SNIClientCommandProcessor
game = None # set in validate_rom
items_handling = None # set in game_watcher
game: typing.Optional[str] = None # set in validate_rom
items_handling: typing.Optional[int] = None # set in game_watcher
snes_connect_task: "typing.Optional[asyncio.Task[None]]" = None
snes_autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None
@@ -442,7 +444,8 @@ async def snes_connect(ctx: SNIContext, address: str, deviceIndex: int = -1) ->
recv_task = asyncio.create_task(snes_recv_loop(ctx))
except Exception as e:
if recv_task is not None:
ctx.snes_state = SNESState.SNES_DISCONNECTED
if task_alive(recv_task):
if not ctx.snes_socket.closed:
await ctx.snes_socket.close()
else:
@@ -450,15 +453,9 @@ async def snes_connect(ctx: SNIContext, address: str, deviceIndex: int = -1) ->
if not ctx.snes_socket.closed:
await ctx.snes_socket.close()
ctx.snes_socket = None
ctx.snes_state = SNESState.SNES_DISCONNECTED
if not ctx.snes_reconnect_address:
snes_logger.error("Error connecting to snes (%s)" % e)
else:
snes_logger.error(f"Error connecting to snes, retrying in {_global_snes_reconnect_delay} seconds")
assert ctx.snes_autoreconnect_task is None
ctx.snes_autoreconnect_task = asyncio.create_task(snes_autoreconnect(ctx), name="snes auto-reconnect")
snes_logger.error(f"Error connecting to snes ({e}), retrying in {_global_snes_reconnect_delay} seconds")
ctx.snes_autoreconnect_task = asyncio.create_task(snes_autoreconnect(ctx), name="snes auto-reconnect")
_global_snes_reconnect_delay *= 2
else:
_global_snes_reconnect_delay = ctx.starting_reconnect_delay
snes_logger.info(f"Attached to {device}")
@@ -471,10 +468,17 @@ async def snes_disconnect(ctx: SNIContext) -> None:
ctx.snes_socket = None
def task_alive(task: typing.Optional[asyncio.Task]) -> bool:
if task:
return not task.done()
return False
async def snes_autoreconnect(ctx: SNIContext) -> None:
await asyncio.sleep(_global_snes_reconnect_delay)
if ctx.snes_reconnect_address and not ctx.snes_socket and not ctx.snes_connect_task:
ctx.snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_reconnect_address), name="SNES Connect")
if not ctx.snes_socket and not task_alive(ctx.snes_connect_task):
address = ctx.snes_reconnect_address if ctx.snes_reconnect_address else ctx.snes_address
ctx.snes_connect_task = asyncio.create_task(snes_connect(ctx, address), name="SNES Connect")
async def snes_recv_loop(ctx: SNIContext) -> None:

View File

@@ -10,6 +10,8 @@ import re
import sys
import typing
import queue
import zipfile
import io
from pathlib import Path
# CommonClient import first to trigger ModuleUpdater
@@ -50,9 +52,9 @@ class StarcraftClientProcessor(ClientCommandProcessor):
"""Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal"""
options = difficulty.split()
num_options = len(options)
difficulty_choice = options[0].lower()
if num_options > 0:
difficulty_choice = options[0].lower()
if difficulty_choice == "casual":
self.ctx.difficulty_override = 0
elif difficulty_choice == "normal":
@@ -69,7 +71,11 @@ class StarcraftClientProcessor(ClientCommandProcessor):
return True
else:
self.output("Difficulty needs to be specified in the command.")
if self.ctx.difficulty == -1:
self.output("Please connect to a seed before checking difficulty.")
else:
self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][self.ctx.difficulty])
self.output("To change the difficulty, add the name of the difficulty after the command.")
return False
def _cmd_disable_mission_check(self) -> bool:
@@ -120,9 +126,9 @@ class StarcraftClientProcessor(ClientCommandProcessor):
sc2_logger.warning("When using set_path, you must type the path to your SC2 install directory.")
return False
def _cmd_download_data(self, force: bool = False) -> bool:
def _cmd_download_data(self) -> bool:
"""Download the most recent release of the necessary files for playing SC2 with
Archipelago. force should be True or False. force=True will overwrite your files."""
Archipelago. Will overwrite existing files."""
if "SC2PATH" not in os.environ:
check_game_install_path()
@@ -132,11 +138,11 @@ class StarcraftClientProcessor(ClientCommandProcessor):
else:
current_ver = None
tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData', current_version=current_ver, force_download=force)
tempzip, version = download_latest_release_zip('TheCondor07', 'Starcraft2ArchipelagoData',
current_version=current_ver, force_download=True)
if tempzip != '':
try:
import zipfile
zipfile.ZipFile(tempzip).extractall(path=os.environ["SC2PATH"])
sc2_logger.info(f"Download complete. Version {version} installed.")
with open(os.environ["SC2PATH"]+"ArchipelagoSC2Version.txt", "w") as f:
@@ -195,12 +201,16 @@ class SC2Context(CommonContext):
self.build_location_to_mission_mapping()
# Looks for the required maps and mods for SC2. Runs check_game_install_path.
is_mod_installed_correctly()
maps_present = is_mod_installed_correctly()
if os.path.exists(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt"):
with open(os.environ["SC2PATH"] + "ArchipelagoSC2Version.txt", "r") as f:
current_ver = f.read()
if is_mod_update_available("TheCondor07", "Starcraft2ArchipelagoData", current_ver):
sc2_logger.info("NOTICE: Update for required files found. Run /download_data to install.")
elif maps_present:
sc2_logger.warning("NOTICE: Your map files may be outdated (version number not found). "
"Run /download_data to update them.")
def on_print_json(self, args: dict):
# goes to this world
@@ -639,6 +649,13 @@ def request_unfinished_missions(ctx: SC2Context):
_, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks)
# Removing All-In from location pool
final_mission = lookup_id_to_mission[ctx.final_mission]
if final_mission in unfinished_missions.keys():
message = f"Final Mission Available: {final_mission}[{ctx.final_mission}]\n" + message
if unfinished_missions[final_mission] == -1:
unfinished_missions.pop(final_mission)
message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " +
mark_up_objectives(
f"[{len(unfinished_missions[mission])}/"
@@ -996,7 +1013,7 @@ def download_latest_release_zip(owner: str, repo: str, current_version: str = No
download_url = r1.json()["assets"][0]["browser_download_url"]
r2 = requests.get(download_url, headers=headers)
if r2.status_code == 200:
if r2.status_code == 200 and zipfile.is_zipfile(io.BytesIO(r2.content)):
with open(f"{repo}.zip", "wb") as fh:
fh.write(r2.content)
sc2_logger.info(f"Successfully downloaded {repo}.zip.")

132
Utils.py
View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import json
import typing
import builtins
import os
@@ -12,7 +13,7 @@ import io
import collections
import importlib
import logging
from typing import BinaryIO, ClassVar, Coroutine, Optional, Set
from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union
from yaml import load, load_all, dump, SafeLoader
@@ -38,7 +39,7 @@ class Version(typing.NamedTuple):
build: int
__version__ = "0.3.6"
__version__ = "0.4.0"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -87,7 +88,10 @@ def is_frozen() -> bool:
def local_path(*path: str) -> str:
"""Returns path to a file in the local Archipelago installation or source."""
"""
Returns path to a file in the local Archipelago installation or source.
This might be read-only and user_path should be used instead for ROMs, configuration, etc.
"""
if hasattr(local_path, 'cached_path'):
pass
elif is_frozen():
@@ -99,7 +103,7 @@ def local_path(*path: str) -> str:
local_path.cached_path = os.path.dirname(os.path.abspath(sys.argv[0]))
else:
import __main__
if hasattr(__main__, "__file__"):
if hasattr(__main__, "__file__") and os.path.isfile(__main__.__file__):
# we are running in a normal Python environment
local_path.cached_path = os.path.dirname(os.path.abspath(__main__.__file__))
else:
@@ -142,6 +146,17 @@ def user_path(*path: str) -> str:
return os.path.join(user_path.cached_path, *path)
def cache_path(*path: str) -> str:
"""Returns path to a file in the user's Archipelago cache directory."""
if hasattr(cache_path, "cached_path"):
pass
else:
import platformdirs
cache_path.cached_path = platformdirs.user_cache_dir("Archipelago", False)
return os.path.join(cache_path.cached_path, *path)
def output_path(*path: str) -> str:
if hasattr(output_path, 'cached_path'):
return os.path.join(output_path.cached_path, *path)
@@ -195,11 +210,11 @@ def get_public_ipv4() -> str:
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx).read().decode("utf8").strip()
ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip()
except Exception as e:
# noinspection PyBroadException
try:
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip()
ip = urllib.request.urlopen("https://v4.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
except Exception:
logging.exception(e)
pass # we could be offline, in a local game, so no point in erroring out
@@ -213,7 +228,7 @@ def get_public_ipv6() -> str:
ip = socket.gethostbyname(socket.gethostname())
ctx = get_cert_none_ssl_context()
try:
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx).read().decode("utf8").strip()
ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip()
except Exception as e:
logging.exception(e)
pass # we could be offline, in a local game, or ipv6 may not be available
@@ -236,7 +251,7 @@ def get_default_options() -> OptionsType:
"bridge_chat_out": True,
},
"sni_options": {
"sni": "SNI",
"sni_path": "SNI",
"snes_rom_start": True,
},
"sm_options": {
@@ -248,6 +263,9 @@ def get_default_options() -> OptionsType:
"lttp_options": {
"rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc",
},
"ladx_options": {
"rom_file": "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc",
},
"server_options": {
"host": None,
"port": 38281,
@@ -260,7 +278,7 @@ def get_default_options() -> OptionsType:
"disable_item_cheat": False,
"location_check_points": 1,
"hint_cost": 10,
"forfeit_mode": "goal",
"release_mode": "goal",
"collect_mode": "disabled",
"remaining_mode": "goal",
"auto_shutdown": 0,
@@ -268,13 +286,12 @@ def get_default_options() -> OptionsType:
"log_network": 0
},
"generator": {
"teams": 1,
"enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"),
"player_files_path": "Players",
"players": 0,
"weights_file_path": "weights.yaml",
"meta_file_path": "meta.yaml",
"spoiler": 2,
"spoiler": 3,
"glitch_triforce_room": 1,
"race": 0,
"plando_options": "bosses",
@@ -286,6 +303,7 @@ def get_default_options() -> OptionsType:
},
"oot_options": {
"rom_file": "The Legend of Zelda - Ocarina of Time.z64",
"rom_start": True
},
"dkc3_options": {
"rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc",
@@ -303,9 +321,28 @@ def get_default_options() -> OptionsType:
"red_rom_file": "Pokemon Red (UE) [S][!].gb",
"blue_rom_file": "Pokemon Blue (UE) [S][!].gb",
"rom_start": True
}
},
"ffr_options": {
"display_msgs": True,
},
"lufia2ac_options": {
"rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc",
},
"tloz_options": {
"rom_file": "Legend of Zelda, The (U) (PRG0) [!].nes",
"rom_start": True,
"display_msgs": True,
},
"wargroove_options": {
"root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove"
},
"adventure_options": {
"rom_file": "ADVNTURE.BIN",
"display_msgs": True,
"rom_start": True,
"rom_args": ""
},
}
return options
@@ -372,6 +409,45 @@ def persistent_load() -> typing.Dict[str, dict]:
return storage
def get_file_safe_name(name: str) -> str:
return "".join(c for c in name if c not in '<>:"/\\|?*')
def load_data_package_for_checksum(game: str, checksum: typing.Optional[str]) -> Dict[str, Any]:
if checksum and game:
if checksum != get_file_safe_name(checksum):
raise ValueError(f"Bad symbols in checksum: {checksum}")
path = cache_path("datapackage", get_file_safe_name(game), f"{checksum}.json")
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8-sig") as f:
return json.load(f)
except Exception as e:
logging.debug(f"Could not load data package: {e}")
# fall back to old cache
cache = persistent_load().get("datapackage", {}).get("games", {}).get(game, {})
if cache.get("checksum") == checksum:
return cache
# cache does not match
return {}
def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> None:
checksum = data.get("checksum")
if checksum and game:
if checksum != get_file_safe_name(checksum):
raise ValueError(f"Bad symbols in checksum: {checksum}")
game_folder = cache_path("datapackage", get_file_safe_name(game))
os.makedirs(game_folder, exist_ok=True)
try:
with open(os.path.join(game_folder, f"{checksum}.json"), "w", encoding="utf-8-sig") as f:
json.dump(data, f, ensure_ascii=False, separators=(",", ":"))
except Exception as e:
logging.debug(f"Could not store data package: {e}")
def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]:
adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {})
return adjuster_settings
@@ -452,6 +528,7 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log
def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w",
log_format: str = "[%(name)s at %(asctime)s]: %(message)s",
exception_logger: typing.Optional[str] = None):
import datetime
loglevel: int = loglevel_mapping.get(loglevel, loglevel)
log_folder = user_path("logs")
os.makedirs(log_folder, exist_ok=True)
@@ -460,6 +537,8 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
root_logger.removeHandler(handler)
handler.close()
root_logger.setLevel(loglevel)
if "a" not in write_mode:
name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}"
file_handler = logging.FileHandler(
os.path.join(log_folder, f"{name}.txt"),
write_mode,
@@ -487,7 +566,25 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
sys.excepthook = handle_exception
logging.info(f"Archipelago ({__version__}) logging initialized.")
def _cleanup():
for file in os.scandir(log_folder):
if file.name.endswith(".txt"):
last_change = datetime.datetime.fromtimestamp(file.stat().st_mtime)
if datetime.datetime.now() - last_change > datetime.timedelta(days=7):
try:
os.unlink(file.path)
except Exception as e:
logging.exception(e)
else:
logging.debug(f"Deleted old logfile {file.path}")
import threading
threading.Thread(target=_cleanup, name="LogCleaner").start()
import platform
logging.info(
f"Archipelago ({__version__}) logging initialized"
f" on {platform.platform()}"
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
)
def stream_input(stream, queue):
@@ -636,7 +733,10 @@ def messagebox(title: str, text: str, error: bool = False) -> None:
def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset(("a", "the"))):
"""Sorts a sequence of text ignoring typical articles like "a" or "the" in the beginning."""
def sorter(element: str) -> str:
def sorter(element: Union[str, Dict[str, Any]]) -> str:
if (not isinstance(element, str)):
element = element["title"]
parts = element.split(maxsplit=1)
if parts[0].lower() in ignore:
return parts[1].lower()
@@ -656,7 +756,7 @@ def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray:
_faf_tasks: "Set[asyncio.Task[None]]" = set()
def async_start(co: Coroutine[None, None, None], name: Optional[str] = None) -> None:
def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None:
"""
Use this to start a task when you don't keep a reference to it or immediately await it,
to prevent early garbage collection. "fire-and-forget"

445
WargrooveClient.py Normal file
View File

@@ -0,0 +1,445 @@
from __future__ import annotations
import atexit
import os
import sys
import asyncio
import random
import shutil
from typing import Tuple, List, Iterable, Dict
from worlds.wargroove import WargrooveWorld
from worlds.wargroove.Items import item_table, faction_table, CommanderData, ItemData
import ModuleUpdate
ModuleUpdate.update()
import Utils
import json
import logging
if __name__ == "__main__":
Utils.init_logging("WargrooveClient", exception_logger="Client")
from NetUtils import NetworkItem, ClientStatus
from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \
CommonContext, server_loop
wg_logger = logging.getLogger("WG")
class WargrooveClientCommandProcessor(ClientCommandProcessor):
def _cmd_resync(self):
"""Manually trigger a resync."""
self.output(f"Syncing items.")
self.ctx.syncing = True
def _cmd_commander(self, *commander_name: Iterable[str]):
"""Set the current commander to the given commander."""
if commander_name:
self.ctx.set_commander(' '.join(commander_name))
else:
if self.ctx.can_choose_commander:
commanders = self.ctx.get_commanders()
wg_logger.info('Unlocked commanders: ' +
', '.join((commander.name for commander, unlocked in commanders if unlocked)))
wg_logger.info('Locked commanders: ' +
', '.join((commander.name for commander, unlocked in commanders if not unlocked)))
else:
wg_logger.error('Cannot set commanders in this game mode.')
class WargrooveContext(CommonContext):
command_processor: int = WargrooveClientCommandProcessor
game = "Wargroove"
items_handling = 0b111 # full remote
current_commander: CommanderData = faction_table["Starter"][0]
can_choose_commander: bool = False
commander_defense_boost_multiplier: int = 0
income_boost_multiplier: int = 0
starting_groove_multiplier: float
faction_item_ids = {
'Starter': 0,
'Cherrystone': 52025,
'Felheim': 52026,
'Floran': 52027,
'Heavensong': 52028,
'Requiem': 52029,
'Outlaw': 52030
}
buff_item_ids = {
'Income Boost': 52023,
'Commander Defense Boost': 52024,
}
def __init__(self, server_address, password):
super(WargrooveContext, self).__init__(server_address, password)
self.send_index: int = 0
self.syncing = False
self.awaiting_bridge = False
# self.game_communication_path: files go in this path to pass data between us and the actual game
if "appdata" in os.environ:
options = Utils.get_options()
root_directory = os.path.join(options["wargroove_options"]["root_directory"])
data_directory = os.path.join("lib", "worlds", "wargroove", "data")
dev_data_directory = os.path.join("worlds", "wargroove", "data")
appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove"))
if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")):
print_error_and_close("WargrooveClient couldn't find wargroove64.exe. "
"Unable to infer required game_communication_path")
self.game_communication_path = os.path.join(root_directory, "AP")
if not os.path.exists(self.game_communication_path):
os.makedirs(self.game_communication_path)
self.remove_communication_files()
atexit.register(self.remove_communication_files)
if not os.path.isdir(appdata_wargroove):
print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!"
"Boot Wargroove and then close it to attempt to fix this error")
if not os.path.isdir(data_directory):
data_directory = dev_data_directory
if not os.path.isdir(data_directory):
print_error_and_close("WargrooveClient couldn't find Wargoove mod and save files in install!")
shutil.copytree(data_directory, appdata_wargroove, dirs_exist_ok=True)
else:
print_error_and_close("WargrooveClient couldn't detect system type. "
"Unable to infer required game_communication_path")
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(WargrooveContext, self).server_auth(password_requested)
await self.get_username()
await self.send_connect()
async def connection_closed(self):
await super(WargrooveContext, self).connection_closed()
self.remove_communication_files()
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
async def shutdown(self):
await super(WargrooveContext, self).shutdown()
self.remove_communication_files()
def remove_communication_files(self):
for root, dirs, files in os.walk(self.game_communication_path):
for file in files:
os.remove(root + "/" + file)
def on_package(self, cmd: str, args: dict):
if cmd in {"Connected"}:
filename = f"AP_settings.json"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
slot_data = args["slot_data"]
json.dump(args["slot_data"], f)
self.can_choose_commander = slot_data["can_choose_commander"]
print('can choose commander:', self.can_choose_commander)
self.starting_groove_multiplier = slot_data["starting_groove_multiplier"]
self.income_boost_multiplier = slot_data["income_boost"]
self.commander_defense_boost_multiplier = slot_data["commander_defense_boost"]
f.close()
for ss in self.checked_locations:
filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.close()
self.update_commander_data()
self.ui.update_tracker()
random.seed(self.seed_name + str(self.slot))
# Our indexes start at 1 and we have 24 levels
for i in range(1, 25):
filename = f"seed{i}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.write(str(random.randint(0, 4294967295)))
f.close()
if cmd in {"RoomInfo"}:
self.seed_name = args["seed_name"]
if cmd in {"ReceivedItems"}:
received_ids = [item.item for item in self.items_received]
for network_item in self.items_received:
filename = f"AP_{str(network_item.item)}.item"
path = os.path.join(self.game_communication_path, filename)
# Newly-obtained items
if not os.path.isfile(path):
open(path, 'w').close()
# Announcing commander unlocks
item_name = self.item_names[network_item.item]
if item_name in faction_table.keys():
for commander in faction_table[item_name]:
logger.info(f"{commander.name} has been unlocked!")
with open(path, 'w') as f:
item_count = received_ids.count(network_item.item)
if self.buff_item_ids["Income Boost"] == network_item.item:
f.write(f"{item_count * self.income_boost_multiplier}")
elif self.buff_item_ids["Commander Defense Boost"] == network_item.item:
f.write(f"{item_count * self.commander_defense_boost_multiplier}")
else:
f.write(f"{item_count}")
f.close()
print_filename = f"AP_{str(network_item.item)}.item.print"
print_path = os.path.join(self.game_communication_path, print_filename)
if not os.path.isfile(print_path):
open(print_path, 'w').close()
with open(print_path, 'w') as f:
f.write("Received " +
self.item_names[network_item.item] +
" from " +
self.player_names[network_item.player])
f.close()
self.update_commander_data()
self.ui.update_tracker()
if cmd in {"RoomUpdate"}:
if "checked_locations" in args:
for ss in self.checked_locations:
filename = f"send{ss}"
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
f.close()
def run_gui(self):
"""Import kivy UI system and start running it as self.ui_task."""
from kvui import GameManager, HoverBehavior, ServerToolTip
from kivy.uix.tabbedpanel import TabbedPanelItem
from kivy.lang import Builder
from kivy.uix.button import Button
from kivy.uix.togglebutton import ToggleButton
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.gridlayout import GridLayout
from kivy.uix.image import AsyncImage, Image
from kivy.uix.stacklayout import StackLayout
from kivy.uix.label import Label
from kivy.properties import ColorProperty
from kivy.uix.image import Image
import pkgutil
class TrackerLayout(BoxLayout):
pass
class CommanderSelect(BoxLayout):
pass
class CommanderButton(ToggleButton):
pass
class FactionBox(BoxLayout):
pass
class CommanderGroup(BoxLayout):
pass
class ItemTracker(BoxLayout):
pass
class ItemLabel(Label):
pass
class WargrooveManager(GameManager):
logging_pairs = [
("Client", "Archipelago"),
("WG", "WG Console"),
]
base_title = "Archipelago Wargroove Client"
ctx: WargrooveContext
unit_tracker: ItemTracker
trigger_tracker: BoxLayout
boost_tracker: BoxLayout
commander_buttons: Dict[int, List[CommanderButton]]
tracker_items = {
"Swordsman": ItemData(None, "Unit", False),
"Dog": ItemData(None, "Unit", False),
**item_table
}
def build(self):
container = super().build()
panel = TabbedPanelItem(text="Wargroove")
panel.content = self.build_tracker()
self.tabs.add_widget(panel)
return container
def build_tracker(self) -> TrackerLayout:
try:
tracker = TrackerLayout(orientation="horizontal")
commander_select = CommanderSelect(orientation="vertical")
self.commander_buttons = {}
for faction, commanders in faction_table.items():
faction_box = FactionBox(size_hint=(None, None), width=100 * len(commanders), height=70)
commander_group = CommanderGroup()
commander_buttons = []
for commander in commanders:
commander_button = CommanderButton(text=commander.name, group="commanders")
if faction == "Starter":
commander_button.disabled = False
commander_button.bind(on_press=lambda instance: self.ctx.set_commander(instance.text))
commander_buttons.append(commander_button)
commander_group.add_widget(commander_button)
self.commander_buttons[faction] = commander_buttons
faction_box.add_widget(Label(text=faction, size_hint_x=None, pos_hint={'left': 1}, size_hint_y=None, height=10))
faction_box.add_widget(commander_group)
commander_select.add_widget(faction_box)
item_tracker = ItemTracker(padding=[0,20])
self.unit_tracker = BoxLayout(orientation="vertical")
other_tracker = BoxLayout(orientation="vertical")
self.trigger_tracker = BoxLayout(orientation="vertical")
self.boost_tracker = BoxLayout(orientation="vertical")
other_tracker.add_widget(self.trigger_tracker)
other_tracker.add_widget(self.boost_tracker)
item_tracker.add_widget(self.unit_tracker)
item_tracker.add_widget(other_tracker)
tracker.add_widget(commander_select)
tracker.add_widget(item_tracker)
self.update_tracker()
return tracker
except Exception as e:
print(e)
def update_tracker(self):
received_ids = [item.item for item in self.ctx.items_received]
for faction, item_id in self.ctx.faction_item_ids.items():
for commander_button in self.commander_buttons[faction]:
commander_button.disabled = not (faction == "Starter" or item_id in received_ids)
self.unit_tracker.clear_widgets()
self.trigger_tracker.clear_widgets()
for name, item in self.tracker_items.items():
if item.type in ("Unit", "Trigger"):
status_color = (1, 1, 1, 1) if item.code is None or item.code in received_ids else (0.6, 0.2, 0.2, 1)
label = ItemLabel(text=name, color=status_color)
if item.type == "Unit":
self.unit_tracker.add_widget(label)
else:
self.trigger_tracker.add_widget(label)
self.boost_tracker.clear_widgets()
extra_income = received_ids.count(52023) * self.ctx.income_boost_multiplier
extra_defense = received_ids.count(52024) * self.ctx.commander_defense_boost_multiplier
income_boost = ItemLabel(text="Extra Income: " + str(extra_income))
defense_boost = ItemLabel(text="Comm Defense: " + str(100 + extra_defense))
self.boost_tracker.add_widget(income_boost)
self.boost_tracker.add_widget(defense_boost)
self.ui = WargrooveManager(self)
data = pkgutil.get_data(WargrooveWorld.__module__, "Wargroove.kv").decode()
Builder.load_string(data)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def update_commander_data(self):
if self.can_choose_commander:
faction_items = 0
faction_item_names = [faction + ' Commanders' for faction in faction_table.keys()]
for network_item in self.items_received:
if self.item_names[network_item.item] in faction_item_names:
faction_items += 1
starting_groove = (faction_items - 1) * self.starting_groove_multiplier
# Must be an integer larger than 0
starting_groove = int(max(starting_groove, 0))
data = {
"commander": self.current_commander.internal_name,
"starting_groove": starting_groove
}
else:
data = {
"commander": "seed",
"starting_groove": 0
}
filename = 'commander.json'
with open(os.path.join(self.game_communication_path, filename), 'w') as f:
json.dump(data, f)
if self.ui:
self.ui.update_tracker()
def set_commander(self, commander_name: str) -> bool:
"""Sets the current commander to the given one, if possible"""
if not self.can_choose_commander:
wg_logger.error("Cannot set commanders in this game mode.")
return
match_name = commander_name.lower()
for commander, unlocked in self.get_commanders():
if commander.name.lower() == match_name or commander.alt_name and commander.alt_name.lower() == match_name:
if unlocked:
self.current_commander = commander
self.syncing = True
wg_logger.info(f"Commander set to {commander.name}.")
self.update_commander_data()
return True
else:
wg_logger.error(f"Commander {commander.name} has not been unlocked.")
return False
else:
wg_logger.error(f"{commander_name} is not a recognized Wargroove commander.")
def get_commanders(self) -> List[Tuple[CommanderData, bool]]:
"""Gets a list of commanders with their unlocked status"""
commanders = []
received_ids = [item.item for item in self.items_received]
for faction in faction_table.keys():
unlocked = faction == 'Starter' or self.faction_item_ids[faction] in received_ids
commanders += [(commander, unlocked) for commander in faction_table[faction]]
return commanders
async def game_watcher(ctx: WargrooveContext):
from worlds.wargroove.Locations import location_table
while not ctx.exit_event.is_set():
if ctx.syncing == True:
sync_msg = [{'cmd': 'Sync'}]
if ctx.locations_checked:
sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)})
await ctx.send_msgs(sync_msg)
ctx.syncing = False
sending = []
victory = False
for root, dirs, files in os.walk(ctx.game_communication_path):
for file in files:
if file.find("send") > -1:
st = file.split("send", -1)[1]
sending = sending+[(int(st))]
if file.find("victory") > -1:
victory = True
ctx.locations_checked = sending
message = [{"cmd": 'LocationChecks', "locations": sending}]
await ctx.send_msgs(message)
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
await asyncio.sleep(0.1)
def print_error_and_close(msg):
logger.error("Error: " + msg)
Utils.messagebox("Error", msg, error=True)
sys.exit(1)
if __name__ == '__main__':
async def main(args):
ctx = WargrooveContext(args.connect, args.password)
ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop")
if gui_enabled:
ctx.run_gui()
ctx.run_cli()
progression_watcher = asyncio.create_task(
game_watcher(ctx), name="WargrooveProgressionWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
await progression_watcher
await ctx.shutdown()
import colorama
parser = get_base_parser(description="Wargroove Client, for text interfacing.")
args, rest = parser.parse_known_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -29,10 +29,15 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
def get_app():
register()
app = raw_app
if os.path.exists(configpath):
if os.path.exists(configpath) and not app.config["TESTING"]:
import yaml
app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}")
if not app.config["HOST_ADDRESS"]:
logging.info("Getting public IP, as HOST_ADDRESS is empty.")
app.config["HOST_ADDRESS"] = Utils.get_public_ipv4()
logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}")
db.bind(**app.config["PONY"])
db.generate_mapping(create_tables=True)
return app

View File

@@ -24,6 +24,8 @@ app.jinja_env.filters['all'] = all
app.config["SELFHOST"] = True # application process is in charge of running the websites
app.config["GENERATORS"] = 8 # maximum concurrent world gens
app.config["SELFLAUNCH"] = True # application process is in charge of launching Rooms.
app.config["SELFLAUNCHCERT"] = None # can point to a SSL Certificate to encrypt Room websocket connections
app.config["SELFLAUNCHKEY"] = None # can point to a SSL Certificate Key to encrypt Room websocket connections
app.config["SELFGEN"] = True # application process is in charge of scheduling Generations.
app.config["DEBUG"] = False
app.config["PORT"] = 80
@@ -49,7 +51,7 @@ app.config["PONY"] = {
app.config["MAX_ROLL"] = 20
app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache"
app.config["JSON_AS_ASCII"] = False
app.config["PATCH_TARGET"] = "archipelago.gg"
app.config["HOST_ADDRESS"] = ""
cache = Cache(app)
Compress(app)

View File

@@ -40,9 +40,19 @@ def get_datapackage():
@api_endpoints.route('/datapackage_version')
@cache.cached()
def get_datapackage_versions():
from worlds import network_data_package, AutoWorldRegister
from worlds import AutoWorldRegister
version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()}
version_package["version"] = network_data_package["version"]
return version_package
@api_endpoints.route('/datapackage_checksum')
@cache.cached()
def get_datapackage_checksums():
from worlds import network_data_package
version_package = {
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
}
return version_package

View File

@@ -2,7 +2,7 @@ import json
import pickle
from uuid import UUID
from flask import request, session, url_for
from flask import request, session, url_for, Markup
from pony.orm import commit
from WebHostLib import app
@@ -21,13 +21,18 @@ def generate_api():
if 'file' in request.files:
file = request.files['file']
options = get_yaml_data(file)
if type(options) == str:
if isinstance(options, Markup):
return {"text": options.striptags()}, 400
if isinstance(options, str):
return {"text": options}, 400
if "race" in request.form:
race = bool(0 if request.form["race"] in {"false"} else int(request.form["race"]))
meta_options_source = request.form
json_data = request.get_json()
# json_data is optional, we can have it silently fall to None as it used to do.
# See https://flask.palletsprojects.com/en/2.2.x/api/#flask.Request.get_json -> Changelog -> 2.1
json_data = request.get_json(silent=True)
if json_data:
meta_options_source = json_data
if 'weights' in json_data:

View File

@@ -177,6 +177,9 @@ class MultiworldInstance():
with guardian_lock:
multiworlds[self.room_id] = self
self.ponyconfig = config["PONY"]
self.cert = config["SELFLAUNCHCERT"]
self.key = config["SELFLAUNCHKEY"]
self.host = config["HOST_ADDRESS"]
def start(self):
if self.process and self.process.is_alive():
@@ -184,7 +187,8 @@ class MultiworldInstance():
logging.info(f"Spinning up {self.room_id}")
process = multiprocessing.Process(group=None, target=run_server_process,
args=(self.room_id, self.ponyconfig, get_static_server_data()),
args=(self.room_id, self.ponyconfig, get_static_server_data(),
self.cert, self.key, self.host),
name="MultiHost")
process.start()
# bind after start to prevent thread sync issues with guardian.

View File

@@ -1,7 +1,7 @@
import zipfile
from typing import *
from flask import request, flash, redirect, url_for, render_template
from flask import request, flash, redirect, url_for, render_template, Markup
from WebHostLib import app
@@ -12,7 +12,7 @@ def allowed_file(filename):
return filename.endswith(('.txt', ".yaml", ".zip"))
from Generate import roll_settings, PlandoSettings
from Generate import roll_settings, PlandoOptions
from Utils import parse_yamls
@@ -25,7 +25,7 @@ def check():
else:
file = request.files['file']
options = get_yaml_data(file)
if type(options) == str:
if isinstance(options, str):
flash(options)
else:
results, _ = roll_options(options)
@@ -38,7 +38,7 @@ def mysterycheck():
return redirect(url_for("check"), 301)
def get_yaml_data(file) -> Union[Dict[str, str], str]:
def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
options = {}
# if user does not select file, browser also
# submit an empty part without filename
@@ -50,6 +50,10 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
with zipfile.ZipFile(file, 'r') as zfile:
infolist = zfile.infolist()
if any(file.filename.endswith(".archipelago") for file in infolist):
return Markup("Error: Your .zip file contains an .archipelago file. "
'Did you mean to <a href="/uploads">host a game</a>?')
for file in infolist:
if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted."
@@ -65,7 +69,7 @@ def get_yaml_data(file) -> Union[Dict[str, str], str]:
def roll_options(options: Dict[str, Union[dict, str]],
plando_options: Set[str] = frozenset({"bosses", "items", "connections", "texts"})) -> \
Tuple[Dict[str, Union[str, bool]], Dict[str, dict]]:
plando_options = PlandoSettings.from_set(set(plando_options))
plando_options = PlandoOptions.from_set(set(plando_options))
results = {}
rolled_results = {}
for filename, text in options.items():

View File

@@ -10,14 +10,16 @@ import random
import socket
import threading
import time
import typing
import websockets
from pony.orm import db_session, commit, select
from pony.orm import commit, db_session, select
import Utils
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor
from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert
from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless
from .models import Room, Command, db
from .models import Command, GameDataPackage, Room, db
class CustomClientMessageProcessor(ClientMessageProcessor):
@@ -66,7 +68,6 @@ class WebHostContext(Context):
def _load_game_data(self):
for key, value in self.static_server_data.items():
setattr(self, key, value)
self.forced_auto_forfeits = collections.defaultdict(lambda: False, self.forced_auto_forfeits)
self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names)
def listen_to_db_commands(self):
@@ -91,7 +92,20 @@ class WebHostContext(Context):
else:
self.port = get_random_port()
return self._load(self.decompress(room.seed.multidata), True)
multidata = self.decompress(room.seed.multidata)
game_data_packages = {}
for game in list(multidata["datapackage"]):
game_data = multidata["datapackage"][game]
if "checksum" in game_data:
if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]:
# non-custom. remove from multidata
# games package could be dropped from static data once all rooms embed data package
del multidata["datapackage"][game]
else:
data = Utils.restricted_loads(GameDataPackage.get(checksum=game_data["checksum"]).data)
game_data_packages[game] = data
return self._load(multidata, game_data_packages, True)
@db_session
def init_save(self, enabled: bool = True):
@@ -126,21 +140,23 @@ def get_random_port():
def get_static_server_data() -> dict:
import worlds
data = {
"forced_auto_forfeits": {},
"non_hintable_names": {},
"gamespackage": worlds.network_data_package["games"],
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
data["forced_auto_forfeits"][world_name] = world.forced_auto_forfeit
data["non_hintable_names"][world_name] = world.hint_blacklist
return data
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
def run_server_process(room_id, ponyconfig: dict, static_server_data: dict,
cert_file: typing.Optional[str], cert_key_file: typing.Optional[str],
host: str):
# establish DB connection for multidata and multisave
db.bind(**ponyconfig)
db.generate_mapping(check_tables=False)
@@ -150,32 +166,33 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
ctx = WebHostContext(static_server_data)
ctx.load(room_id)
ctx.init_save()
ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None
try:
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None,
ping_interval=None)
ping_interval=None, ssl=ssl_context)
await ctx.server
except Exception: # likely port in use - in windows this is OSError, but I didn't check the others
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None,
ping_interval=None)
ping_interval=None, ssl=ssl_context)
await ctx.server
port = 0
for wssocket in ctx.server.ws_server.sockets:
socketname = wssocket.getsockname()
if wssocket.family == socket.AF_INET6:
logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}')
# Prefer IPv4, as most users seem to not have working ipv6 support
if not port:
port = socketname[1]
elif wssocket.family == socket.AF_INET:
logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}')
port = socketname[1]
if port:
logging.info(f'Hosting game at {host}:{port}')
with db_session:
room = Room.get(id=ctx.room_id)
room.last_port = port
else:
logging.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
@@ -186,6 +203,11 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict):
with Locker(room_id):
try:
asyncio.run(main())
except KeyboardInterrupt:
with db_session:
room = Room.get(id=room_id)
# ensure the Room does not spin up again on its own, minute of safety buffer
room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout)
except:
with db_session:
room = Room.get(id=room_id)

View File

@@ -26,7 +26,7 @@ def download_patch(room_id, patch_id):
with zipfile.ZipFile(filelike, "a") as zf:
with zf.open("archipelago.json", "r") as f:
manifest = json.load(f)
manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None
manifest["server"] = f"{app.config['HOST_ADDRESS']}:{last_port}" if last_port else None
with zipfile.ZipFile(new_file, "w") as new_zip:
for file in zf.infolist():
if file.filename == "archipelago.json":
@@ -64,7 +64,7 @@ def download_slot_file(room_id, player_id: int):
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['PATCH_TARGET'], port=room.last_port)
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":
with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf:
@@ -72,7 +72,14 @@ def download_slot_file(room_id, player_id: int):
if name.endswith("info.json"):
fname = name.rsplit("/", 1)[0] + ".zip"
elif slot_data.game == "Ocarina of Time":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
stream = io.BytesIO(slot_data.data)
if zipfile.is_zipfile(stream):
with zipfile.ZipFile(stream) as zf:
for name in zf.namelist():
if name.endswith(".zpf"):
fname = name.rsplit(".", 1)[0] + ".apz5"
else: # pre-ootr-7.0 support
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apz5"
elif slot_data.game == "VVVVVV":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apv6"
elif slot_data.game == "Zillion":
@@ -81,6 +88,8 @@ def download_slot_file(room_id, player_id: int):
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex"
elif slot_data.game == "Dark Souls III":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json"
elif slot_data.game == "Kingdom Hearts 2":
fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip"
else:
return "Game download not supported."
return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname)

View File

@@ -12,7 +12,7 @@ from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import commit, db_session
from BaseClasses import seeddigits, get_seed
from Generate import handle_name, PlandoSettings
from Generate import handle_name, PlandoOptions
from Main import main as ERmain
from Utils import __version__
from WebHostLib import app
@@ -33,7 +33,7 @@ def get_meta(options_source: dict) -> dict:
server_options = {
"hint_cost": int(options_source.get("hint_cost", 10)),
"forfeit_mode": options_source.get("forfeit_mode", "goal"),
"release_mode": options_source.get("release_mode", "goal"),
"remaining_mode": options_source.get("remaining_mode", "disabled"),
"collect_mode": options_source.get("collect_mode", "disabled"),
"item_cheat": bool(int(options_source.get("item_cheat", 1))),
@@ -52,7 +52,7 @@ def generate(race=False):
else:
file = request.files['file']
options = get_yaml_data(file)
if type(options) == str:
if isinstance(options, str):
flash(options)
else:
meta = get_meta(request.form)
@@ -92,7 +92,7 @@ def generate(race=False):
return render_template("generate.html", race=race, version=__version__)
def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=None, sid=None):
if not meta:
meta: Dict[str, Any] = {}
@@ -114,12 +114,12 @@ def gen_game(gen_options, meta: Optional[Dict[str, Any]] = None, owner=None, sid
erargs = parse_arguments(['--multi', str(playercount)])
erargs.seed = seed
erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery
erargs.spoiler = 0 if race else 2
erargs.spoiler = 0 if race else 3
erargs.race = race
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options",
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
name_counter = Counter()

View File

@@ -1,5 +1,6 @@
import datetime
import os
from typing import List, Dict, Union
import jinja2.exceptions
from flask import request, redirect, url_for, render_template, Response, session, abort, send_from_directory
@@ -69,10 +70,6 @@ def tutorial(game, file, lang):
@app.route('/tutorial/')
def tutorial_landing():
worlds = {}
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
worlds[game] = world
return render_template("tutorialLanding.html")
@@ -167,8 +164,9 @@ def get_datapackage():
@app.route('/index')
@app.route('/sitemap')
def get_sitemap():
available_games = []
available_games: List[Dict[str, Union[str, bool]]] = []
for game, world in AutoWorldRegister.world_types.items():
if not world.hidden:
available_games.append(game)
has_settings: bool = isinstance(world.web.settings_page, bool) and world.web.settings_page
available_games.append({ 'title': game, 'has_settings': has_settings })
return render_template("siteMap.html", games=available_games)

View File

@@ -56,3 +56,8 @@ class Generation(db.Entity):
options = Required(buffer, lazy=True)
meta = Required(LongStr, default=lambda: "{\"race\": false}")
state = Required(int, default=0, index=True)
class GameDataPackage(db.Entity):
checksum = PrimaryKey(str)
data = Required(bytes)

View File

@@ -11,7 +11,7 @@ from Utils import __version__, local_path
from worlds.AutoWorld import AutoWorldRegister
handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints",
"exclude_locations"}
"exclude_locations", "priority_locations"}
def create():
@@ -71,7 +71,7 @@ def create():
del file_data
with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f:
with open(os.path.join(target_folder, "configs", game_name + ".yaml"), "w", encoding="utf-8") as f:
f.write(res)
# Generate JSON files for player-settings pages
@@ -88,7 +88,7 @@ def create():
if option_name in handled_in_js:
pass
elif option.options:
elif issubclass(option, Options.Choice) or issubclass(option, Options.Toggle):
game_options[option_name] = this_option = {
"type": "select",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
@@ -98,15 +98,15 @@ def create():
}
for sub_option_id, sub_option_name in option.name_lookup.items():
this_option["options"].append({
"name": option.get_option_name(sub_option_id),
"value": sub_option_name,
})
if sub_option_name != "random":
this_option["options"].append({
"name": option.get_option_name(sub_option_id),
"value": sub_option_name,
})
if sub_option_id == option.default:
this_option["defaultValue"] = sub_option_name
if option.default == "random":
if not this_option["defaultValue"]:
this_option["defaultValue"] = "random"
elif issubclass(option, Options.Range):
@@ -126,21 +126,21 @@ def create():
for key, val in option.special_range_names.items():
game_options[option_name]["value_names"][key] = val
elif getattr(option, "verify_item_name", False):
elif issubclass(option, Options.ItemSet):
game_options[option_name] = {
"type": "items-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
}
elif getattr(option, "verify_location_name", False):
elif issubclass(option, Options.LocationSet):
game_options[option_name] = {
"type": "locations-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
}
elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet):
elif issubclass(option, Options.VerifyKeys):
if option.valid_keys:
game_options[option_name] = {
"type": "custom-list",
@@ -160,6 +160,14 @@ def create():
json.dump(player_settings, f, indent=2, separators=(',', ': '))
if not world.hidden and world.web.settings_page is True:
# Add the random option to Choice, TextChoice, and Toggle settings
for option in game_options.values():
if option["type"] == "select":
option["options"].append({"name": "Random", "value": "random"})
if not option["defaultValue"]:
option["defaultValue"] = "random"
weighted_settings["baseOptions"]["game"][game_name] = 0
weighted_settings["games"][game_name] = {}
weighted_settings["games"][game_name]["gameSettings"] = game_options

View File

@@ -1,7 +1,7 @@
flask>=2.2.2
flask>=2.2.3
pony>=0.7.16
waitress>=2.1.2
Flask-Caching>=2.0.1
Flask-Caching>=2.0.2
Flask-Compress>=1.13
Flask-Limiter>=2.7.0
bokeh>=3.0.0
Flask-Limiter>=3.3.0
bokeh>=3.1.0

View File

@@ -4,6 +4,7 @@ window.addEventListener('load', () => {
"ordering": true,
"info": false,
"dom": "t",
"stateSave": true,
});
console.log(tables);
});

View File

@@ -0,0 +1,40 @@
window.addEventListener('load', () => {
// Mobile menu handling
const menuButton = document.getElementById('base-header-mobile-menu-button');
const mobileMenu = document.getElementById('base-header-mobile-menu');
menuButton.addEventListener('click', (evt) => {
evt.preventDefault();
evt.stopPropagation();
if (!mobileMenu.style.display || mobileMenu.style.display === 'none') {
return mobileMenu.style.display = 'flex';
}
mobileMenu.style.display = 'none';
});
window.addEventListener('resize', () => {
mobileMenu.style.display = 'none';
});
// Popover handling
const popoverText = document.getElementById('base-header-popover-text');
const popoverMenu = document.getElementById('base-header-popover-menu');
popoverText.addEventListener('click', (evt) => {
evt.preventDefault();
evt.stopPropagation();
if (!popoverMenu.style.display || popoverMenu.style.display === 'none') {
return popoverMenu.style.display = 'flex';
}
popoverMenu.style.display = 'none';
});
document.body.addEventListener('click', () => {
mobileMenu.style.display = 'none';
popoverMenu.style.display = 'none';
});
});

View File

@@ -0,0 +1,49 @@
window.addEventListener('load', () => {
// Reload tracker every 60 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();
}, 60000)
// 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

@@ -20,7 +20,7 @@ comfortable exploiting certain glitches in the game.
## What is a multi-world?
While a randomizer shuffles a game, a multi-world randomizer shuffles that game for multiple players. For example, in a
two player multi-world, players A and B each get their own randomized version of a game, called seeds. In each player's
two player multi-world, players A and B each get their own randomized version of a game, called a world. In each player's
game, they may find items which belong to the other player. If player A finds an item which belongs to player B, the
item will be sent to player B's world over the internet.
@@ -29,7 +29,7 @@ their game.
## What happens if a person has to leave early?
If a player must leave early, they can use Archipelago's forfeit system. When a player forfeits their game, all the
If a player must leave early, they can use Archipelago's release system. When a player releases their game, all the
items in that game which belong to other players are sent out automatically, so other players can continue to play.
## What does multi-game mean?

View File

@@ -0,0 +1,6 @@
window.addEventListener('load', () => {
$(".table-wrapper").scrollsync({
y_sync: true,
x_sync: true
});
});

View File

@@ -205,6 +205,11 @@ const buildOptionsTable = (settings, romOpts = false) => {
let presetOption = document.createElement('option');
presetOption.innerText = presetName;
presetOption.value = settings[setting].value_names[presetName];
const words = presetOption.innerText.split("_");
for (let i = 0; i < words.length; i++) {
words[i] = words[i][0].toUpperCase() + words[i].substring(1);
}
presetOption.innerText = words.join(" ");
specialRangeSelect.appendChild(presetOption);
});
let customOption = document.createElement('option');

View File

@@ -0,0 +1,49 @@
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,5 +1,7 @@
const adjustTableHeight = () => {
const tablesContainer = document.getElementById('tables-container');
if (!tablesContainer)
return;
const upperDistance = tablesContainer.getBoundingClientRect().top;
const containerHeight = window.innerHeight - upperDistance;
@@ -17,6 +19,14 @@ window.addEventListener('load', () => {
paging: false,
info: false,
dom: "t",
stateSave: true,
stateSaveCallback: function(settings, data) {
delete data.search;
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
},
stateLoadCallback: function(settings) {
return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`));
},
columnDefs: [
{
targets: 'hours',
@@ -63,16 +73,44 @@ window.addEventListener('load', () => {
// the tbody and render two separate tables.
});
document.getElementById('search').addEventListener('keyup', (event) => {
tables.search(event.target.value);
console.info(tables.search());
const searchBox = document.getElementById("search");
searchBox.value = tables.search();
searchBox.focus();
searchBox.select();
const doSearch = () => {
tables.search(searchBox.value);
tables.draw();
};
searchBox.addEventListener("keyup", doSearch);
window.addEventListener("keydown", (event) => {
if (!event.ctrlKey && !event.altKey && event.key.length === 1 && document.activeElement !== searchBox) {
searchBox.focus();
searchBox.select();
}
if (!event.ctrlKey && !event.altKey && !event.shiftKey && event.key === "Escape") {
if (searchBox.value !== "") {
searchBox.value = "";
doSearch();
}
searchBox.blur();
if (!document.getElementById("tables-container"))
window.scroll(0, 0);
event.preventDefault();
}
});
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
const target_second = document.getElementById('tracker-wrapper').getAttribute('data-second') + 3;
function getSleepTimeSeconds(){
// -40 % 60 is -40, which is absolutely wrong and should burn
var sleepSeconds = (((target_second - new Date().getSeconds()) % 60) + 60) % 60;
return sleepSeconds || 60;
}
const update = () => {
const target = $("<div></div>");
const tracker = document.getElementById('tracker-wrapper').getAttribute('data-tracker');
target.load("/tracker/" + tracker, function (response, status) {
console.log("Updating Tracker...");
target.load(location.href, function (response, status) {
if (status === "success") {
target.find(".table").each(function (i, new_table) {
const new_trs = $(new_table).find("tbody>tr");
@@ -90,19 +128,14 @@ window.addEventListener('load', () => {
console.log(response);
}
})
setTimeout(update, getSleepTimeSeconds()*1000);
}
setInterval(update, 30000);
setTimeout(update, getSleepTimeSeconds()*1000);
window.addEventListener('resize', () => {
adjustTableHeight();
tables.draw();
});
$(".table-wrapper").scrollsync({
y_sync: true,
x_sync: true
});
adjustTableHeight();
});

View File

@@ -6,6 +6,7 @@ window.addEventListener('load', () => {
"order": [[ 3, "desc" ]],
"info": false,
"dom": "t",
"stateSave": true,
});
$("#seeds-table").DataTable({
"paging": false,
@@ -13,5 +14,6 @@ window.addEventListener('load', () => {
"order": [[ 2, "desc" ]],
"info": false,
"dom": "t",
"stateSave": true,
});
});

View File

@@ -78,8 +78,6 @@ const createDefaultSettings = (settingData) => {
break;
case 'range':
case 'special_range':
newSettings[game][gameSetting][setting.min] = 0;
newSettings[game][gameSetting][setting.max] = 0;
newSettings[game][gameSetting]['random'] = 0;
newSettings[game][gameSetting]['random-low'] = 0;
newSettings[game][gameSetting]['random-high'] = 0;
@@ -103,6 +101,7 @@ const createDefaultSettings = (settingData) => {
newSettings[game].start_inventory = {};
newSettings[game].exclude_locations = [];
newSettings[game].priority_locations = [];
newSettings[game].local_items = [];
newSettings[game].non_local_items = [];
newSettings[game].start_hints = [];
@@ -138,21 +137,28 @@ const buildUI = (settingData) => {
expandButton.classList.add('invisible');
gameDiv.appendChild(expandButton);
const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings);
settingData.games[game].gameItems.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0)));
settingData.games[game].gameLocations.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0)));
const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings,
settingData.games[game].gameItems, settingData.games[game].gameLocations);
gameDiv.appendChild(weightedSettingsDiv);
const itemsDiv = buildItemsDiv(game, settingData.games[game].gameItems);
gameDiv.appendChild(itemsDiv);
const itemPoolDiv = buildItemsDiv(game, settingData.games[game].gameItems);
gameDiv.appendChild(itemPoolDiv);
const hintsDiv = buildHintsDiv(game, settingData.games[game].gameItems, settingData.games[game].gameLocations);
gameDiv.appendChild(hintsDiv);
const locationsDiv = buildLocationsDiv(game, settingData.games[game].gameLocations);
gameDiv.appendChild(locationsDiv);
gamesWrapper.appendChild(gameDiv);
collapseButton.addEventListener('click', () => {
collapseButton.classList.add('invisible');
weightedSettingsDiv.classList.add('invisible');
itemsDiv.classList.add('invisible');
itemPoolDiv.classList.add('invisible');
hintsDiv.classList.add('invisible');
expandButton.classList.remove('invisible');
});
@@ -160,7 +166,7 @@ const buildUI = (settingData) => {
expandButton.addEventListener('click', () => {
collapseButton.classList.remove('invisible');
weightedSettingsDiv.classList.remove('invisible');
itemsDiv.classList.remove('invisible');
itemPoolDiv.classList.remove('invisible');
hintsDiv.classList.remove('invisible');
expandButton.classList.add('invisible');
});
@@ -228,7 +234,7 @@ const buildGameChoice = (games) => {
gameChoiceDiv.appendChild(table);
};
const buildWeightedSettingsDiv = (game, settings) => {
const buildWeightedSettingsDiv = (game, settings, gameItems, gameLocations) => {
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
const settingsWrapper = document.createElement('div');
settingsWrapper.classList.add('settings-wrapper');
@@ -270,7 +276,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
range.setAttribute('data-type', setting.type);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateGameSetting);
range.addEventListener('change', updateRangeSetting);
range.value = currentSettings[game][settingName][option.value];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
@@ -296,33 +302,33 @@ const buildWeightedSettingsDiv = (game, settings) => {
if (((setting.max - setting.min) + 1) < 11) {
for (let i=setting.min; i <= setting.max; ++i) {
const tr = document.createElement('tr');
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = i;
tr.appendChild(tdLeft);
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = i;
tr.appendChild(tdLeft);
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('id', `${game}-${settingName}-${i}-range`);
range.setAttribute('data-game', game);
range.setAttribute('data-setting', settingName);
range.setAttribute('data-option', i);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateGameSetting);
range.value = currentSettings[game][settingName][i];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdMiddle = document.createElement('td');
tdMiddle.classList.add('td-middle');
const range = document.createElement('input');
range.setAttribute('type', 'range');
range.setAttribute('id', `${game}-${settingName}-${i}-range`);
range.setAttribute('data-game', game);
range.setAttribute('data-setting', settingName);
range.setAttribute('data-option', i);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateRangeSetting);
range.value = currentSettings[game][settingName][i] || 0;
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
const tdRight = document.createElement('td');
tdRight.setAttribute('id', `${game}-${settingName}-${i}`)
tdRight.classList.add('td-right');
tdRight.innerText = range.value;
tr.appendChild(tdRight);
const tdRight = document.createElement('td');
tdRight.setAttribute('id', `${game}-${settingName}-${i}`)
tdRight.classList.add('td-right');
tdRight.innerText = range.value;
tr.appendChild(tdRight);
rangeTbody.appendChild(tr);
rangeTbody.appendChild(tr);
}
} else {
const hintText = document.createElement('p');
@@ -379,7 +385,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
range.setAttribute('data-option', option);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateGameSetting);
range.addEventListener('change', updateRangeSetting);
range.value = currentSettings[game][settingName][parseInt(option, 10)];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
@@ -430,7 +436,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
range.setAttribute('data-option', option);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateGameSetting);
range.addEventListener('change', updateRangeSetting);
range.value = currentSettings[game][settingName][parseInt(option, 10)];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
@@ -464,7 +470,17 @@ const buildWeightedSettingsDiv = (game, settings) => {
const tr = document.createElement('tr');
const tdLeft = document.createElement('td');
tdLeft.classList.add('td-left');
tdLeft.innerText = option;
switch(option){
case 'random':
tdLeft.innerText = 'Random';
break;
case 'random-low':
tdLeft.innerText = "Random (Low)";
break;
case 'random-high':
tdLeft.innerText = "Random (High)";
break;
}
tr.appendChild(tdLeft);
const tdMiddle = document.createElement('td');
@@ -477,7 +493,7 @@ const buildWeightedSettingsDiv = (game, settings) => {
range.setAttribute('data-option', option);
range.setAttribute('min', 0);
range.setAttribute('max', 50);
range.addEventListener('change', updateGameSetting);
range.addEventListener('change', updateRangeSetting);
range.value = currentSettings[game][settingName][option];
tdMiddle.appendChild(range);
tr.appendChild(tdMiddle);
@@ -495,15 +511,108 @@ const buildWeightedSettingsDiv = (game, settings) => {
break;
case 'items-list':
// TODO
const itemsList = document.createElement('div');
itemsList.classList.add('simple-list');
Object.values(gameItems).forEach((item) => {
const itemRow = document.createElement('div');
itemRow.classList.add('list-row');
const itemLabel = document.createElement('label');
itemLabel.setAttribute('for', `${game}-${settingName}-${item}`)
const itemCheckbox = document.createElement('input');
itemCheckbox.setAttribute('id', `${game}-${settingName}-${item}`);
itemCheckbox.setAttribute('type', 'checkbox');
itemCheckbox.setAttribute('data-game', game);
itemCheckbox.setAttribute('data-setting', settingName);
itemCheckbox.setAttribute('data-option', item.toString());
itemCheckbox.addEventListener('change', updateListSetting);
if (currentSettings[game][settingName].includes(item)) {
itemCheckbox.setAttribute('checked', '1');
}
const itemName = document.createElement('span');
itemName.innerText = item.toString();
itemLabel.appendChild(itemCheckbox);
itemLabel.appendChild(itemName);
itemRow.appendChild(itemLabel);
itemsList.appendChild((itemRow));
});
settingWrapper.appendChild(itemsList);
break;
case 'locations-list':
// TODO
const locationsList = document.createElement('div');
locationsList.classList.add('simple-list');
Object.values(gameLocations).forEach((location) => {
const locationRow = document.createElement('div');
locationRow.classList.add('list-row');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${game}-${settingName}-${location}`)
const locationCheckbox = document.createElement('input');
locationCheckbox.setAttribute('id', `${game}-${settingName}-${location}`);
locationCheckbox.setAttribute('type', 'checkbox');
locationCheckbox.setAttribute('data-game', game);
locationCheckbox.setAttribute('data-setting', settingName);
locationCheckbox.setAttribute('data-option', location.toString());
locationCheckbox.addEventListener('change', updateListSetting);
if (currentSettings[game][settingName].includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
const locationName = document.createElement('span');
locationName.innerText = location.toString();
locationLabel.appendChild(locationCheckbox);
locationLabel.appendChild(locationName);
locationRow.appendChild(locationLabel);
locationsList.appendChild((locationRow));
});
settingWrapper.appendChild(locationsList);
break;
case 'custom-list':
// TODO
const customList = document.createElement('div');
customList.classList.add('simple-list');
Object.values(settings[settingName].options).forEach((listItem) => {
const customListRow = document.createElement('div');
customListRow.classList.add('list-row');
const customItemLabel = document.createElement('label');
customItemLabel.setAttribute('for', `${game}-${settingName}-${listItem}`)
const customItemCheckbox = document.createElement('input');
customItemCheckbox.setAttribute('id', `${game}-${settingName}-${listItem}`);
customItemCheckbox.setAttribute('type', 'checkbox');
customItemCheckbox.setAttribute('data-game', game);
customItemCheckbox.setAttribute('data-setting', settingName);
customItemCheckbox.setAttribute('data-option', listItem.toString());
customItemCheckbox.addEventListener('change', updateListSetting);
if (currentSettings[game][settingName].includes(listItem)) {
customItemCheckbox.setAttribute('checked', '1');
}
const customItemName = document.createElement('span');
customItemName.innerText = listItem.toString();
customItemLabel.appendChild(customItemCheckbox);
customItemLabel.appendChild(customItemName);
customListRow.appendChild(customItemLabel);
customList.appendChild((customListRow));
});
settingWrapper.appendChild(customList);
break;
default:
@@ -729,21 +838,22 @@ const buildHintsDiv = (game, items, locations) => {
const hintsDescription = document.createElement('p');
hintsDescription.classList.add('setting-description');
hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' +
' items are, or what those locations contain. Excluded locations will not contain progression items.';
' items are, or what those locations contain.';
hintsDiv.appendChild(hintsDescription);
const itemHintsContainer = document.createElement('div');
itemHintsContainer.classList.add('hints-container');
// Item Hints
const itemHintsWrapper = document.createElement('div');
itemHintsWrapper.classList.add('hints-wrapper');
itemHintsWrapper.innerText = 'Starting Item Hints';
const itemHintsDiv = document.createElement('div');
itemHintsDiv.classList.add('item-container');
itemHintsDiv.classList.add('simple-list');
items.forEach((item) => {
const itemDiv = document.createElement('div');
itemDiv.classList.add('hint-div');
const itemRow = document.createElement('div');
itemRow.classList.add('list-row');
const itemLabel = document.createElement('label');
itemLabel.setAttribute('for', `${game}-start_hints-${item}`);
@@ -757,29 +867,30 @@ const buildHintsDiv = (game, items, locations) => {
if (currentSettings[game].start_hints.includes(item)) {
itemCheckbox.setAttribute('checked', 'true');
}
itemCheckbox.addEventListener('change', hintChangeHandler);
itemCheckbox.addEventListener('change', updateListSetting);
itemLabel.appendChild(itemCheckbox);
const itemName = document.createElement('span');
itemName.innerText = item;
itemLabel.appendChild(itemName);
itemDiv.appendChild(itemLabel);
itemHintsDiv.appendChild(itemDiv);
itemRow.appendChild(itemLabel);
itemHintsDiv.appendChild(itemRow);
});
itemHintsWrapper.appendChild(itemHintsDiv);
itemHintsContainer.appendChild(itemHintsWrapper);
// Starting Location Hints
const locationHintsWrapper = document.createElement('div');
locationHintsWrapper.classList.add('hints-wrapper');
locationHintsWrapper.innerText = 'Starting Location Hints';
const locationHintsDiv = document.createElement('div');
locationHintsDiv.classList.add('item-container');
locationHintsDiv.classList.add('simple-list');
locations.forEach((location) => {
const locationDiv = document.createElement('div');
locationDiv.classList.add('hint-div');
const locationRow = document.createElement('div');
locationRow.classList.add('list-row');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${game}-start_location_hints-${location}`);
@@ -793,29 +904,89 @@ const buildHintsDiv = (game, items, locations) => {
if (currentSettings[game].start_location_hints.includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
locationCheckbox.addEventListener('change', hintChangeHandler);
locationCheckbox.addEventListener('change', updateListSetting);
locationLabel.appendChild(locationCheckbox);
const locationName = document.createElement('span');
locationName.innerText = location;
locationLabel.appendChild(locationName);
locationDiv.appendChild(locationLabel);
locationHintsDiv.appendChild(locationDiv);
locationRow.appendChild(locationLabel);
locationHintsDiv.appendChild(locationRow);
});
locationHintsWrapper.appendChild(locationHintsDiv);
itemHintsContainer.appendChild(locationHintsWrapper);
hintsDiv.appendChild(itemHintsContainer);
return hintsDiv;
};
const buildLocationsDiv = (game, locations) => {
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
locations.sort(); // Sort alphabetical, in-place
const locationsDiv = document.createElement('div');
locationsDiv.classList.add('locations-div');
const locationsHeader = document.createElement('h3');
locationsHeader.innerText = 'Priority & Exclusion Locations';
locationsDiv.appendChild(locationsHeader);
const locationsDescription = document.createElement('p');
locationsDescription.classList.add('setting-description');
locationsDescription.innerText = 'Priority locations guarantee a progression item will be placed there while ' +
'excluded locations will not contain progression or useful items.';
locationsDiv.appendChild(locationsDescription);
const locationsContainer = document.createElement('div');
locationsContainer.classList.add('locations-container');
// Priority Locations
const priorityLocationsWrapper = document.createElement('div');
priorityLocationsWrapper.classList.add('locations-wrapper');
priorityLocationsWrapper.innerText = 'Priority Locations';
const priorityLocationsDiv = document.createElement('div');
priorityLocationsDiv.classList.add('simple-list');
locations.forEach((location) => {
const locationRow = document.createElement('div');
locationRow.classList.add('list-row');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${game}-priority_locations-${location}`);
const locationCheckbox = document.createElement('input');
locationCheckbox.setAttribute('type', 'checkbox');
locationCheckbox.setAttribute('id', `${game}-priority_locations-${location}`);
locationCheckbox.setAttribute('data-game', game);
locationCheckbox.setAttribute('data-setting', 'priority_locations');
locationCheckbox.setAttribute('data-option', location);
if (currentSettings[game].priority_locations.includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
locationCheckbox.addEventListener('change', updateListSetting);
locationLabel.appendChild(locationCheckbox);
const locationName = document.createElement('span');
locationName.innerText = location;
locationLabel.appendChild(locationName);
locationRow.appendChild(locationLabel);
priorityLocationsDiv.appendChild(locationRow);
});
priorityLocationsWrapper.appendChild(priorityLocationsDiv);
locationsContainer.appendChild(priorityLocationsWrapper);
// Exclude Locations
const excludeLocationsWrapper = document.createElement('div');
excludeLocationsWrapper.classList.add('hints-wrapper');
excludeLocationsWrapper.classList.add('locations-wrapper');
excludeLocationsWrapper.innerText = 'Exclude Locations';
const excludeLocationsDiv = document.createElement('div');
excludeLocationsDiv.classList.add('item-container');
excludeLocationsDiv.classList.add('simple-list');
locations.forEach((location) => {
const locationDiv = document.createElement('div');
locationDiv.classList.add('hint-div');
const locationRow = document.createElement('div');
locationRow.classList.add('list-row');
const locationLabel = document.createElement('label');
locationLabel.setAttribute('for', `${game}-exclude_locations-${location}`);
@@ -829,40 +1000,22 @@ const buildHintsDiv = (game, items, locations) => {
if (currentSettings[game].exclude_locations.includes(location)) {
locationCheckbox.setAttribute('checked', '1');
}
locationCheckbox.addEventListener('change', hintChangeHandler);
locationCheckbox.addEventListener('change', updateListSetting);
locationLabel.appendChild(locationCheckbox);
const locationName = document.createElement('span');
locationName.innerText = location;
locationLabel.appendChild(locationName);
locationDiv.appendChild(locationLabel);
excludeLocationsDiv.appendChild(locationDiv);
locationRow.appendChild(locationLabel);
excludeLocationsDiv.appendChild(locationRow);
});
excludeLocationsWrapper.appendChild(excludeLocationsDiv);
itemHintsContainer.appendChild(excludeLocationsWrapper);
locationsContainer.appendChild(excludeLocationsWrapper);
hintsDiv.appendChild(itemHintsContainer);
return hintsDiv;
};
const hintChangeHandler = (evt) => {
const currentSettings = JSON.parse(localStorage.getItem('weighted-settings'));
const game = evt.target.getAttribute('data-game');
const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option');
if (evt.target.checked) {
if (!currentSettings[game][setting].includes(option)) {
currentSettings[game][setting].push(option);
}
} else {
if (currentSettings[game][setting].includes(option)) {
currentSettings[game][setting].splice(currentSettings[game][setting].indexOf(option), 1);
}
}
localStorage.setItem('weighted-settings', JSON.stringify(currentSettings));
locationsDiv.appendChild(locationsContainer);
return locationsDiv;
};
const updateVisibleGames = () => {
@@ -908,13 +1061,12 @@ const updateBaseSetting = (event) => {
localStorage.setItem('weighted-settings', JSON.stringify(settings));
};
const updateGameSetting = (evt) => {
const updateRangeSetting = (evt) => {
const options = JSON.parse(localStorage.getItem('weighted-settings'));
const game = evt.target.getAttribute('data-game');
const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option');
document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value;
console.log(event);
if (evt.action && evt.action === 'rangeDelete') {
delete options[game][setting][option];
} else {
@@ -923,6 +1075,26 @@ const updateGameSetting = (evt) => {
localStorage.setItem('weighted-settings', JSON.stringify(options));
};
const updateListSetting = (evt) => {
const options = JSON.parse(localStorage.getItem('weighted-settings'));
const game = evt.target.getAttribute('data-game');
const setting = evt.target.getAttribute('data-setting');
const option = evt.target.getAttribute('data-option');
if (evt.target.checked) {
// If the option is to be enabled and it is already enabled, do nothing
if (options[game][setting].includes(option)) { return; }
options[game][setting].push(option);
} else {
// If the option is to be disabled and it is already disabled, do nothing
if (!options[game][setting].includes(option)) { return; }
options[game][setting].splice(options[game][setting].indexOf(option), 1);
}
localStorage.setItem('weighted-settings', JSON.stringify(options));
};
const updateItemSetting = (evt) => {
const options = JSON.parse(localStorage.getItem('weighted-settings'));
const game = evt.target.getAttribute('data-game');

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@@ -0,0 +1,30 @@
#player-tracker-wrapper{
margin: 0;
}
#inventory-table{
padding: 8px 10px 2px 6px;
background-color: #42b149;
border-radius: 4px;
border: 2px solid black;
}
#inventory-table tr.column-headers td {
font-size: 1rem;
padding: 0 5rem 0 0;
}
#inventory-table td{
padding: 0 0.5rem 0.5rem;
font-family: LexendDeca-Light, monospace;
font-size: 2.5rem;
color: #ffffff;
}
#inventory-table td img{
vertical-align: middle;
}
.hide {
display: none;
}

View File

@@ -105,6 +105,9 @@ h5, h6{
margin-bottom: 20px;
background-color: #ffff00;
}
.user-message a{
color: #ff7700;
}
.interactive{
color: #ffef00;

View File

@@ -15,3 +15,33 @@
padding-left: 0.5rem;
color: #dfedc6;
}
@media all and (max-width: 900px) {
#island-footer{
font-size: 17px;
font-size: 2vw;
}
}
@media all and (max-width: 768px) {
#island-footer{
font-size: 15px;
font-size: 2vw;
}
}
@media all and (max-width: 650px) {
#island-footer{
font-size: 13px;
font-size: 2vw;
}
}
@media all and (max-width: 580px) {
#island-footer{
font-size: 11px;
font-size: 2vw;
}
}
@media all and (max-width: 512px) {
#island-footer{
font-size: 9px;
font-size: 2vw;
}
}

View File

@@ -21,7 +21,6 @@ html{
margin-right: auto;
margin-top: 10px;
height: 140px;
z-index: 10;
}
#landing-header h4{
@@ -223,7 +222,7 @@ html{
}
#landing{
width: 700px;
max-width: 700px;
min-height: 280px;
margin-left: auto;
margin-right: auto;

View File

@@ -9,7 +9,7 @@
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
width: 448px;
width: 480px;
background-color: rgb(60, 114, 157);
}
@@ -46,7 +46,7 @@
}
#location-table{
width: 448px;
width: 480px;
border-left: 2px solid #000000;
border-right: 2px solid #000000;
border-bottom: 2px solid #000000;
@@ -108,7 +108,7 @@
}
#location-table td:first-child {
width: 272px;
width: 300px;
}
.location-category td:first-child {

View File

@@ -0,0 +1,110 @@
#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: 500px;
background-color: #525494;
}
#inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
}
#inventory-table td.title{
padding-top: 10px;
height: 20px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
}
#inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
border: 1px solid #000000;
filter: grayscale(100%) contrast(75%) brightness(20%);
}
#inventory-table img.acquired{
filter: none;
}
#inventory-table div.counted-item {
position: relative;
}
#inventory-table div.item-count {
text-align: left;
color: black;
font-family: "JuraBook", monospace;
font-weight: bold;
}
#location-table{
width: 500px;
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: #525494;
padding: 10px 3px 3px;
font-family: "JuraBook", monospace;
font-size: 16px;
font-weight: bold;
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: 16px;
}
#location-table td.location-name {
padding-left: 16px;
}
.hide {
display: none;
}

View File

@@ -30,6 +30,8 @@ html{
}
#base-header-right{
display: flex;
flex-direction: row;
margin-top: 4px;
}
@@ -42,7 +44,7 @@ html{
margin-top: 4px;
}
#base-header a{
#base-header a, #base-header-mobile-menu a, #base-header-popover-text{
color: #2f6b83;
text-decoration: none;
cursor: pointer;
@@ -51,3 +53,126 @@ html{
font-family: LondrinaSolid-Light, sans-serif;
text-transform: uppercase;
}
#base-header-right-mobile{
display: none;
margin-top: 2rem;
margin-right: 1rem;
}
#base-header-mobile-menu{
display: none;
flex-direction: column;
background-color: #ffffff;
text-align: center;
overflow-y: auto;
z-index: 10000;
width: 100vw;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
position: absolute;
top: 7rem;
right: 0;
}
#base-header-mobile-menu a{
padding: 3rem 1.5rem;
font-size: 4rem;
line-height: 5rem;
color: #699ca8;
border-top: 1px solid #d3d3d3;
}
#base-header-mobile-menu :first-child, #base-header-popover-menu :first-child{
border-top: none;
}
#base-header-right-mobile img{
height: 3rem;
}
#base-header-popover-menu{
display: none;
flex-direction: column;
position: absolute;
background-color: #fff;
margin-left: -108px;
margin-top: 2.25rem;
border-radius: 10px;
border-left: 2px solid #d0ebe6;
border-bottom: 2px solid #d0ebe6;
border-right: 1px solid #d0ebe6;
filter: drop-shadow(-6px 6px 2px #2e3e83);
}
#base-header-popover-menu a{
color: #699ca8;
border-top: 1px solid #d3d3d3;
text-align: center;
font-size: 1.5rem;
line-height: 3rem;
margin-right: 2px;
padding: 0.25rem 1rem;
}
#base-header-popover-icon {
width: 14px;
margin-bottom: 3px;
margin-left: 2px;
}
@media all and (max-width: 960px), only screen and (max-device-width: 768px) {
#base-header-right{
display: none;
}
#base-header-right-mobile{
display: unset;
}
}
@media all and (max-width: 960px){
#base-header-right-mobile{
margin-top: 0.5rem;
margin-right: 0;
}
#base-header-right-mobile img{
height: 1.5rem;
}
#base-header-mobile-menu{
top: 3.3rem;
width: unset;
border-left: 2px solid #d0ebe6;
border-bottom: 2px solid #d0ebe6;
filter: drop-shadow(-6px 6px 2px #2e3e83);
border-top-left-radius: 10px;
}
#base-header-mobile-menu a{
font-size: 1.5rem;
line-height: 3rem;
margin: 0;
padding: 0.25rem 1rem;
}
}
@media only screen and (max-device-width: 768px){
html{
padding-top: 260px;
scroll-padding-top: 230px;
}
#base-header{
height: 200px;
background-size: auto 200px;
}
#base-header #site-title img{
height: calc(38px * 2);
margin-top: 30px;
margin-left: 20px;
}
}

View File

@@ -9,19 +9,54 @@
border-top-left-radius: 4px;
border-top-right-radius: 4px;
padding: 3px 3px 10px;
width: 384px;
width: 374px;
background-color: #8d60a7;
}
#inventory-table td{
width: 40px;
height: 40px;
text-align: center;
vertical-align: middle;
display: grid;
grid-template-rows: repeat(5, 48px);
}
#inventory-table img{
display: block;
}
#inventory-table div.table-row{
display: grid;
grid-template-columns: repeat(5, 1fr);
}
#inventory-table div.C1{
grid-column: 1;
place-content: center;
place-items: center;
display: flex;
}
#inventory-table div.C2{
grid-column: 2;
place-content: center;
place-items: center;
display: flex;
}
#inventory-table div.C3{
grid-column: 3;
place-content: center;
place-items: center;
display: flex;
}
#inventory-table div.C4{
grid-column: 4;
place-content: center;
place-items: center;
display: flex;
}
#inventory-table div.C5{
grid-column: 5;
place-content: center;
place-items: center;
display: flex;
}
#inventory-table img{
height: 100%;
max-width: 40px;
max-height: 40px;
filter: grayscale(100%) contrast(75%) brightness(30%);
@@ -31,11 +66,70 @@
filter: none;
}
#inventory-table div.counted-item {
#inventory-table img.acquired.purple{ /*00FFFF*/
filter: hue-rotate(270deg) saturate(6) brightness(0.8);
}
#inventory-table img.acquired.cyan{ /*FF00FF*/
filter: hue-rotate(138deg) saturate(10) brightness(0.8);
}
#inventory-table img.acquired.green{ /*32CD32*/
filter: hue-rotate(84deg) saturate(10) brightness(0.7);
}
#inventory-table div.image-stack{
display: grid;
position: relative;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
}
#inventory-table div.image-stack div.stack-back{
grid-column: 1;
grid-row: 1;
}
#inventory-table div.image-stack div.stack-front{
grid-column: 1;
grid-row: 1;
display: grid;
grid-template-columns: 20px 20px;
grid-template-rows: 20px 20px;
}
#inventory-table div.image-stack div.stack-top-left{
grid-column: 1;
grid-row: 1;
z-index: 1;
}
#inventory-table div.image-stack div.stack-top-right{
grid-column: 2;
grid-row: 1;
z-index: 1;
}
#inventory-table div.image-stack div.stack-bottum-left{
grid-column: 1;
grid-row: 2;
z-index: 1;
}
#inventory-table div.image-stack div.stack-bottum-right{
grid-column: 2;
grid-row: 2;
z-index: 1;
}
#inventory-table div.image-stack div.stack-front img{
width: 20px;
height: 20px;
}
#inventory-table div.counted-item{
position: relative;
}
#inventory-table div.item-count {
#inventory-table div.item-count{
position: absolute;
color: white;
font-family: "Minecraftia", monospace;
@@ -69,16 +163,16 @@
line-height: 20px;
}
#location-table td.counter {
#location-table td.counter{
text-align: right;
font-size: 14px;
}
#location-table td.toggle-arrow {
#location-table td.toggle-arrow{
text-align: right;
}
#location-table tr#Total-header {
#location-table tr#Total-header{
font-weight: bold;
}
@@ -88,14 +182,14 @@
max-height: 30px;
}
#location-table tbody.locations {
#location-table tbody.locations{
font-size: 12px;
}
#location-table td.location-name {
#location-table td.location-name{
padding-left: 16px;
}
.hide {
.hide{
display: none;
}

View File

@@ -119,6 +119,33 @@ img.alttp-sprite {
background-color: #d3c97d;
}
#tracker-navigation {
display: inline-flex;
background-color: #b0a77d;
margin: 0.5rem;
border-radius: 4px;
}
.tracker-navigation-button {
display: block;
margin: 4px;
padding-left: 12px;
padding-right: 12px;
border-radius: 4px;
text-align: center;
font-size: 14px;
color: #000;
font-weight: lighter;
}
.tracker-navigation-button:hover {
background-color: #e2eabb !important;
}
.tracker-navigation-button.selected {
background-color: rgb(220, 226, 189);
}
@media all and (max-width: 1700px) {
table.dataTable thead th.upper-row{
position: -webkit-sticky;

View File

@@ -157,41 +157,29 @@ html{
background-color: rgba(0, 0, 0, 0.1);
}
#weighted-settings .hints-div{
#weighted-settings .hints-div, #weighted-settings .locations-div{
margin-top: 2rem;
}
#weighted-settings .hints-div h3{
#weighted-settings .hints-div h3, #weighted-settings .locations-div h3{
margin-bottom: 0.5rem;
}
#weighted-settings .hints-div .hints-container{
#weighted-settings .hints-container, #weighted-settings .locations-container{
display: flex;
flex-direction: row;
justify-content: space-between;
}
#weighted-settings .hints-wrapper, #weighted-settings .locations-wrapper{
width: calc(50% - 0.5rem);
font-weight: bold;
}
#weighted-settings .hints-div .hints-wrapper{
width: 32.5%;
}
#weighted-settings .hints-div .hints-wrapper .hint-div{
display: flex;
flex-direction: row;
cursor: pointer;
user-select: none;
-moz-user-select: none;
}
#weighted-settings .hints-div .hints-wrapper .hint-div:hover{
background-color: rgba(0, 0, 0, 0.1);
}
#weighted-settings .hints-div .hints-wrapper .hint-div label{
flex-grow: 1;
padding: 0.125rem 0.5rem;
cursor: pointer;
#weighted-settings .hints-wrapper .simple-list, #weighted-settings .locations-wrapper .simple-list{
margin-top: 0.25rem;
height: 300px;
font-weight: normal;
}
#weighted-settings #weighted-settings-button-row{
@@ -280,6 +268,30 @@ html{
flex-direction: column;
}
#weighted-settings .simple-list{
display: flex;
flex-direction: column;
max-height: 300px;
overflow-y: auto;
border: 1px solid #ffffff;
border-radius: 4px;
}
#weighted-settings .simple-list .list-row label{
display: block;
width: calc(100% - 0.5rem);
padding: 0.0625rem 0.25rem;
}
#weighted-settings .simple-list .list-row label:hover{
background-color: rgba(0, 0, 0, 0.1);
}
#weighted-settings .simple-list .list-row label input[type=checkbox]{
margin-right: 0.5rem;
}
#weighted-settings .invisible{
display: none;
}

View File

@@ -22,7 +22,7 @@ def get_db_data(known_games: typing.Set[str]) -> typing.Tuple[typing.Counter[str
typing.DefaultDict[datetime.date, typing.Dict[str, int]]]:
games_played = defaultdict(Counter)
total_games = Counter()
cutoff = date.today()-timedelta(days=30)
cutoff = date.today() - timedelta(days=30)
room: Room
for room in select(room for room in Room if room.creation_time >= cutoff):
for slot in room.seed.slots:

View File

@@ -12,7 +12,7 @@
<div id="check-result" class="grass-island">
<h1>Verification Results</h1>
<p>The results of your requested file check are below.</p>
<table class="table autodatatable">
<table id="results-table" class="table autodatatable">
<thead>
<tr>
<th>File</th>

View File

@@ -0,0 +1,35 @@
<!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/checksfinderTracker.css') }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/checksfinderTracker.js') }}"></script>
</head>
<body>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr class="column-headers">
<td colspan="2">Checks Available:</td>
<td colspan="2">Map Bombs:</td>
</tr>
<tr>
<td><img alt="Checks Available" src="{{ icons['Checks Available'] }}" /></td>
<td>{{ checks_available }}</td>
<td><img alt="Bombs Remaining" src="{{ icons['Map Bombs'] }}" /></td>
<td>{{ bombs_display }}/20</td>
</tr>
<tr class="column-headers">
<td colspan="2">Map Width:</td>
<td colspan="2">Map Height:</td>
</tr>
<tr>
<td><img alt="Map Width" src="{{ icons['Map Width'] }}" /></td>
<td>{{ width_display }}/10</td>
<td><img alt="Map Height" src="{{ icons['Map Height'] }}" /></td>
<td>{{ height_display }}/10</td>
</tr>
</table>
</div>
</body>
</html>

View File

@@ -40,20 +40,20 @@
<tbody>
<tr>
<td>
<label for="forfeit_mode">Forfeit Permission:
<span class="interactive" data-tooltip="A forfeit releases all remaining items from the locations in your world.">
<label for="release_mode">Release Permission:
<span class="interactive" data-tooltip="Permissions on when players are able to release all remaining items from their world.">
(?)
</span>
</label>
</td>
<td>
<select name="forfeit_mode" id="forfeit_mode">
<select name="release_mode" id="release_mode">
<option value="auto">Automatic on goal completion</option>
<option value="goal">Allow !forfeit after goal completion</option>
<option value="goal">Allow !release after goal completion</option>
<option value="auto-enabled">
Automatic on goal completion and manual !forfeit
Automatic on goal completion and manual !release
</option>
<option value="enabled">Manual !forfeit</option>
<option value="enabled">Manual !release</option>
<option value="disabled">Disabled</option>
</select>
</td>
@@ -62,7 +62,7 @@
<tr>
<td>
<label for="collect_mode">Collect Permission:
<span class="interactive" data-tooltip="A collect releases all of your remaining items to you from across the multiworld.">
<span class="interactive" data-tooltip="Permissions on when players are able to collect all their remaining items from across the multiworld.">
(?)
</span>
</label>

View File

@@ -4,18 +4,18 @@
<title>{{ player_name }}&apos;s Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}">
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}/{{ team }}/{{ player }}" data-second="{{ saving_second }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
<span class="info">This tracker will automatically update itself periodically.</span>
</div>
<div class="table-wrapper">
<table class="table non-unique-item-table">
<table id="received-table" class="table non-unique-item-table">
<thead>
<tr>
<th>Item</th>
@@ -37,7 +37,7 @@
</table>
</div>
<div class="table-wrapper">
<table class="table non-unique-item-table">
<table id="locations-table" class="table non-unique-item-table">
<thead>
<tr>
<th>Location</th>

View File

@@ -1,5 +1,6 @@
{% block head %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/themes/base.css") }}" />
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/baseHeader.js") }}"></script>
{% endblock %}
{% block header %}
@@ -10,11 +11,33 @@
</a>
</div>
<div id="base-header-right">
<a href="/games">supported games</a>
<a href="/tutorial">setup guides</a>
<a href="/start-playing">start playing</a>
<a href="/faq/en">f.a.q.</a>
<div id="base-header-popover-text">
<img id="base-header-popover-icon" src="/static/static/button-images/popover.png" alt="Popover Menu" />
get started
</div>
<div id="base-header-popover-menu">
<a href="/games">supported games</a>
<a href="/tutorial">setup guides</a>
<a href="/generate">generate game</a>
<a href="/uploads">host game</a>
<a href="/user-content">user content</a>
</div>
<a href="/faq/en">f.a.q</a>
<a href="https://discord.gg/8Z65BR2" target="_blank">discord</a>
</div>
<div id="base-header-right-mobile">
<a id="base-header-mobile-menu-button" href="#">
<img src="/static/static/button-images/hamburger-menu-icon.png" alt="Menu" />
</a>
</div>
</header>
<div id="base-header-mobile-menu">
<a href="/games">supported games</a>
<a href="/tutorial">setup guides</a>
<a href="/faq/en">f.a.q.</a>
<a href="/generate">generate game</a>
<a href="/uploads">host game</a>
<a href="/user-content">user content</a>
<a href="https://discord.gg/8Z65BR2" target="_blank">discord</a>
</div>
{% endblock %}

View File

@@ -14,7 +14,7 @@
<br />
{% endif %}
{% if room.tracker %}
This room has a <a href="{{ url_for("getTracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
This room has a <a href="{{ url_for("get_multiworld_tracker", tracker=room.tracker) }}">Multiworld Tracker</a> enabled.
<br />
{% endif %}
The server for this room will be paused after {{ room.timeout//60//60 }} hours of inactivity.
@@ -25,8 +25,8 @@
The most likely failure reason is that the multiworld is too old to be loaded now.
{% elif room.last_port %}
You can connect to this room by using <span class="interactive"
data-tooltip="This means address/ip is {{ config['PATCH_TARGET'] }} and port is {{ room.last_port }}.">
'/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}'
data-tooltip="This means address/ip is {{ config['HOST_ADDRESS'] }} and port is {{ room.last_port }}.">
'/connect {{ config['HOST_ADDRESS'] }}:{{ room.last_port }}'
</span>
in the <a href="{{ url_for("tutorial_landing")}}">client</a>.<br>
{% endif %}

View File

@@ -1,14 +1,16 @@
{% extends 'tablepage.html' %}
{% block head %}
{{ super() }}
<title>Multiworld Tracker</title>
<title>ALttP Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/jquery.scrollsync.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tracker.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/lttpMultiTracker.js") }}"></script>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
{% include 'multiTrackerNavigation.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
@@ -25,7 +27,7 @@
<div id="tables-container">
{% for team, players in inventory.items() %}
<div class="table-wrapper">
<table class="table unique-item-table">
<table id="inventory-table" class="table unique-item-table">
<thead>
<tr>
<th>#</th>
@@ -44,7 +46,7 @@
<tbody>
{%- for player, items in players.items() -%}
<tr>
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
{%- if (team, loop.index) in video -%}
{%- if video[(team, loop.index)][0] == "Twitch" -%}
@@ -78,7 +80,7 @@
{% for team, players in checks_done.items() %}
<div class="table-wrapper">
<table class="table non-unique-item-table">
<table id="checks-table" class="table non-unique-item-table">
<thead>
<tr>
<th rowspan="2">#</th>
@@ -98,6 +100,7 @@
<th colspan="{{ colspan }}" class="center-column">{{ area }}</th>
{%- endif -%}
{%- endfor -%}
<th rowspan="2" class="center-column">&percnt;</th>
<th rowspan="2" class="center-column hours">Last<br>Activity</th>
</tr>
<tr>
@@ -121,7 +124,7 @@
<tbody>
{%- for player, checks in players.items() -%}
<tr>
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker,
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
<td>{{ player_names[(team, loop.index)]|e }}</td>
{%- for area in ordered_areas -%}
@@ -140,6 +143,7 @@
<td class="center-column">{% if inventory[team][player][big_key_ids[area]] %}✔️{% endif %}</td>
{%- endif -%}
{%- endfor -%}
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
{%- if activity_timers[(team, player)] -%}
<td class="center-column">{{ activity_timers[(team, player)].total_seconds() }}</td>
{%- else -%}
@@ -153,7 +157,7 @@
{% endfor %}
{% for team, hints in hints.items() %}
<div class="table-wrapper">
<table class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
<thead>
<tr>
<th>Finder</th>

View File

@@ -22,7 +22,7 @@
{% for patch in room.seed.slots|list|sort(attribute="player_id") %}
<tr>
<td>{{ patch.player_id }}</td>
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['PATCH_TARGET'] }}:{{ room.last_port }}">{{ patch.player_name }}<a/></td>
<td data-tooltip="Connect via TextClient"><a href="archipelago://{{ patch.player_name | e}}:@{{ config['HOST_ADDRESS'] }}:{{ room.last_port }}">{{ patch.player_name }}</a></td>
<td>{{ patch.game }}</td>
<td>
{% if patch.game == "Minecraft" %}
@@ -31,6 +31,9 @@
{% 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>
@@ -50,7 +53,7 @@
No file to download for this game.
{% endif %}
</td>
<td><a href="{{ url_for("getPlayerTracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker, tracked_team=0, tracked_player=patch.player_id) }}">Tracker</a></td>
</tr>
{% endfor %}
</tbody>

View File

@@ -0,0 +1,44 @@
{% extends "multiTracker.html" %}
{% block custom_table_headers %}
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Logistic_science_pack.png/32px-Logistic_science_pack.png"
alt="Logistic Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Military_science_pack.png/32px-Military_science_pack.png"
alt="Military Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Chemical_science_pack.png/32px-Chemical_science_pack.png"
alt="Chemical Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Production_science_pack.png/32px-Production_science_pack.png"
alt="Production Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Utility_science_pack.png/32px-Utility_science_pack.png"
alt="Utility Science Pack">
</th>
<th class="center-column">
<img src="https://wiki.factorio.com/images/thumb/Space_science_pack.png/32px-Space_science_pack.png"
alt="Space Science Pack">
</th>
{% endblock %}
{% block custom_table_row scoped %}
{% if games[player] == "Factorio" %}
<td class="center-column">{% if inventory[team][player][131161] or inventory[team][player][131281] %}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131172] or inventory[team][player][131281] > 1%}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131195] or inventory[team][player][131281] > 2%}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131240] or inventory[team][player][131281] > 3%}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131240] or inventory[team][player][131281] > 4%}✔{% endif %}</td>
<td class="center-column">{% if inventory[team][player][131220] or inventory[team][player][131281] > 5%}✔{% endif %}</td>
{% else %}
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
<td class="center-column"></td>
{% endif %}
{% endblock%}

View File

@@ -0,0 +1,98 @@
{% extends 'tablepage.html' %}
{% block head %}
{{ super() }}
<title>Multiworld Tracker</title>
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tracker.css") }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/trackerCommon.js") }}"></script>
{% endblock %}
{% block body %}
{% include 'header/dirtHeader.html' %}
{% include 'multiTrackerNavigation.html' %}
<div id="tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<div id="tracker-header-bar">
<input placeholder="Search" id="search"/>
<span{% if not video %} hidden{% endif %} id="multi-stream-link">
<a target="_blank" href="https://multistream.me/
{%- for platform, link in video.values()|unique(False, 1)-%}
{%- if platform == "Twitch" -%}t{%- else -%}yt{%- endif -%}:{{- link -}}/
{%- endfor -%}">
Multistream
</a>
</span>
<span class="info">Clicking on a slot's number will bring up a slot-specific auto-tracker. This tracker will automatically update itself periodically.</span>
</div>
<div id="tables-container">
{% for team, players in checks_done.items() %}
<div class="table-wrapper">
<table id="checks-table" class="table non-unique-item-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Game</th>
{% block custom_table_headers %}
{# implement this block in game-specific multi trackers #}
{% endblock %}
<th class="center-column">Checks</th>
<th class="center-column">&percnt;</th>
<th class="center-column">Status</th>
<th class="center-column hours">Last<br>Activity</th>
</tr>
</thead>
<tbody>
{%- for player, checks in players.items() -%}
<tr>
<td><a href="{{ url_for("get_player_tracker", tracker=room.tracker,
tracked_team=team, tracked_player=player)}}">{{ loop.index }}</a></td>
<td>{{ player_names[(team, loop.index)]|e }}</td>
<td>{{ games[player] }}</td>
{% block custom_table_row scoped %}
{# implement this block in game-specific multi trackers #}
{% endblock %}
<td class="center-column">{{ checks["Total"] }}/{{ checks_in_area[player]["Total"] }}</td>
<td class="center-column">{{ percent_total_checks_done[team][player] }}</td>
<td>{{ {0: "Disconnected", 5: "Connected", 10: "Ready", 20: "Playing",
30: "Goal Completed"}.get(states[team, player], "Unknown State") }}</td>
{%- if activity_timers[team, player] -%}
<td class="center-column">{{ activity_timers[team, player].total_seconds() }}</td>
{%- else -%}
<td class="center-column">None</td>
{%- endif -%}
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}
{% for team, hints in hints.items() %}
<div class="table-wrapper">
<table id="hints-table" class="table non-unique-item-table" data-order='[[5, "asc"], [0, "asc"]]'>
<thead>
<tr>
<th>Finder</th>
<th>Receiver</th>
<th>Item</th>
<th>Location</th>
<th>Entrance</th>
<th>Found</th>
</tr>
</thead>
<tbody>
{%- for hint in hints -%}
<tr>
<td>{{ long_player_names[team, hint.finding_player] }}</td>
<td>{{ long_player_names[team, hint.receiving_player] }}</td>
<td>{{ hint.item|item_name }}</td>
<td>{{ hint.location|location_name }}</td>
<td>{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}</td>
<td>{% if hint.found %}✔{% endif %}</td>
</tr>
{%- endfor -%}
</tbody>
</table>
</div>
{% endfor %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,9 @@
{%- if enabled_multiworld_trackers|length > 1 -%}
<div id="tracker-navigation">
{% for enabled_tracker in enabled_multiworld_trackers %}
{% set tracker_url = url_for(enabled_tracker.endpoint, tracker=room.tracker) %}
<a class="tracker-navigation-button{%- if enabled_tracker.current -%} selected{% endif %}"
href="{{ tracker_url }}">{{ enabled_tracker.name }}</a>
{% endfor %}
</div>
{%- endif -%}

View File

@@ -0,0 +1,233 @@
<!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/sc2wolTracker.css') }}"/>
<script type="application/ecmascript" src="{{ url_for('static', filename='assets/sc2wolTracker.js') }}"></script>
<link rel="stylesheet" media="screen" href="https://fontlibrary.org//face/jura" type="text/css"/>
</head>
<body>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td colspan="10" class="title">
Starting Resources
</td>
</tr>
<tr>
<td><img src="{{ icons['Starting Minerals'] }}" class="{{ 'acquired' if '+15 Starting Minerals' in acquired_items }}" title="Starting Minerals" /></td>
<td colspan="2"><div class="item-count">+{{ minerals_count }}</div></td>
<td><img src="{{ icons['Starting Vespene'] }}" class="{{ 'acquired' if '+15 Starting Vespene' in acquired_items }}" title="Starting Vespene" /></td>
<td colspan="2"><div class="item-count">+{{ vespene_count }}</div></td>
<!--
<td><img src="{{ icons['Starting Supply'] }}" class="{{ 'acquired' if '+2 Starting Supply' in acquired_items }}" title="Starting Supply" /></td>
<td colspan="2"><div class="item-count">+{{ supply_count }}</div></td>
-->
</tr>
<tr>
<td colspan="10" class="title">
Weapon & Armor Upgrades
</td>
</tr>
<tr>
<td><img src="{{ infantry_weapon_url }}" class="{{ 'acquired' if 'Progressive Infantry Weapon' in acquired_items }}" title="Progressive Infantry Weapons{% if infantry_weapon_level > 0 %} (Level {{ infantry_weapon_level }}){% endif %}" /></td>
<td><img src="{{ infantry_armor_url }}" class="{{ 'acquired' if 'Progressive Infantry Armor' in acquired_items }}" title="Progressive Infantry Armor{% if infantry_armor_level > 0 %} (Level {{ infantry_armor_level }}){% endif %}" /></td>
<td><img src="{{ vehicle_weapon_url }}" class="{{ 'acquired' if 'Progressive Vehicle Weapon' in acquired_items }}" title="Progressive Vehicle Weapons{% if vehicle_weapon_level > 0 %} (Level {{ vehicle_weapon_level }}){% endif %}" /></td>
<td><img src="{{ vehicle_armor_url }}" class="{{ 'acquired' if 'Progressive Vehicle Armor' in acquired_items }}" title="Progressive Vehicle Armor{% if vehicle_armor_level > 0 %} (Level {{ vehicle_armor_level }}){% endif %}" /></td>
<td><img src="{{ ship_weapon_url }}" class="{{ 'acquired' if 'Progressive Ship Weapon' in acquired_items }}" title="Progressive Ship Weapons{% if ship_weapon_level > 0 %} (Level {{ ship_weapon_level }}){% endif %}" /></td>
<td><img src="{{ ship_armor_url }}" class="{{ 'acquired' if 'Progressive Ship Armor' in acquired_items }}" title="Progressive Ship Armor{% if ship_armor_level > 0 %} (Level {{ ship_armor_level }}){% endif %}" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Base
</td>
</tr>
<tr>
<td colspan="2"><img src="{{ icons['Bunker'] }}" class="{{ 'acquired' if 'Bunker' in acquired_items }}" title="Bunker" /></td>
<td colspan="2"><img src="{{ icons['Missile Turret'] }}" class="{{ 'acquired' if 'Missile Turret' in acquired_items }}" title="Missile Turret" /></td>
<td colspan="2"><img src="{{ icons['Sensor Tower'] }}" class="{{ 'acquired' if 'Sensor Tower' in acquired_items }}" title="Sensor Tower" /></td>
</tr>
<tr>
<td><img src="{{ icons['Projectile Accelerator (Bunker)'] }}" class="{{ 'acquired' if 'Projectile Accelerator (Bunker)' in acquired_items }}" title="Projectile Accelerator (Bunker)" /></td>
<td><img src="{{ icons['Neosteel Bunker (Bunker)'] }}" class="{{ 'acquired' if 'Neosteel Bunker (Bunker)' in acquired_items }}" title="Neosteel Bunker (Bunker)" /></td>
<td><img src="{{ icons['Titanium Housing (Missile Turret)'] }}" class="{{ 'acquired' if 'Titanium Housing (Missile Turret)' in acquired_items }}" title="Titanium Housing (Missile Turret)" /></td>
<td><img src="{{ icons['Hellstorm Batteries (Missile Turret)'] }}" class="{{ 'acquired' if 'Hellstorm Batteries (Missile Turret)' in acquired_items }}" title="Hellstorm Batteries (Missile Turret)" /></td>
<td colspan="2">&nbsp;</td>
<td><img src="{{ icons['Advanced Construction (SCV)'] }}" class="{{ 'acquired' if 'Advanced Construction (SCV)' in acquired_items }}" title="Advanced Construction (SCV)" /></td>
<td><img src="{{ icons['Dual-Fusion Welders (SCV)'] }}" class="{{ 'acquired' if 'Dual-Fusion Welders (SCV)' in acquired_items }}" title="Dual-Fusion Welders (SCV)" /></td>
<td><img src="{{ icons['Fire-Suppression System (Building)'] }}" class="{{ 'acquired' if 'Fire-Suppression System (Building)' in acquired_items }}" title="Fire-Suppression System (Building)" /></td>
<td><img src="{{ icons['Orbital Command (Building)'] }}" class="{{ 'acquired' if 'Orbital Command (Building)' in acquired_items }}" title="Orbital Command (Building)" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Infantry
</td>
</tr>
<tr>
<td colspan="2"><img src="{{ icons['Marine'] }}" class="{{ 'acquired' if 'Marine' in acquired_items }}" title="Marine" /></td>
<td colspan="2"><img src="{{ icons['Medic'] }}" class="{{ 'acquired' if 'Medic' in acquired_items }}" title="Medic" /></td>
<td colspan="2"><img src="{{ icons['Firebat'] }}" class="{{ 'acquired' if 'Firebat' in acquired_items }}" title="Firebat" /></td>
<td colspan="2"><img src="{{ icons['Marauder'] }}" class="{{ 'acquired' if 'Marauder' in acquired_items }}" title="Marauder" /></td>
<td colspan="2"><img src="{{ icons['Reaper'] }}" class="{{ 'acquired' if 'Reaper' in acquired_items }}" title="Reaper" /></td>
</tr>
<tr>
<td><img src="{{ icons['Stimpack (Marine)'] }}" class="{{ 'acquired' if 'Stimpack (Marine)' in acquired_items }}" title="Stimpack (Marine)" /></td>
<td><img src="{{ icons['Combat Shield (Marine)'] }}" class="{{ 'acquired' if 'Combat Shield (Marine)' in acquired_items }}" title="Combat Shield (Marine)" /></td>
<td><img src="{{ icons['Advanced Medic Facilities (Medic)'] }}" class="{{ 'acquired' if 'Advanced Medic Facilities (Medic)' in acquired_items }}" title="Advanced Medic Facilities (Medic)" /></td>
<td><img src="{{ icons['Stabilizer Medpacks (Medic)'] }}" class="{{ 'acquired' if 'Stabilizer Medpacks (Medic)' in acquired_items }}" title="Stabilizer Medpacks (Medic)" /></td>
<td><img src="{{ icons['Incinerator Gauntlets (Firebat)'] }}" class="{{ 'acquired' if 'Incinerator Gauntlets (Firebat)' in acquired_items }}" title="Incinerator Gauntlets (Firebat)" /></td>
<td><img src="{{ icons['Juggernaut Plating (Firebat)'] }}" class="{{ 'acquired' if 'Juggernaut Plating (Firebat)' in acquired_items }}" title="Juggernaut Plating (Firebat)" /></td>
<td><img src="{{ icons['Concussive Shells (Marauder)'] }}" class="{{ 'acquired' if 'Concussive Shells (Marauder)' in acquired_items }}" title="Concussive Shells (Marauder)" /></td>
<td><img src="{{ icons['Kinetic Foam (Marauder)'] }}" class="{{ 'acquired' if 'Kinetic Foam (Marauder)' in acquired_items }}" title="Kinetic Foam (Marauder)" /></td>
<td><img src="{{ icons['U-238 Rounds (Reaper)'] }}" class="{{ 'acquired' if 'U-238 Rounds (Reaper)' in acquired_items }}" title="U-238 Rounds (Reaper)" /></td>
<td><img src="{{ icons['G-4 Clusterbomb (Reaper)'] }}" class="{{ 'acquired' if 'G-4 Clusterbomb (Reaper)' in acquired_items }}" title="G-4 Clusterbomb (Reaper)" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Vehicles
</td>
</tr>
<tr>
<td colspan="2"><img src="{{ icons['Hellion'] }}" class="{{ 'acquired' if 'Hellion' in acquired_items }}" title="Hellion" /></td>
<td colspan="2"><img src="{{ icons['Vulture'] }}" class="{{ 'acquired' if 'Vulture' in acquired_items }}" title="Vulture" /></td>
<td colspan="2"><img src="{{ icons['Goliath'] }}" class="{{ 'acquired' if 'Goliath' in acquired_items }}" title="Goliath" /></td>
<td colspan="2"><img src="{{ icons['Diamondback'] }}" class="{{ 'acquired' if 'Diamondback' in acquired_items }}" title="Diamondback" /></td>
<td colspan="2"><img src="{{ icons['Siege Tank'] }}" class="{{ 'acquired' if 'Siege Tank' in acquired_items }}" title="Siege Tank" /></td>
</tr>
<tr>
<td><img src="{{ icons['Twin-Linked Flamethrower (Hellion)'] }}" class="{{ 'acquired' if 'Twin-Linked Flamethrower (Hellion)' in acquired_items }}" title="Twin-Linked Flamethrower (Hellion)" /></td>
<td><img src="{{ icons['Thermite Filaments (Hellion)'] }}" class="{{ 'acquired' if 'Thermite Filaments (Hellion)' in acquired_items }}" title="Thermite Filaments (Hellion)" /></td>
<td><img src="{{ icons['Cerberus Mine (Vulture)'] }}" class="{{ 'acquired' if 'Cerberus Mine (Vulture)' in acquired_items }}" title="Cerberus Mine (Vulture)" /></td>
<td><img src="{{ icons['Replenishable Magazine (Vulture)'] }}" class="{{ 'acquired' if 'Replenishable Magazine (Vulture)' in acquired_items }}" title="Replenishable Magazine (Vulture)" /></td>
<td><img src="{{ icons['Multi-Lock Weapons System (Goliath)'] }}" class="{{ 'acquired' if 'Multi-Lock Weapons System (Goliath)' in acquired_items }}" title="Multi-Lock Weapons System (Goliath)" /></td>
<td><img src="{{ icons['Ares-Class Targeting System (Goliath)'] }}" class="{{ 'acquired' if 'Ares-Class Targeting System (Goliath)' in acquired_items }}" title="Ares-Class Targeting System (Goliath)" /></td>
<td><img src="{{ icons['Tri-Lithium Power Cell (Diamondback)'] }}" class="{{ 'acquired' if 'Tri-Lithium Power Cell (Diamondback)' in acquired_items }}" title="Tri-Lithium Power Cell (Diamondback)" /></td>
<td><img src="{{ icons['Shaped Hull (Diamondback)'] }}" class="{{ 'acquired' if 'Shaped Hull (Diamondback)' in acquired_items }}" title="Shaped Hull (Diamondback)" /></td>
<td><img src="{{ icons['Maelstrom Rounds (Siege Tank)'] }}" class="{{ 'acquired' if 'Maelstrom Rounds (Siege Tank)' in acquired_items }}" title="Maelstrom Rounds (Siege Tank)" /></td>
<td><img src="{{ icons['Shaped Blast (Siege Tank)'] }}" class="{{ 'acquired' if 'Shaped Blast (Siege Tank)' in acquired_items }}" title="Shaped Blast (Siege Tank)" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Starships
</td>
</tr>
<tr>
<td colspan="2"><img src="{{ icons['Medivac'] }}" class="{{ 'acquired' if 'Medivac' in acquired_items }}" title="Medivac" /></td>
<td colspan="2"><img src="{{ icons['Wraith'] }}" class="{{ 'acquired' if 'Wraith' in acquired_items }}" title="Wraith" /></td>
<td colspan="2"><img src="{{ icons['Viking'] }}" class="{{ 'acquired' if 'Viking' in acquired_items }}" title="Viking" /></td>
<td colspan="2"><img src="{{ icons['Banshee'] }}" class="{{ 'acquired' if 'Banshee' in acquired_items }}" title="Banshee" /></td>
<td colspan="2"><img src="{{ icons['Battlecruiser'] }}" class="{{ 'acquired' if 'Battlecruiser' in acquired_items }}" title="Battlecruiser" /></td>
</tr>
<tr>
<td><img src="{{ icons['Rapid Deployment Tube (Medivac)'] }}" class="{{ 'acquired' if 'Rapid Deployment Tube (Medivac)' in acquired_items }}" title="Rapid Deployment Tube (Medivac)" /></td>
<td><img src="{{ icons['Advanced Healing AI (Medivac)'] }}" class="{{ 'acquired' if 'Advanced Healing AI (Medivac)' in acquired_items }}" title="Advanced Healing AI (Medivac)" /></td>
<td><img src="{{ icons['Tomahawk Power Cells (Wraith)'] }}" class="{{ 'acquired' if 'Tomahawk Power Cells (Wraith)' in acquired_items }}" title="Tomahawk Power Cells (Wraith)" /></td>
<td><img src="{{ icons['Displacement Field (Wraith)'] }}" class="{{ 'acquired' if 'Displacement Field (Wraith)' in acquired_items }}" title="Displacement Field (Wraith)" /></td>
<td><img src="{{ icons['Ripwave Missiles (Viking)'] }}" class="{{ 'acquired' if 'Ripwave Missiles (Viking)' in acquired_items }}" title="Ripwave Missiles (Viking)" /></td>
<td><img src="{{ icons['Phobos-Class Weapons System (Viking)'] }}" class="{{ 'acquired' if 'Phobos-Class Weapons System (Viking)' in acquired_items }}" title="Phobos-Class Weapons System (Viking)" /></td>
<td><img src="{{ icons['Cross-Spectrum Dampeners (Banshee)'] }}" class="{{ 'acquired' if 'Cross-Spectrum Dampeners (Banshee)' in acquired_items }}" title="Cross-Spectrum Dampeners (Banshee)" /></td>
<td><img src="{{ icons['Shockwave Missile Battery (Banshee)'] }}" class="{{ 'acquired' if 'Shockwave Missile Battery (Banshee)' in acquired_items }}" title="Shockwave Missile Battery (Banshee)" /></td>
<td><img src="{{ icons['Missile Pods (Battlecruiser)'] }}" class="{{ 'acquired' if 'Missile Pods (Battlecruiser)' in acquired_items }}" title="Missile Pods (Battlecruiser)" /></td>
<td><img src="{{ icons['Defensive Matrix (Battlecruiser)'] }}" class="{{ 'acquired' if 'Defensive Matrix (Battlecruiser)' in acquired_items }}" title="Defensive Matrix (Battlecruiser)" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Dominion
</td>
</tr>
<tr>
<td colspan="2"><img src="{{ icons['Ghost'] }}" class="{{ 'acquired' if 'Ghost' in acquired_items }}" title="Ghost" /></td>
<td colspan="2"><img src="{{ icons['Spectre'] }}" class="{{ 'acquired' if 'Spectre' in acquired_items }}" title="Spectre" /></td>
<td colspan="2"><img src="{{ icons['Thor'] }}" class="{{ 'acquired' if 'Thor' in acquired_items }}" title="Thor" /></td>
</tr>
<tr>
<td><img src="{{ icons['Ocular Implants (Ghost)'] }}" class="{{ 'acquired' if 'Ocular Implants (Ghost)' in acquired_items }}" title="Ocular Implants (Ghost)" /></td>
<td><img src="{{ icons['Crius Suit (Ghost)'] }}" class="{{ 'acquired' if 'Crius Suit (Ghost)' in acquired_items }}" title="Crius Suit (Ghost)" /></td>
<td><img src="{{ icons['Psionic Lash (Spectre)'] }}" class="{{ 'acquired' if 'Psionic Lash (Spectre)' in acquired_items }}" title="Psionic Lash (Spectre)" /></td>
<td><img src="{{ icons['Nyx-Class Cloaking Module (Spectre)'] }}" class="{{ 'acquired' if 'Nyx-Class Cloaking Module (Spectre)' in acquired_items }}" title="Nyx-Class Cloaking Module (Spectre)" /></td>
<td><img src="{{ icons['330mm Barrage Cannon (Thor)'] }}" class="{{ 'acquired' if '330mm Barrage Cannon (Thor)' in acquired_items }}" title="330mm Barrage Cannon (Thor)" /></td>
<td><img src="{{ icons['Immortality Protocol (Thor)'] }}" class="{{ 'acquired' if 'Immortality Protocol (Thor)' in acquired_items }}" title="Immortality Protocol (Thor)" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Mercenaries
</td>
</tr>
<tr>
<td><img src="{{ icons['War Pigs'] }}" class="{{ 'acquired' if 'War Pigs' in acquired_items }}" title="War Pigs" /></td>
<td><img src="{{ icons['Devil Dogs'] }}" class="{{ 'acquired' if 'Devil Dogs' in acquired_items }}" title="Devil Dogs" /></td>
<td><img src="{{ icons['Hammer Securities'] }}" class="{{ 'acquired' if 'Hammer Securities' in acquired_items }}" title="Hammer Securities" /></td>
<td><img src="{{ icons['Spartan Company'] }}" class="{{ 'acquired' if 'Spartan Company' in acquired_items }}" title="Spartan Company" /></td>
<td><img src="{{ icons['Siege Breakers'] }}" class="{{ 'acquired' if 'Siege Breakers' in acquired_items }}" title="Siege Breakers" /></td>
<td><img src="{{ icons['Hel\'s Angel'] }}" class="{{ 'acquired' if 'Hel\'s Angel' in acquired_items }}" title="Hel's Angel" /></td>
<td><img src="{{ icons['Dusk Wings'] }}" class="{{ 'acquired' if 'Dusk Wings' in acquired_items }}" title="Dusk Wings" /></td>
<td><img src="{{ icons['Jackson\'s Revenge'] }}" class="{{ 'acquired' if 'Jackson\'s Revenge' in acquired_items }}" title="Jackson's Revenge" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Lab Upgrades
</td>
</tr>
<tr>
<td><img src="{{ icons['Ultra-Capacitors'] }}" class="{{ 'acquired' if 'Ultra-Capacitors' in acquired_items }}" title="Ultra-Capacitors" /></td>
<td><img src="{{ icons['Vanadium Plating'] }}" class="{{ 'acquired' if 'Vanadium Plating' in acquired_items }}" title="Vanadium Plating" /></td>
<td><img src="{{ icons['Orbital Depots'] }}" class="{{ 'acquired' if 'Orbital Depots' in acquired_items }}" title="Orbital Depots" /></td>
<td><img src="{{ icons['Micro-Filtering'] }}" class="{{ 'acquired' if 'Micro-Filtering' in acquired_items }}" title="Micro-Filtering" /></td>
<td><img src="{{ icons['Automated Refinery'] }}" class="{{ 'acquired' if 'Automated Refinery' in acquired_items }}" title="Automated Refinery" /></td>
<td><img src="{{ icons['Command Center Reactor'] }}" class="{{ 'acquired' if 'Command Center Reactor' in acquired_items }}" title="Command Center Reactor" /></td>
<td><img src="{{ icons['Raven'] }}" class="{{ 'acquired' if 'Raven' in acquired_items }}" title="Raven" /></td>
<td><img src="{{ icons['Science Vessel'] }}" class="{{ 'acquired' if 'Science Vessel' in acquired_items }}" title="Science Vessel" /></td>
<td><img src="{{ icons['Tech Reactor'] }}" class="{{ 'acquired' if 'Tech Reactor' in acquired_items }}" title="Tech Reactor" /></td>
<td><img src="{{ icons['Orbital Strike'] }}" class="{{ 'acquired' if 'Orbital Strike' in acquired_items }}" title="Orbital Strike" /></td>
</tr>
<tr>
<td><img src="{{ icons['Shrike Turret'] }}" class="{{ 'acquired' if 'Shrike Turret' in acquired_items }}" title="Shrike Turret" /></td>
<td><img src="{{ icons['Fortified Bunker'] }}" class="{{ 'acquired' if 'Fortified Bunker' in acquired_items }}" title="Fortified Bunker" /></td>
<td><img src="{{ icons['Planetary Fortress'] }}" class="{{ 'acquired' if 'Planetary Fortress' in acquired_items }}" title="Planetary Fortress" /></td>
<td><img src="{{ icons['Perdition Turret'] }}" class="{{ 'acquired' if 'Perdition Turret' in acquired_items }}" title="Perdition Turret" /></td>
<td><img src="{{ icons['Predator'] }}" class="{{ 'acquired' if 'Predator' in acquired_items }}" title="Predator" /></td>
<td><img src="{{ icons['Hercules'] }}" class="{{ 'acquired' if 'Hercules' in acquired_items }}" title="Hercules" /></td>
<td><img src="{{ icons['Cellular Reactor'] }}" class="{{ 'acquired' if 'Cellular Reactor' in acquired_items }}" title="Cellular Reactor" /></td>
<td><img src="{{ icons['Regenerative Bio-Steel'] }}" class="{{ 'acquired' if 'Regenerative Bio-Steel' in acquired_items }}" title="Regenerative Bio-Steel" /></td>
<td><img src="{{ icons['Hive Mind Emulator'] }}" class="{{ 'acquired' if 'Hive Mind Emulator' in acquired_items }}" title="Hive Mind Emulator" /></td>
<td><img src="{{ icons['Psi Disrupter'] }}" class="{{ 'acquired' if 'Psi Disrupter' in acquired_items }}" title="Psi Disrupter" /></td>
</tr>
<tr>
<td colspan="10" class="title">
Protoss Units
</td>
</tr>
<tr>
<td><img src="{{ icons['Zealot'] }}" class="{{ 'acquired' if 'Zealot' in acquired_items }}" title="Zealot" /></td>
<td><img src="{{ icons['Stalker'] }}" class="{{ 'acquired' if 'Stalker' in acquired_items }}" title="Stalker" /></td>
<td><img src="{{ icons['High Templar'] }}" class="{{ 'acquired' if 'High Templar' in acquired_items }}" title="High Templar" /></td>
<td><img src="{{ icons['Dark Templar'] }}" class="{{ 'acquired' if 'Dark Templar' in acquired_items }}" title="Dark Templar" /></td>
<td><img src="{{ icons['Immortal'] }}" class="{{ 'acquired' if 'Immortal' in acquired_items }}" title="Immortal" /></td>
<td><img src="{{ icons['Colossus'] }}" class="{{ 'acquired' if 'Colossus' in acquired_items }}" title="Colossus" /></td>
<td><img src="{{ icons['Phoenix'] }}" class="{{ 'acquired' if 'Phoenix' in acquired_items }}" title="Phoenix" /></td>
<td><img src="{{ icons['Void Ray'] }}" class="{{ 'acquired' if 'Void Ray' in acquired_items }}" title="Void Ray" /></td>
<td><img src="{{ icons['Carrier'] }}" class="{{ 'acquired' if 'Carrier' in acquired_items }}" title="Carrier" /></td>
</tr>
</table>
<table id="location-table">
{% for area in checks_in_area %}
{% if checks_in_area[area] > 0 %}
<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>
{% endif %}
{% endfor %}
</table>
</div>
</body>
</html>

View File

@@ -29,17 +29,30 @@
<li><a href="/glossary/en">Glossary</a></li>
</ul>
<h2>Tutorials</h2>
<ul>
<li><a href="/tutorial/Archipelago/setup/en">Multiworld Setup Tutorial</a></li>
<li><a href="/tutorial/Archipelago/using_website/en">Website User Guide</a></li>
<li><a href="/tutorial/Archipelago/mac/en">Setup Guide for Mac</a></li>
<li><a href="/tutorial/Archipelago/commands/en">Server and Client Commands</a></li>
<li><a href="/tutorial/Archipelago/advanced_settings/en">Advanced YAML Guide</a></li>
<li><a href="/tutorial/Archipelago/triggers/en">Triggers Guide</a></li>
<li><a href="/tutorial/Archipelago/plando/en">Plando Guide</a></li>
</ul>
<h2>Game Info Pages</h2>
<ul>
{% for game in games %}
<li><a href="{{ url_for('game_info', game=game, lang='en') }}">{{ game }}</a></li>
{% for game in games | title_sorted %}
<li><a href="{{ url_for('game_info', game=game['title'], lang='en') }}">{{ game['title'] }}</a></li>
{% endfor %}
</ul>
<h2>Game Settings Pages</h2>
<ul>
{% for game in games %}
<li><a href="{{ url_for('player_settings', game=game) }}">{{ game }}</a></li>
{% for game in games | title_sorted %}
{% if game['has_settings'] %}
<li><a href="{{ url_for('player_settings', game=game['title']) }}">{{ game['title'] }}</a></li>
{% endif %}
{% endfor %}
</ul>
</div>

View File

@@ -8,79 +8,94 @@
<body>
<div id="player-tracker-wrapper" data-tracker="{{ room.tracker|suuid }}">
<table id="inventory-table">
<tr>
<td><img src="{{ icons['Timespinner Wheel'] }}" class="{{ 'acquired' if 'Timespinner Wheel' in acquired_items }}" title="Timespinner Wheel" /></td>
<td><img src="{{ icons['Timespinner Spindle'] }}" class="{{ 'acquired' if 'Timespinner Spindle' in acquired_items }}" title="Timespinner Spindle" /></td>
<td><img src="{{ icons['Timespinner Gear 1'] }}" class="{{ 'acquired' if 'Timespinner Gear 1' in acquired_items }}" title="Timespinner Gear 1" /></td>
<td><img src="{{ icons['Timespinner Gear 2'] }}" class="{{ 'acquired' if 'Timespinner Gear 2' in acquired_items }}" title="Timespinner Gear 2" /></td>
<td><img src="{{ icons['Timespinner Gear 3'] }}" class="{{ 'acquired' if 'Timespinner Gear 3' in acquired_items }}" title="Timespinner Gear 3" /></td>
</tr>
<tr>
<td><img src="{{ icons['Talaria Attachment'] }}" class="{{ 'acquired' if 'Talaria Attachment' in acquired_items or 'QuickSeed' in options }}" title="Talaria Attachment" /></td>
<td><img src="{{ icons['Succubus Hairpin'] }}" class="{{ 'acquired' if 'Succubus Hairpin' in acquired_items }}" title="Succubus Hairpin" /></td>
<td><img src="{{ icons['Lightwall'] }}" class="{{ 'acquired' if 'Lightwall' in acquired_items }}" title="Lightwall" /></td>
<td><img src="{{ icons['Celestial Sash'] }}" class="{{ 'acquired' if 'Celestial Sash' in acquired_items }}" title="Celestial Sash" /></td>
<td><img src="{{ icons['Twin Pyramid Key'] }}" class="{{ 'acquired' if 'Twin Pyramid Key' in acquired_items }}" title="Twin Pyramid Key" /></td>
</tr>
<tr>
<td><img src="{{ icons['Security Keycard A'] }}" class="{{ 'acquired' if 'Security Keycard A' in acquired_items }}" title="Security Keycard A" /></td>
<td><img src="{{ icons['Security Keycard B'] }}" class="{{ 'acquired' if 'Security Keycard B' in acquired_items }}" title="Security Keycard B" /></td>
<td><img src="{{ icons['Security Keycard C'] }}" class="{{ 'acquired' if 'Security Keycard C' in acquired_items }}" title="Security Keycard C" /></td>
<td><img src="{{ icons['Security Keycard D'] }}" class="{{ 'acquired' if 'Security Keycard D' in acquired_items }}" title="Security Keycard D" /></td>
{% if 'DownloadableItems' in options %}
<td><img src="{{ icons['Library Keycard V'] }}" class="{{ 'acquired' if 'Library Keycard V' in acquired_items }}" title="Library Keycard V" /></td>
{% else %}
<td></td>
{% endif %}
</tr>
<tr>
{% if 'DownloadableItems' in options %}
<td><img src="{{ icons['Tablet'] }}" class="{{ 'acquired' if 'Tablet' in acquired_items }}" title="Tablet" /></td>
{% else %}
<td></td>
{% endif %}
<td><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></td>
{% if 'EyeSpy' in options %}
<td><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></td>
{% else %}
<td></td>
{% endif %}
<td><img src="{{ icons['Water Mask'] }}" class="{{ 'acquired' if 'Water Mask' in acquired_items }}" title="Water Mask" /></td>
<td><img src="{{ icons['Gas Mask'] }}" class="{{ 'acquired' if 'Gas Mask' in acquired_items }}" title="Gas Mask" /></td>
</tr>
<tr>
{% if 'GyreArchives' in options %}
<td><img src="{{ icons['Kobo'] }}" class="{{ 'acquired' if 'Kobo' in acquired_items }}" title="Kobo" /></td>
<td><img src="{{ icons['Merchant Crow'] }}" class="{{ 'acquired' if 'Merchant Crow' in acquired_items }}" title="Merchant Crow" /></td>
{% else %}
<td></td>
<td></td>
{% endif %}
<div id="inventory-table">
<div class="table-row">
<div class="C1"><img src="{{ icons['Timespinner Wheel'] }}" class="{{ 'acquired' if 'Timespinner Wheel' in acquired_items }}" title="Timespinner Wheel" /></div>
<div class="C2"><img src="{{ icons['Timespinner Spindle'] }}" class="{{ 'acquired' if 'Timespinner Spindle' in acquired_items }}" title="Timespinner Spindle" /></div>
<div class="C3"><img src="{{ icons['Timespinner Gear 1'] }}" class="{{ 'acquired' if 'Timespinner Gear 1' in acquired_items }}" title="Timespinner Gear 1" /></div>
<div class="C4"><img src="{{ icons['Timespinner Gear 2'] }}" class="{{ 'acquired' if 'Timespinner Gear 2' in acquired_items }}" title="Timespinner Gear 2" /></div>
<div class="C5"><img src="{{ icons['Timespinner Gear 3'] }}" class="{{ 'acquired' if 'Timespinner Gear 3' in acquired_items }}" title="Timespinner Gear 3" /></div>
</div>
<div class="table-row">
<div class="C1"><img src="{{ icons['Talaria Attachment'] }}" class="{{ 'acquired' if 'Talaria Attachment' in acquired_items or 'QuickSeed' in options }}" title="Talaria Attachment" /></div>
<div class="C2"><img src="{{ icons['Succubus Hairpin'] }}" class="{{ 'acquired' if 'Succubus Hairpin' in acquired_items }}" title="Succubus Hairpin" /></div>
<div class="C3"><img src="{{ icons['Lightwall'] }}" class="{{ 'acquired' if 'Lightwall' in acquired_items }}" title="Lightwall" /></div>
<div class="C4"><img src="{{ icons['Celestial Sash'] }}" class="{{ 'acquired' if 'Celestial Sash' in acquired_items }}" title="Celestial Sash" /></div>
<div class="C5">
<div class="image-stack">
<div class="stack-back">
<img src="{{ icons['Twin Pyramid Key'] }}" class="{{ 'acquired' if 'Twin Pyramid Key' in acquired_items or 'UnchainedKeys' in options }}" title="Twin Pyramid Key" />
</div>
<div class="stack-front">
{% if 'UnchainedKeys' in options %}
{% if 'EnterSandman' in options %}
<div class="stack-top-right">
<img src="{{ icons['Twin Pyramid Key'] }}" class="green {{ 'acquired' if 'Mysterious Warp Beacon' in acquired_items }}" title="Mysterious Warp Beacon" />
</div>
{% endif %}
<div class="stack-bottum-left">
<img src="{{ icons['Twin Pyramid Key'] }}" class="cyan {{ 'acquired' if 'Timeworn Warp Beacon' in acquired_items }}" title="Timeworn Warp Beacon" />
</div>
<div class="stack-bottum-right">
<img src="{{ icons['Twin Pyramid Key'] }}" class="purple {{ 'acquired' if 'Modern Warp Beacon' in acquired_items }}" title="Modern Warp Beacon" />
</div>
{% endif %}
</div>
</div>
</div>
</div>
<div class="table-row">
<div class="C1"><img src="{{ icons['Security Keycard A'] }}" class="{{ 'acquired' if 'Security Keycard A' in acquired_items }}" title="Security Keycard A" /></div>
<div class="C2"><img src="{{ icons['Security Keycard B'] }}" class="{{ 'acquired' if 'Security Keycard B' in acquired_items }}" title="Security Keycard B" /></div>
<div class="C3"><img src="{{ icons['Security Keycard C'] }}" class="{{ 'acquired' if 'Security Keycard C' in acquired_items }}" title="Security Keycard C" /></div>
<div class="C4"><img src="{{ icons['Security Keycard D'] }}" class="{{ 'acquired' if 'Security Keycard D' in acquired_items }}" title="Security Keycard D" /></div>
{% if 'DownloadableItems' in options %}
<div class="C5"><img src="{{ icons['Library Keycard V'] }}" class="{{ 'acquired' if 'Library Keycard V' in acquired_items }}" title="Library Keycard V" /></div>
{% endif %}
</div>
<div class="table-row">
{% if 'DownloadableItems' in options %}
<div class="C1"><img src="{{ icons['Tablet'] }}" class="{{ 'acquired' if 'Tablet' in acquired_items }}" title="Tablet" /></div>
{% endif %}
<div class="C2"><img src="{{ icons['Elevator Keycard'] }}" class="{{ 'acquired' if 'Elevator Keycard' in acquired_items }}" title="Elevator Keycard" /></div>
{% if 'EyeSpy' in options %}
<div class="C3"><img src="{{ icons['Oculus Ring'] }}" class="{{ 'acquired' if 'Oculus Ring' in acquired_items }}" title="Oculus Ring" /></div>
{% endif %}
<div class="C4"><img src="{{ icons['Water Mask'] }}" class="{{ 'acquired' if 'Water Mask' in acquired_items }}" title="Water Mask" /></div>
<div class="C5"><img src="{{ icons['Gas Mask'] }}" class="{{ 'acquired' if 'Gas Mask' in acquired_items }}" title="Gas Mask" /></div>
</div>
<div class="table-row">
{% if 'GyreArchives' in options %}
<div class="C1"><img src="{{ icons['Kobo'] }}" class="{{ 'acquired' if 'Kobo' in acquired_items }}" title="Kobo" /></div>
<div class="C2"><img src="{{ icons['Merchant Crow'] }}" class="{{ 'acquired' if 'Merchant Crow' in acquired_items }}" title="Merchant Crow" /></div>
{% endif %}
<div class="C3">
{% if 'Djinn Inferno' in acquired_items %}
<td><img src="{{ icons['Djinn Inferno'] }}" class="acquired" title="Djinn Inferno" /></td>
<img src="{{ icons['Djinn Inferno'] }}" class="acquired" title="Djinn Inferno" />
{% elif 'Pyro Ring' in acquired_items %}
<td><img src="{{ icons['Pyro Ring'] }}" class="acquired" title="Pyro Ring" /></td>
<img src="{{ icons['Pyro Ring'] }}" class="acquired" title="Pyro Ring" />
{% elif 'Fire Orb' in acquired_items %}
<td><img src="{{ icons['Fire Orb'] }}" class="acquired" title="Fire Orb" /></td>
<img src="{{ icons['Fire Orb'] }}" class="acquired" title="Fire Orb" />
{% elif 'Infernal Flames' in acquired_items %}
<td><img src="{{ icons['Infernal Flames'] }}" class="acquired" title="Infernal Flames" /></td>
<img src="{{ icons['Infernal Flames'] }}" class="acquired" title="Infernal Flames" />
{% else %}
<td><img src="{{ icons['Djinn Inferno'] }}" title="Djinn Inferno" /></td>
<img src="{{ icons['Djinn Inferno'] }}" title="Djinn Inferno" />
{% endif %}
</div>
<div class="C4">
{% if 'Royal Ring' in acquired_items %}
<td><img src="{{ icons['Royal Ring'] }}" class="acquired" title="Royal Ring" /></td>
<img src="{{ icons['Royal Ring'] }}" class="acquired" title="Royal Ring" />
{% elif 'Plasma Geyser' in acquired_items %}
<td><img src="{{ icons['Plasma Geyser'] }}" class="acquired" title="Plasma Geyser" /></td>
<img src="{{ icons['Plasma Geyser'] }}" class="acquired" title="Plasma Geyser" />
{% elif 'Plasma Orb' in acquired_items %}
<td><img src="{{ icons['Plasma Orb'] }}" class="acquired" title="Plasma Orb" /></td>
<img src="{{ icons['Plasma Orb'] }}" class="acquired" title="Plasma Orb" />
{% else %}
<td><img src="{{ icons['Royal Ring'] }}" title="Royal Ring" /></td>
<img src="{{ icons['Royal Ring'] }}" title="Royal Ring" />
{% endif %}
</tr>
</table>
</div>
</div>
</div>
<table id="location-table">
{% for area in checks_done %}
<tr class="location-category" id="{{area}}-header">

View File

@@ -5,15 +5,16 @@ from typing import Counter, Optional, Dict, Any, Tuple
from uuid import UUID
from flask import render_template
from jinja2 import pass_context, runtime
from werkzeug.exceptions import abort
from MultiServer import Context
from MultiServer import Context, get_saving_second
from NetUtils import SlotType
from Utils import restricted_loads
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name
from worlds import lookup_any_item_id_to_name, lookup_any_location_id_to_name, network_data_package
from worlds.alttp import Items
from . import app, cache
from .models import Room
from .models import GameDataPackage, Room
alttp_icons = {
"Blue Shield": r"https://www.zeldadungeon.net/wiki/images/8/85/Fighters-Shield.png",
@@ -83,9 +84,6 @@ def get_alttp_id(item_name):
return Items.item_table[item_name][2]
app.jinja_env.filters["location_name"] = lambda location: lookup_any_location_id_to_name.get(location, location)
app.jinja_env.filters['item_name'] = lambda id: lookup_any_item_id_to_name.get(id, id)
links = {"Bow": "Progressive Bow",
"Silver Arrows": "Progressive Bow",
"Silver Bow": "Progressive Bow",
@@ -212,14 +210,6 @@ del data
del item
def attribute_item(inventory, team, recipient, item):
target_item = links.get(item, item)
if item in levels: # non-progressive
inventory[team][recipient][target_item] = max(inventory[team][recipient][target_item], levels[item])
else:
inventory[team][recipient][target_item] += 1
def attribute_item_solo(inventory, item):
"""Adds item to inventory counter, converts everything to progressive."""
target_item = links.get(item, item)
@@ -237,6 +227,23 @@ def render_timedelta(delta: datetime.timedelta):
return f"{hours}:{minutes}"
@pass_context
def get_location_name(context: runtime.Context, loc: int) -> str:
# once all rooms embed data package, the chain lookup can be dropped
context_locations = context.get("custom_locations", {})
return collections.ChainMap(context_locations, lookup_any_location_id_to_name).get(loc, loc)
@pass_context
def get_item_name(context: runtime.Context, item: int) -> str:
context_items = context.get("custom_items", {})
return collections.ChainMap(context_items, lookup_any_item_id_to_name).get(item, item)
app.jinja_env.filters["location_name"] = get_location_name
app.jinja_env.filters["item_name"] = get_item_name
_multidata_cache = {}
@@ -258,10 +265,33 @@ def get_static_room_data(room: Room):
# in > 100 players this can take a bit of time and is the main reason for the cache
locations: Dict[int, Dict[int, Tuple[int, int, int]]] = multidata['locations']
names: Dict[int, Dict[int, str]] = multidata["names"]
games = {}
groups = {}
custom_locations = {}
custom_items = {}
if "slot_info" in multidata:
games = {slot: slot_info.game for slot, slot_info in multidata["slot_info"].items()}
groups = {slot: slot_info.group_members for slot, slot_info in multidata["slot_info"].items()
if slot_info.type == SlotType.group}
for game in games.values():
if game not in multidata["datapackage"]:
continue
game_data = multidata["datapackage"][game]
if "checksum" in game_data:
if network_data_package["games"].get(game, {}).get("checksum") == game_data["checksum"]:
# non-custom. remove from multidata
# network_data_package import could be skipped once all rooms embed data package
del multidata["datapackage"][game]
continue
else:
game_data = restricted_loads(GameDataPackage.get(checksum=game_data["checksum"]).data)
custom_locations.update(
{id_: name for name, id_ in game_data["location_name_to_id"].items()})
custom_items.update(
{id_: name for name, id_ in game_data["item_name_to_id"].items()})
elif "games" in multidata:
games = multidata["games"]
seed_checks_in_area = checks_in_area.copy()
use_door_tracker = False
@@ -280,16 +310,26 @@ def get_static_room_data(room: Room):
player_location_to_area = {playernumber: get_location_table(multidata["checks_in_area"][playernumber])
for playernumber in range(1, len(names[0]) + 1)
if playernumber not in groups}
saving_second = get_saving_second(multidata["seed_name"])
result = locations, names, use_door_tracker, player_checks_in_area, player_location_to_area, \
multidata["precollected_items"], multidata["games"], multidata["slot_data"], groups
multidata["precollected_items"], games, multidata["slot_data"], groups, saving_second, \
custom_locations, custom_items
_multidata_cache[room.seed.id] = result
return result
@app.route('/tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False):
def get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool = False):
key = f"{tracker}_{tracked_team}_{tracked_player}_{want_generic}"
tracker_page = cache.get(key)
if tracker_page:
return tracker_page
timeout, tracker_page = _get_player_tracker(tracker, tracked_team, tracked_player, want_generic)
cache.set(key, tracker_page, timeout)
return tracker_page
def _get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, want_generic: bool):
# Team and player must be positive and greater than zero
if tracked_team < 0 or tracked_player < 1:
abort(404)
@@ -300,7 +340,8 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
# Collect seed information and pare it down to a single player
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
precollected_items, games, slot_data, groups = get_static_room_data(room)
precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \
get_static_room_data(room)
player_name = names[tracked_team][tracked_player - 1]
location_to_area = player_location_to_area[tracked_player]
inventory = collections.Counter()
@@ -338,21 +379,24 @@ def getPlayerTracker(tracker: UUID, tracked_team: int, tracked_player: int, want
checks_done["Total"] += 1
specific_tracker = game_specific_trackers.get(games[tracked_player], None)
if specific_tracker and not want_generic:
return specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
seed_checks_in_area, checks_done, slot_data[tracked_player])
tracker = specific_tracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
seed_checks_in_area, checks_done, slot_data[tracked_player], saving_second)
else:
return __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
seed_checks_in_area, checks_done)
tracker = __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
seed_checks_in_area, checks_done, saving_second, custom_locations, custom_items)
return (saving_second - datetime.datetime.now().second) % 60 or 60, tracker
@app.route('/generic_tracker/<suuid:tracker>/<int:tracked_team>/<int:tracked_player>')
def get_generic_tracker(tracker: UUID, tracked_team: int, tracked_player: int):
return getPlayerTracker(tracker, tracked_team, tracked_player, True)
return get_player_tracker(tracker, tracked_team, tracked_player, True)
def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, player_name: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict,
saving_second: int) -> str:
# Note the presence of the triforce item
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
@@ -414,7 +458,8 @@ def __renderAlttpTracker(multisave: Dict[str, Any], room: Room, locations: Dict[
def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict,
saving_second: int) -> str:
icons = {
"Wooden Pickaxe": "https://static.wikia.nocookie.net/minecraft_gamepedia/images/d/d2/Wooden_Pickaxe_JE3_BE3.png",
@@ -444,7 +489,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
"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",
"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",
@@ -452,7 +497,7 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
}
minecraft_location_ids = {
"Story": [42073, 42023, 42027, 42039, 42002, 42009, 42010, 42070,
"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],
@@ -516,14 +561,15 @@ def __renderMinecraftTracker(multisave: Dict[str, Any], room: Room, locations: D
inventory=inventory, icons=icons,
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
id in lookup_any_item_id_to_name},
player=player, team=team, room=room, player_name=playerName,
player=player, team=team, room=room, player_name=playerName, saving_second = saving_second,
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
**display_data)
def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict,
saving_second: int) -> str:
icons = {
"Fairy Ocarina": "https://static.wikia.nocookie.net/zelda_gamepedia_en/images/9/97/OoT_Fairy_Ocarina_Icon.png",
@@ -613,7 +659,7 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
if base_name == "hookshot":
display_data['hookshot_length'] = {0: '', 1: 'H', 2: 'L'}.get(level)
if base_name == "wallet":
if base_name == "wallet":
display_data['wallet_size'] = {0: '99', 1: '200', 2: '500', 3: '999'}.get(level)
# Determine display for bottles. Show letter if it's obtained, determine bottle count
@@ -631,48 +677,51 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
}
for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower()
count = inventory[item_id]
display_data[base_name+"_count"] = inventory[item_id]
# Gather dungeon locations
area_id_ranges = {
"Overworld": (67000, 67280),
"Deku Tree": (67281, 67303),
"Dodongo's Cavern": (67304, 67334),
"Jabu Jabu's Belly": (67335, 67359),
"Bottom of the Well": (67360, 67384),
"Forest Temple": (67385, 67420),
"Fire Temple": (67421, 67457),
"Water Temple": (67458, 67484),
"Shadow Temple": (67485, 67532),
"Spirit Temple": (67533, 67582),
"Ice Cavern": (67583, 67596),
"Gerudo Training Ground": (67597, 67635),
"Thieves' Hideout": (67259, 67263),
"Ganon's Castle": (67636, 67673),
"Overworld": ((67000, 67263), (67269, 67280), (67747, 68024), (68054, 68062)),
"Deku Tree": ((67281, 67303), (68063, 68077)),
"Dodongo's Cavern": ((67304, 67334), (68078, 68160)),
"Jabu Jabu's Belly": ((67335, 67359), (68161, 68188)),
"Bottom of the Well": ((67360, 67384), (68189, 68230)),
"Forest Temple": ((67385, 67420), (68231, 68281)),
"Fire Temple": ((67421, 67457), (68282, 68350)),
"Water Temple": ((67458, 67484), (68351, 68483)),
"Shadow Temple": ((67485, 67532), (68484, 68565)),
"Spirit Temple": ((67533, 67582), (68566, 68625)),
"Ice Cavern": ((67583, 67596), (68626, 68649)),
"Gerudo Training Ground": ((67597, 67635), (68650, 68656)),
"Thieves' Hideout": ((67264, 67268), (68025, 68053)),
"Ganon's Castle": ((67636, 67673), (68657, 68705)),
}
def lookup_and_trim(id, area):
full_name = lookup_any_location_id_to_name[id]
if id == 67673:
return full_name[13:] # Ganons Tower Boss Key Chest
if 'Ganons Tower' in full_name:
return full_name
if area not in ["Overworld", "Thieves' Hideout"]:
# trim dungeon name. leaves an extra space that doesn't display, or trims fully for DC/Jabu/GC
return full_name[len(area):]
return full_name
checked_locations = multisave.get("location_checks", {}).get((team, player), set()).intersection(set(locations[player]))
location_info = {area: {lookup_and_trim(id, area): id in checked_locations for id in range(min_id, max_id+1) if id in locations[player]}
for area, (min_id, max_id) in area_id_ranges.items()}
checks_done = {area: len(list(filter(lambda x: x, location_info[area].values()))) for area in area_id_ranges}
checks_in_area = {area: len([id for id in range(min_id, max_id+1) if id in locations[player]])
for area, (min_id, max_id) in area_id_ranges.items()}
# Remove Thieves' Hideout checks from Overworld, since it's in the middle of the range
checks_in_area["Overworld"] -= checks_in_area["Thieves' Hideout"]
checks_done["Overworld"] -= checks_done["Thieves' Hideout"]
for loc in location_info["Thieves' Hideout"]:
del location_info["Overworld"][loc]
location_info = {}
checks_done = {}
checks_in_area = {}
for area, ranges in area_id_ranges.items():
location_info[area] = {}
checks_done[area] = 0
checks_in_area[area] = 0
for r in ranges:
min_id, max_id = r
for id in range(min_id, max_id+1):
if id in locations[player]:
checked = id in checked_locations
location_info[area][lookup_and_trim(id, area)] = checked
checks_in_area[area] += 1
checks_done[area] += checked
checks_done['Total'] = sum(checks_done.values())
checks_in_area['Total'] = sum(checks_in_area.values())
@@ -683,25 +732,28 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
if "GS" in lookup_and_trim(id, ''):
display_data["token_count"] += 1
oot_y = ''
oot_x = ''
# Gather small and boss key info
small_key_counts = {
"Forest Temple": inventory[66175],
"Fire Temple": inventory[66176],
"Water Temple": inventory[66177],
"Spirit Temple": inventory[66178],
"Shadow Temple": inventory[66179],
"Bottom of the Well": inventory[66180],
"Gerudo Training Ground": inventory[66181],
"Thieves' Hideout": inventory[66182],
"Ganon's Castle": inventory[66183],
"Forest Temple": oot_y if inventory[66203] else inventory[66175],
"Fire Temple": oot_y if inventory[66204] else inventory[66176],
"Water Temple": oot_y if inventory[66205] else inventory[66177],
"Spirit Temple": oot_y if inventory[66206] else inventory[66178],
"Shadow Temple": oot_y if inventory[66207] else inventory[66179],
"Bottom of the Well": oot_y if inventory[66208] else inventory[66180],
"Gerudo Training Ground": oot_y if inventory[66209] else inventory[66181],
"Thieves' Hideout": oot_y if inventory[66210] else inventory[66182],
"Ganon's Castle": oot_y if inventory[66211] else inventory[66183],
}
boss_key_counts = {
"Forest Temple": '' if inventory[66149] else '',
"Fire Temple": '' if inventory[66150] else '',
"Water Temple": '' if inventory[66151] else '',
"Spirit Temple": '' if inventory[66152] else '',
"Shadow Temple": '' if inventory[66153] else '',
"Ganon's Castle": '' if inventory[66154] else '',
"Forest Temple": oot_y if inventory[66149] else oot_x,
"Fire Temple": oot_y if inventory[66150] else oot_x,
"Water Temple": oot_y if inventory[66151] else oot_x,
"Spirit Temple": oot_y if inventory[66152] else oot_x,
"Shadow Temple": oot_y if inventory[66153] else oot_x,
"Ganon's Castle": oot_y if inventory[66154] else oot_x,
}
# Victory condition
@@ -718,7 +770,8 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict[str, Any]) -> str:
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int],
slot_data: Dict[str, Any], saving_second: int) -> str:
icons = {
"Timespinner Wheel": "https://timespinnerwiki.com/mediawiki/images/7/76/Timespinner_Wheel.png",
@@ -753,7 +806,7 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
}
timespinner_location_ids = {
"Present": [
"Present": [
1337000, 1337001, 1337002, 1337003, 1337004, 1337005, 1337006, 1337007, 1337008, 1337009,
1337010, 1337011, 1337012, 1337013, 1337014, 1337015, 1337016, 1337017, 1337018, 1337019,
1337020, 1337021, 1337022, 1337023, 1337024, 1337025, 1337026, 1337027, 1337028, 1337029,
@@ -774,20 +827,20 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
1337150, 1337151, 1337152, 1337153, 1337154, 1337155,
1337171, 1337172, 1337173, 1337174, 1337175],
"Ancient Pyramid": [
1337236,
1337236,
1337246, 1337247, 1337248, 1337249]
}
if(slot_data["DownloadableItems"]):
timespinner_location_ids["Present"] += [
1337156, 1337157, 1337159,
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
1337160, 1337161, 1337162, 1337163, 1337164, 1337165, 1337166, 1337167, 1337168, 1337169,
1337170]
if(slot_data["Cantoran"]):
timespinner_location_ids["Past"].append(1337176)
if(slot_data["LoreChecks"]):
timespinner_location_ids["Present"] += [
1337177, 1337178, 1337179,
1337177, 1337178, 1337179,
1337180, 1337181, 1337182, 1337183, 1337184, 1337185, 1337186, 1337187]
timespinner_location_ids["Past"] += [
1337188, 1337189,
@@ -824,7 +877,8 @@ def __renderTimespinnerTracker(multisave: Dict[str, Any], room: Room, locations:
def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict) -> str:
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict,
saving_second: int) -> str:
icons = {
"Energy Tank": "https://randommetroidsolver.pythonanywhere.com/solver/static/images/tracker/inventory/ETank.png",
@@ -922,45 +976,390 @@ def __renderSuperMetroidTracker(multisave: Dict[str, Any], room: Room, locations
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
**display_data)
def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int],
slot_data: Dict, saving_second: int) -> str:
SC2WOL_LOC_ID_OFFSET = 1000
SC2WOL_ITEM_ID_OFFSET = 1000
icons = {
"Starting Minerals": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-mineral-protoss.png",
"Starting Vespene": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/icons/icon-gas-terran.png",
"Starting Supply": "https://static.wikia.nocookie.net/starcraft/images/d/d3/TerranSupply_SC2_Icon1.gif",
"Infantry Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel1.png",
"Infantry Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel2.png",
"Infantry Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryweaponslevel3.png",
"Infantry Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel1.png",
"Infantry Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel2.png",
"Infantry Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-infantryarmorlevel3.png",
"Vehicle Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel1.png",
"Vehicle Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel2.png",
"Vehicle Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleweaponslevel3.png",
"Vehicle Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel1.png",
"Vehicle Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel2.png",
"Vehicle Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-vehicleplatinglevel3.png",
"Ship Weapons Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel1.png",
"Ship Weapons Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel2.png",
"Ship Weapons Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipweaponslevel3.png",
"Ship Armor Level 1": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel1.png",
"Ship Armor Level 2": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel2.png",
"Ship Armor Level 3": "https://sclegacy.com/images/uploaded/starcraftii_beta/gamefiles/upgrades/btn-upgrade-terran-shipplatinglevel3.png",
"Bunker": "https://static.wikia.nocookie.net/starcraft/images/c/c5/Bunker_SC2_Icon1.jpg",
"Missile Turret": "https://static.wikia.nocookie.net/starcraft/images/5/5f/MissileTurret_SC2_Icon1.jpg",
"Sensor Tower": "https://static.wikia.nocookie.net/starcraft/images/d/d2/SensorTower_SC2_Icon1.jpg",
"Projectile Accelerator (Bunker)": "https://0rganics.org/archipelago/sc2wol/ProjectileAccelerator.png",
"Neosteel Bunker (Bunker)": "https://0rganics.org/archipelago/sc2wol/NeosteelBunker.png",
"Titanium Housing (Missile Turret)": "https://0rganics.org/archipelago/sc2wol/TitaniumHousing.png",
"Hellstorm Batteries (Missile Turret)": "https://0rganics.org/archipelago/sc2wol/HellstormBatteries.png",
"Advanced Construction (SCV)": "https://0rganics.org/archipelago/sc2wol/AdvancedConstruction.png",
"Dual-Fusion Welders (SCV)": "https://0rganics.org/archipelago/sc2wol/Dual-FusionWelders.png",
"Fire-Suppression System (Building)": "https://0rganics.org/archipelago/sc2wol/Fire-SuppressionSystem.png",
"Orbital Command (Building)": "https://0rganics.org/archipelago/sc2wol/OrbitalCommandCampaign.png",
"Marine": "https://static.wikia.nocookie.net/starcraft/images/4/47/Marine_SC2_Icon1.jpg",
"Medic": "https://static.wikia.nocookie.net/starcraft/images/7/74/Medic_SC2_Rend1.jpg",
"Firebat": "https://static.wikia.nocookie.net/starcraft/images/3/3c/Firebat_SC2_Rend1.jpg",
"Marauder": "https://static.wikia.nocookie.net/starcraft/images/b/ba/Marauder_SC2_Icon1.jpg",
"Reaper": "https://static.wikia.nocookie.net/starcraft/images/7/7d/Reaper_SC2_Icon1.jpg",
"Stimpack (Marine)": "https://0rganics.org/archipelago/sc2wol/StimpacksCampaign.png",
"Combat Shield (Marine)": "https://0rganics.org/archipelago/sc2wol/CombatShieldCampaign.png",
"Advanced Medic Facilities (Medic)": "https://0rganics.org/archipelago/sc2wol/AdvancedMedicFacilities.png",
"Stabilizer Medpacks (Medic)": "https://0rganics.org/archipelago/sc2wol/StabilizerMedpacks.png",
"Incinerator Gauntlets (Firebat)": "https://0rganics.org/archipelago/sc2wol/IncineratorGauntlets.png",
"Juggernaut Plating (Firebat)": "https://0rganics.org/archipelago/sc2wol/JuggernautPlating.png",
"Concussive Shells (Marauder)": "https://0rganics.org/archipelago/sc2wol/ConcussiveShellsCampaign.png",
"Kinetic Foam (Marauder)": "https://0rganics.org/archipelago/sc2wol/KineticFoam.png",
"U-238 Rounds (Reaper)": "https://0rganics.org/archipelago/sc2wol/U-238Rounds.png",
"G-4 Clusterbomb (Reaper)": "https://0rganics.org/archipelago/sc2wol/G-4Clusterbomb.png",
"Hellion": "https://static.wikia.nocookie.net/starcraft/images/5/56/Hellion_SC2_Icon1.jpg",
"Vulture": "https://static.wikia.nocookie.net/starcraft/images/d/da/Vulture_WoL.jpg",
"Goliath": "https://static.wikia.nocookie.net/starcraft/images/e/eb/Goliath_WoL.jpg",
"Diamondback": "https://static.wikia.nocookie.net/starcraft/images/a/a6/Diamondback_WoL.jpg",
"Siege Tank": "https://static.wikia.nocookie.net/starcraft/images/5/57/SiegeTank_SC2_Icon1.jpg",
"Twin-Linked Flamethrower (Hellion)": "https://0rganics.org/archipelago/sc2wol/Twin-LinkedFlamethrower.png",
"Thermite Filaments (Hellion)": "https://0rganics.org/archipelago/sc2wol/ThermiteFilaments.png",
"Cerberus Mine (Vulture)": "https://0rganics.org/archipelago/sc2wol/CerberusMine.png",
"Replenishable Magazine (Vulture)": "https://0rganics.org/archipelago/sc2wol/ReplenishableMagazine.png",
"Multi-Lock Weapons System (Goliath)": "https://0rganics.org/archipelago/sc2wol/Multi-LockWeaponsSystem.png",
"Ares-Class Targeting System (Goliath)": "https://0rganics.org/archipelago/sc2wol/Ares-ClassTargetingSystem.png",
"Tri-Lithium Power Cell (Diamondback)": "https://0rganics.org/archipelago/sc2wol/Tri-LithiumPowerCell.png",
"Shaped Hull (Diamondback)": "https://0rganics.org/archipelago/sc2wol/ShapedHull.png",
"Maelstrom Rounds (Siege Tank)": "https://0rganics.org/archipelago/sc2wol/MaelstromRounds.png",
"Shaped Blast (Siege Tank)": "https://0rganics.org/archipelago/sc2wol/ShapedBlast.png",
"Medivac": "https://static.wikia.nocookie.net/starcraft/images/d/db/Medivac_SC2_Icon1.jpg",
"Wraith": "https://static.wikia.nocookie.net/starcraft/images/7/75/Wraith_WoL.jpg",
"Viking": "https://static.wikia.nocookie.net/starcraft/images/2/2a/Viking_SC2_Icon1.jpg",
"Banshee": "https://static.wikia.nocookie.net/starcraft/images/3/32/Banshee_SC2_Icon1.jpg",
"Battlecruiser": "https://static.wikia.nocookie.net/starcraft/images/f/f5/Battlecruiser_SC2_Icon1.jpg",
"Rapid Deployment Tube (Medivac)": "https://0rganics.org/archipelago/sc2wol/RapidDeploymentTube.png",
"Advanced Healing AI (Medivac)": "https://0rganics.org/archipelago/sc2wol/AdvancedHealingAI.png",
"Tomahawk Power Cells (Wraith)": "https://0rganics.org/archipelago/sc2wol/TomahawkPowerCells.png",
"Displacement Field (Wraith)": "https://0rganics.org/archipelago/sc2wol/DisplacementField.png",
"Ripwave Missiles (Viking)": "https://0rganics.org/archipelago/sc2wol/RipwaveMissiles.png",
"Phobos-Class Weapons System (Viking)": "https://0rganics.org/archipelago/sc2wol/Phobos-ClassWeaponsSystem.png",
"Cross-Spectrum Dampeners (Banshee)": "https://0rganics.org/archipelago/sc2wol/Cross-SpectrumDampeners.png",
"Shockwave Missile Battery (Banshee)": "https://0rganics.org/archipelago/sc2wol/ShockwaveMissileBattery.png",
"Missile Pods (Battlecruiser)": "https://0rganics.org/archipelago/sc2wol/MissilePods.png",
"Defensive Matrix (Battlecruiser)": "https://0rganics.org/archipelago/sc2wol/DefensiveMatrix.png",
"Ghost": "https://static.wikia.nocookie.net/starcraft/images/6/6e/Ghost_SC2_Icon1.jpg",
"Spectre": "https://static.wikia.nocookie.net/starcraft/images/0/0d/Spectre_WoL.jpg",
"Thor": "https://static.wikia.nocookie.net/starcraft/images/e/ef/Thor_SC2_Icon1.jpg",
"Ocular Implants (Ghost)": "https://0rganics.org/archipelago/sc2wol/OcularImplants.png",
"Crius Suit (Ghost)": "https://0rganics.org/archipelago/sc2wol/CriusSuit.png",
"Psionic Lash (Spectre)": "https://0rganics.org/archipelago/sc2wol/PsionicLash.png",
"Nyx-Class Cloaking Module (Spectre)": "https://0rganics.org/archipelago/sc2wol/Nyx-ClassCloakingModule.png",
"330mm Barrage Cannon (Thor)": "https://0rganics.org/archipelago/sc2wol/330mmBarrageCannon.png",
"Immortality Protocol (Thor)": "https://0rganics.org/archipelago/sc2wol/ImmortalityProtocol.png",
"War Pigs": "https://static.wikia.nocookie.net/starcraft/images/e/ed/WarPigs_SC2_Icon1.jpg",
"Devil Dogs": "https://static.wikia.nocookie.net/starcraft/images/3/33/DevilDogs_SC2_Icon1.jpg",
"Hammer Securities": "https://static.wikia.nocookie.net/starcraft/images/3/3b/HammerSecurity_SC2_Icon1.jpg",
"Spartan Company": "https://static.wikia.nocookie.net/starcraft/images/b/be/SpartanCompany_SC2_Icon1.jpg",
"Siege Breakers": "https://static.wikia.nocookie.net/starcraft/images/3/31/SiegeBreakers_SC2_Icon1.jpg",
"Hel's Angel": "https://static.wikia.nocookie.net/starcraft/images/6/63/HelsAngels_SC2_Icon1.jpg",
"Dusk Wings": "https://static.wikia.nocookie.net/starcraft/images/5/52/DuskWings_SC2_Icon1.jpg",
"Jackson's Revenge": "https://static.wikia.nocookie.net/starcraft/images/9/95/JacksonsRevenge_SC2_Icon1.jpg",
"Ultra-Capacitors": "https://static.wikia.nocookie.net/starcraft/images/2/23/SC2_Lab_Ultra_Capacitors_Icon.png",
"Vanadium Plating": "https://static.wikia.nocookie.net/starcraft/images/6/67/SC2_Lab_VanPlating_Icon.png",
"Orbital Depots": "https://static.wikia.nocookie.net/starcraft/images/0/01/SC2_Lab_Orbital_Depot_Icon.png",
"Micro-Filtering": "https://static.wikia.nocookie.net/starcraft/images/2/20/SC2_Lab_MicroFilter_Icon.png",
"Automated Refinery": "https://static.wikia.nocookie.net/starcraft/images/7/71/SC2_Lab_Auto_Refinery_Icon.png",
"Command Center Reactor": "https://static.wikia.nocookie.net/starcraft/images/e/ef/SC2_Lab_CC_Reactor_Icon.png",
"Raven": "https://static.wikia.nocookie.net/starcraft/images/1/19/SC2_Lab_Raven_Icon.png",
"Science Vessel": "https://static.wikia.nocookie.net/starcraft/images/c/c3/SC2_Lab_SciVes_Icon.png",
"Tech Reactor": "https://static.wikia.nocookie.net/starcraft/images/c/c5/SC2_Lab_Tech_Reactor_Icon.png",
"Orbital Strike": "https://static.wikia.nocookie.net/starcraft/images/d/df/SC2_Lab_Orb_Strike_Icon.png",
"Shrike Turret": "https://static.wikia.nocookie.net/starcraft/images/4/44/SC2_Lab_Shrike_Turret_Icon.png",
"Fortified Bunker": "https://static.wikia.nocookie.net/starcraft/images/4/4f/SC2_Lab_FortBunker_Icon.png",
"Planetary Fortress": "https://static.wikia.nocookie.net/starcraft/images/0/0b/SC2_Lab_PlanetFortress_Icon.png",
"Perdition Turret": "https://static.wikia.nocookie.net/starcraft/images/a/af/SC2_Lab_PerdTurret_Icon.png",
"Predator": "https://static.wikia.nocookie.net/starcraft/images/8/83/SC2_Lab_Predator_Icon.png",
"Hercules": "https://static.wikia.nocookie.net/starcraft/images/4/40/SC2_Lab_Hercules_Icon.png",
"Cellular Reactor": "https://static.wikia.nocookie.net/starcraft/images/d/d8/SC2_Lab_CellReactor_Icon.png",
"Regenerative Bio-Steel": "https://static.wikia.nocookie.net/starcraft/images/d/d3/SC2_Lab_BioSteel_Icon.png",
"Hive Mind Emulator": "https://static.wikia.nocookie.net/starcraft/images/b/bc/SC2_Lab_Hive_Emulator_Icon.png",
"Psi Disrupter": "https://static.wikia.nocookie.net/starcraft/images/c/cf/SC2_Lab_Psi_Disruptor_Icon.png",
"Zealot": "https://static.wikia.nocookie.net/starcraft/images/6/6e/Icon_Protoss_Zealot.jpg",
"Stalker": "https://static.wikia.nocookie.net/starcraft/images/0/0d/Icon_Protoss_Stalker.jpg",
"High Templar": "https://static.wikia.nocookie.net/starcraft/images/a/a0/Icon_Protoss_High_Templar.jpg",
"Dark Templar": "https://static.wikia.nocookie.net/starcraft/images/9/90/Icon_Protoss_Dark_Templar.jpg",
"Immortal": "https://static.wikia.nocookie.net/starcraft/images/c/c1/Icon_Protoss_Immortal.jpg",
"Colossus": "https://static.wikia.nocookie.net/starcraft/images/4/40/Icon_Protoss_Colossus.jpg",
"Phoenix": "https://static.wikia.nocookie.net/starcraft/images/b/b1/Icon_Protoss_Phoenix.jpg",
"Void Ray": "https://static.wikia.nocookie.net/starcraft/images/1/1d/VoidRay_SC2_Rend1.jpg",
"Carrier": "https://static.wikia.nocookie.net/starcraft/images/2/2c/Icon_Protoss_Carrier.jpg",
"Nothing": "",
}
sc2wol_location_ids = {
"Liberation Day": [SC2WOL_LOC_ID_OFFSET + 100, SC2WOL_LOC_ID_OFFSET + 101, SC2WOL_LOC_ID_OFFSET + 102, SC2WOL_LOC_ID_OFFSET + 103, SC2WOL_LOC_ID_OFFSET + 104, SC2WOL_LOC_ID_OFFSET + 105, SC2WOL_LOC_ID_OFFSET + 106],
"The Outlaws": [SC2WOL_LOC_ID_OFFSET + 200, SC2WOL_LOC_ID_OFFSET + 201],
"Zero Hour": [SC2WOL_LOC_ID_OFFSET + 300, SC2WOL_LOC_ID_OFFSET + 301, SC2WOL_LOC_ID_OFFSET + 302, SC2WOL_LOC_ID_OFFSET + 303],
"Evacuation": [SC2WOL_LOC_ID_OFFSET + 400, SC2WOL_LOC_ID_OFFSET + 401, SC2WOL_LOC_ID_OFFSET + 402, SC2WOL_LOC_ID_OFFSET + 403],
"Outbreak": [SC2WOL_LOC_ID_OFFSET + 500, SC2WOL_LOC_ID_OFFSET + 501, SC2WOL_LOC_ID_OFFSET + 502],
"Safe Haven": [SC2WOL_LOC_ID_OFFSET + 600, SC2WOL_LOC_ID_OFFSET + 601, SC2WOL_LOC_ID_OFFSET + 602, SC2WOL_LOC_ID_OFFSET + 603],
"Haven's Fall": [SC2WOL_LOC_ID_OFFSET + 700, SC2WOL_LOC_ID_OFFSET + 701, SC2WOL_LOC_ID_OFFSET + 702, SC2WOL_LOC_ID_OFFSET + 703],
"Smash and Grab": [SC2WOL_LOC_ID_OFFSET + 800, SC2WOL_LOC_ID_OFFSET + 801, SC2WOL_LOC_ID_OFFSET + 802, SC2WOL_LOC_ID_OFFSET + 803, SC2WOL_LOC_ID_OFFSET + 804],
"The Dig": [SC2WOL_LOC_ID_OFFSET + 900, SC2WOL_LOC_ID_OFFSET + 901, SC2WOL_LOC_ID_OFFSET + 902, SC2WOL_LOC_ID_OFFSET + 903],
"The Moebius Factor": [SC2WOL_LOC_ID_OFFSET + 1000, SC2WOL_LOC_ID_OFFSET + 1003, SC2WOL_LOC_ID_OFFSET + 1004, SC2WOL_LOC_ID_OFFSET + 1005, SC2WOL_LOC_ID_OFFSET + 1006, SC2WOL_LOC_ID_OFFSET + 1007, SC2WOL_LOC_ID_OFFSET + 1008],
"Supernova": [SC2WOL_LOC_ID_OFFSET + 1100, SC2WOL_LOC_ID_OFFSET + 1101, SC2WOL_LOC_ID_OFFSET + 1102, SC2WOL_LOC_ID_OFFSET + 1103, SC2WOL_LOC_ID_OFFSET + 1104],
"Maw of the Void": [SC2WOL_LOC_ID_OFFSET + 1200, SC2WOL_LOC_ID_OFFSET + 1201, SC2WOL_LOC_ID_OFFSET + 1202, SC2WOL_LOC_ID_OFFSET + 1203, SC2WOL_LOC_ID_OFFSET + 1204, SC2WOL_LOC_ID_OFFSET + 1205],
"Devil's Playground": [SC2WOL_LOC_ID_OFFSET + 1300, SC2WOL_LOC_ID_OFFSET + 1301, SC2WOL_LOC_ID_OFFSET + 1302],
"Welcome to the Jungle": [SC2WOL_LOC_ID_OFFSET + 1400, SC2WOL_LOC_ID_OFFSET + 1401, SC2WOL_LOC_ID_OFFSET + 1402, SC2WOL_LOC_ID_OFFSET + 1403],
"Breakout": [SC2WOL_LOC_ID_OFFSET + 1500, SC2WOL_LOC_ID_OFFSET + 1501, SC2WOL_LOC_ID_OFFSET + 1502],
"Ghost of a Chance": [SC2WOL_LOC_ID_OFFSET + 1600, SC2WOL_LOC_ID_OFFSET + 1601, SC2WOL_LOC_ID_OFFSET + 1602, SC2WOL_LOC_ID_OFFSET + 1603, SC2WOL_LOC_ID_OFFSET + 1604, SC2WOL_LOC_ID_OFFSET + 1605],
"The Great Train Robbery": [SC2WOL_LOC_ID_OFFSET + 1700, SC2WOL_LOC_ID_OFFSET + 1701, SC2WOL_LOC_ID_OFFSET + 1702, SC2WOL_LOC_ID_OFFSET + 1703],
"Cutthroat": [SC2WOL_LOC_ID_OFFSET + 1800, SC2WOL_LOC_ID_OFFSET + 1801, SC2WOL_LOC_ID_OFFSET + 1802, SC2WOL_LOC_ID_OFFSET + 1803, SC2WOL_LOC_ID_OFFSET + 1804],
"Engine of Destruction": [SC2WOL_LOC_ID_OFFSET + 1900, SC2WOL_LOC_ID_OFFSET + 1901, SC2WOL_LOC_ID_OFFSET + 1902, SC2WOL_LOC_ID_OFFSET + 1903, SC2WOL_LOC_ID_OFFSET + 1904, SC2WOL_LOC_ID_OFFSET + 1905],
"Media Blitz": [SC2WOL_LOC_ID_OFFSET + 2000, SC2WOL_LOC_ID_OFFSET + 2001, SC2WOL_LOC_ID_OFFSET + 2002, SC2WOL_LOC_ID_OFFSET + 2003, SC2WOL_LOC_ID_OFFSET + 2004],
"Piercing the Shroud": [SC2WOL_LOC_ID_OFFSET + 2100, SC2WOL_LOC_ID_OFFSET + 2101, SC2WOL_LOC_ID_OFFSET + 2102, SC2WOL_LOC_ID_OFFSET + 2103, SC2WOL_LOC_ID_OFFSET + 2104, SC2WOL_LOC_ID_OFFSET + 2105],
"Whispers of Doom": [SC2WOL_LOC_ID_OFFSET + 2200, SC2WOL_LOC_ID_OFFSET + 2201, SC2WOL_LOC_ID_OFFSET + 2202, SC2WOL_LOC_ID_OFFSET + 2203],
"A Sinister Turn": [SC2WOL_LOC_ID_OFFSET + 2300, SC2WOL_LOC_ID_OFFSET + 2301, SC2WOL_LOC_ID_OFFSET + 2302, SC2WOL_LOC_ID_OFFSET + 2303],
"Echoes of the Future": [SC2WOL_LOC_ID_OFFSET + 2400, SC2WOL_LOC_ID_OFFSET + 2401, SC2WOL_LOC_ID_OFFSET + 2402],
"In Utter Darkness": [SC2WOL_LOC_ID_OFFSET + 2500, SC2WOL_LOC_ID_OFFSET + 2501, SC2WOL_LOC_ID_OFFSET + 2502],
"Gates of Hell": [SC2WOL_LOC_ID_OFFSET + 2600, SC2WOL_LOC_ID_OFFSET + 2601],
"Belly of the Beast": [SC2WOL_LOC_ID_OFFSET + 2700, SC2WOL_LOC_ID_OFFSET + 2701, SC2WOL_LOC_ID_OFFSET + 2702, SC2WOL_LOC_ID_OFFSET + 2703],
"Shatter the Sky": [SC2WOL_LOC_ID_OFFSET + 2800, SC2WOL_LOC_ID_OFFSET + 2801, SC2WOL_LOC_ID_OFFSET + 2802, SC2WOL_LOC_ID_OFFSET + 2803, SC2WOL_LOC_ID_OFFSET + 2804, SC2WOL_LOC_ID_OFFSET + 2805],
}
display_data = {}
# Determine display for progressive items
progressive_items = {
"Progressive Infantry Weapon": 100 + SC2WOL_ITEM_ID_OFFSET,
"Progressive Infantry Armor": 102 + SC2WOL_ITEM_ID_OFFSET,
"Progressive Vehicle Weapon": 103 + SC2WOL_ITEM_ID_OFFSET,
"Progressive Vehicle Armor": 104 + SC2WOL_ITEM_ID_OFFSET,
"Progressive Ship Weapon": 105 + SC2WOL_ITEM_ID_OFFSET,
"Progressive Ship Armor": 106 + SC2WOL_ITEM_ID_OFFSET
}
progressive_names = {
"Progressive Infantry Weapon": ["Infantry Weapons Level 1", "Infantry Weapons Level 1", "Infantry Weapons Level 2", "Infantry Weapons Level 3"],
"Progressive Infantry Armor": ["Infantry Armor Level 1", "Infantry Armor Level 1", "Infantry Armor Level 2", "Infantry Armor Level 3"],
"Progressive Vehicle Weapon": ["Vehicle Weapons Level 1", "Vehicle Weapons Level 1", "Vehicle Weapons Level 2", "Vehicle Weapons Level 3"],
"Progressive Vehicle Armor": ["Vehicle Armor Level 1", "Vehicle Armor Level 1", "Vehicle Armor Level 2", "Vehicle Armor Level 3"],
"Progressive Ship Weapon": ["Ship Weapons Level 1", "Ship Weapons Level 1", "Ship Weapons Level 2", "Ship Weapons Level 3"],
"Progressive Ship Armor": ["Ship Armor Level 1", "Ship Armor Level 1", "Ship Armor Level 2", "Ship Armor Level 3"]
}
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 + "_level"] = level
display_data[base_name + "_url"] = icons[display_name]
# Multi-items
multi_items = {
"+15 Starting Minerals": 800 + SC2WOL_ITEM_ID_OFFSET,
"+15 Starting Vespene": 801 + SC2WOL_ITEM_ID_OFFSET,
"+2 Starting Supply": 802 + SC2WOL_ITEM_ID_OFFSET
}
for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower()
count = inventory[item_id]
if base_name == "supply":
count = count * 2
display_data[base_name + "_count"] = count
else:
count = count * 15
display_data[base_name + "_count"] = count
# Victory condition
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
display_data['game_finished'] = game_state == 30
# Turn location IDs into mission objective counts
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
lookup_name = lambda id: lookup_any_location_id_to_name[id]
location_info = {mission_name: {lookup_name(id): (id in checked_locations) for id in mission_locations if id in set(locations[player])} for mission_name, mission_locations in sc2wol_location_ids.items()}
checks_done = {mission_name: len([id for id in mission_locations if id in checked_locations and id in set(locations[player])]) for mission_name, mission_locations in sc2wol_location_ids.items()}
checks_done['Total'] = len(checked_locations)
checks_in_area = {mission_name: len([id for id in mission_locations if id in set(locations[player])]) for mission_name, mission_locations in sc2wol_location_ids.items()}
checks_in_area['Total'] = sum(checks_in_area.values())
return render_template("sc2wolTracker.html",
inventory=inventory, icons=icons,
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
id in lookup_any_item_id_to_name},
player=player, team=team, room=room, player_name=playerName,
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
**display_data)
def __renderChecksfinder(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int], slot_data: Dict, saving_second: int) -> str:
icons = {
"Checks Available": "https://0rganics.org/archipelago/cf/spr_tiles_3.png",
"Map Width": "https://0rganics.org/archipelago/cf/spr_tiles_4.png",
"Map Height": "https://0rganics.org/archipelago/cf/spr_tiles_5.png",
"Map Bombs": "https://0rganics.org/archipelago/cf/spr_tiles_6.png",
"Nothing": "",
}
checksfinder_location_ids = {
"Tile 1": 81000,
"Tile 2": 81001,
"Tile 3": 81002,
"Tile 4": 81003,
"Tile 5": 81004,
"Tile 6": 81005,
"Tile 7": 81006,
"Tile 8": 81007,
"Tile 9": 81008,
"Tile 10": 81009,
"Tile 11": 81010,
"Tile 12": 81011,
"Tile 13": 81012,
"Tile 14": 81013,
"Tile 15": 81014,
"Tile 16": 81015,
"Tile 17": 81016,
"Tile 18": 81017,
"Tile 19": 81018,
"Tile 20": 81019,
"Tile 21": 81020,
"Tile 22": 81021,
"Tile 23": 81022,
"Tile 24": 81023,
"Tile 25": 81024,
}
display_data = {}
# Multi-items
multi_items = {
"Map Width": 80000,
"Map Height": 80001,
"Map Bombs": 80002
}
for item_name, item_id in multi_items.items():
base_name = item_name.split()[-1].lower()
count = inventory[item_id]
display_data[base_name + "_count"] = count
display_data[base_name + "_display"] = count + 5
# Get location info
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
lookup_name = lambda id: lookup_any_location_id_to_name[id]
location_info = {tile_name: {lookup_name(tile_location): (tile_location in checked_locations)} for tile_name, tile_location in checksfinder_location_ids.items() if tile_location in set(locations[player])}
checks_done = {tile_name: len([tile_location]) for tile_name, tile_location in checksfinder_location_ids.items() if tile_location in checked_locations and tile_location in set(locations[player])}
checks_done['Total'] = len(checked_locations)
checks_in_area = checks_done
# Calculate checks available
display_data["checks_unlocked"] = min(display_data["width_count"] + display_data["height_count"] + display_data["bombs_count"] + 5, 25)
display_data["checks_available"] = max(display_data["checks_unlocked"] - len(checked_locations), 0)
# Victory condition
game_state = multisave.get("client_game_state", {}).get((team, player), 0)
display_data['game_finished'] = game_state == 30
return render_template("checksfinderTracker.html",
inventory=inventory, icons=icons,
acquired_items={lookup_any_item_id_to_name[id] for id in inventory if
id in lookup_any_item_id_to_name},
player=player, team=team, room=room, player_name=playerName,
checks_done=checks_done, checks_in_area=checks_in_area, location_info=location_info,
**display_data)
def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dict[int, Dict[int, Tuple[int, int, int]]],
inventory: Counter, team: int, player: int, playerName: str,
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int]) -> str:
seed_checks_in_area: Dict[int, Dict[str, int]], checks_done: Dict[str, int],
saving_second: int, custom_locations: Dict[int, str], custom_items: Dict[int, str]) -> str:
checked_locations = multisave.get("location_checks", {}).get((team, player), set())
player_received_items = {}
if multisave.get('version', 0) > 0:
# add numbering to all items but starter_inventory
ordered_items = multisave.get('received_items', {}).get((team, player, True), [])
else:
ordered_items = multisave.get('received_items', {}).get((team, player), [])
# add numbering to all items but starter_inventory
for order_index, networkItem in enumerate(ordered_items, start=1):
player_received_items[networkItem.item] = order_index
return render_template("genericTracker.html",
inventory=inventory,
player=player, team=team, room=room, player_name=playerName,
checked_locations=checked_locations,
not_checked_locations=set(locations[player]) - checked_locations,
received_items=player_received_items)
inventory=inventory,
player=player, team=team, room=room, player_name=playerName,
checked_locations=checked_locations,
not_checked_locations=set(locations[player]) - checked_locations,
received_items=player_received_items, saving_second=saving_second,
custom_items=custom_items, custom_locations=custom_locations)
@app.route('/tracker/<suuid:tracker>')
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def getTracker(tracker: UUID):
def get_enabled_multiworld_trackers(room: Room, current: str):
enabled = [
{
"name": "Generic",
"endpoint": "get_multiworld_tracker",
"current": current == "Generic"
}
]
for game_name, endpoint in multi_trackers.items():
if any(slot.game == game_name for slot in room.seed.slots) or current == game_name:
enabled.append({
"name": game_name,
"endpoint": endpoint.__name__,
"current": current == game_name}
)
return enabled
def _get_multiworld_tracker_data(tracker: UUID) -> typing.Optional[typing.Dict[str, typing.Any]]:
room: Room = Room.get(tracker=tracker)
if not room:
abort(404)
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
precollected_items, games, slot_data, groups = get_static_room_data(room)
return None
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
locations, names, use_door_tracker, checks_in_area, player_location_to_area, \
precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \
get_static_room_data(room)
checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations}
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
percent_total_checks_done = {teamnumber: {playernumber: 0
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
hints = {team: set() for team in range(len(names))}
if room.multisave:
multisave = restricted_loads(room.multisave)
@@ -970,6 +1369,128 @@ def getTracker(tracker: UUID):
for (team, slot), slot_hints in multisave["hints"].items():
hints[team] |= set(slot_hints)
for (team, player), locations_checked in multisave.get("location_checks", {}).items():
if player in groups:
continue
player_locations = locations[player]
checks_done[team][player]["Total"] = sum(1 for loc in locations_checked if loc in player_locations)
percent_total_checks_done[team][player] = int(checks_done[team][player]["Total"] /
checks_in_area[player]["Total"] * 100) \
if checks_in_area[player]["Total"] else 100
activity_timers = {}
now = datetime.datetime.utcnow()
for (team, player), timestamp in multisave.get("client_activity_timers", []):
activity_timers[team, player] = now - datetime.datetime.utcfromtimestamp(timestamp)
player_names = {}
states: typing.Dict[typing.Tuple[int, int], int] = {}
for team, names in enumerate(names):
for player, name in enumerate(names, 1):
player_names[team, player] = name
states[team, player] = multisave.get("client_game_state", {}).get((team, player), 0)
long_player_names = player_names.copy()
for (team, player), alias in multisave.get("name_aliases", {}).items():
player_names[team, player] = alias
long_player_names[(team, player)] = f"{alias} ({long_player_names[team, player]})"
video = {}
for (team, player), data in multisave.get("video", []):
video[team, player] = data
return dict(player_names=player_names, room=room, checks_done=checks_done,
percent_total_checks_done=percent_total_checks_done, checks_in_area=checks_in_area,
activity_timers=activity_timers, video=video, hints=hints,
long_player_names=long_player_names,
multisave=multisave, precollected_items=precollected_items, groups=groups,
locations=locations, games=games, states=states)
def _get_inventory_data(data: typing.Dict[str, typing.Any]) -> typing.Dict[int, typing.Dict[int, int]]:
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in team_data}
for teamnumber, team_data in data["checks_done"].items()}
groups = data["groups"]
for (team, player), locations_checked in data["multisave"].get("location_checks", {}).items():
if player in data["groups"]:
continue
player_locations = data["locations"][player]
precollected = data["precollected_items"][player]
for item_id in precollected:
inventory[team][player][item_id] += 1
for location in locations_checked:
item_id, recipient, flags = player_locations[location]
recipients = groups.get(recipient, [recipient])
for recipient in recipients:
inventory[team][recipient][item_id] += 1
return inventory
@app.route('/tracker/<suuid:tracker>')
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def get_multiworld_tracker(tracker: UUID):
data = _get_multiworld_tracker_data(tracker)
if not data:
abort(404)
data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Generic")
return render_template("multiTracker.html", **data)
@app.route('/tracker/<suuid:tracker>/Factorio')
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def get_Factorio_multiworld_tracker(tracker: UUID):
data = _get_multiworld_tracker_data(tracker)
if not data:
abort(404)
data["inventory"] = _get_inventory_data(data)
data["enabled_multiworld_trackers"] = get_enabled_multiworld_trackers(data["room"], "Factorio")
return render_template("multiFactorioTracker.html", **data)
@app.route('/tracker/<suuid:tracker>/A Link to the Past')
@cache.memoize(timeout=60) # multisave is currently created at most every minute
def get_LttP_multiworld_tracker(tracker: UUID):
room: Room = Room.get(tracker=tracker)
if not room:
abort(404)
locations, names, use_door_tracker, seed_checks_in_area, player_location_to_area, \
precollected_items, games, slot_data, groups, saving_second, custom_locations, custom_items = \
get_static_room_data(room)
inventory = {teamnumber: {playernumber: collections.Counter() for playernumber in range(1, len(team) + 1) if
playernumber not in groups}
for teamnumber, team in enumerate(names)}
checks_done = {teamnumber: {playernumber: {loc_name: 0 for loc_name in default_locations}
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
percent_total_checks_done = {teamnumber: {playernumber: 0
for playernumber in range(1, len(team) + 1) if playernumber not in groups}
for teamnumber, team in enumerate(names)}
hints = {team: set() for team in range(len(names))}
if room.multisave:
multisave = restricted_loads(room.multisave)
else:
multisave = {}
if "hints" in multisave:
for (team, slot), slot_hints in multisave["hints"].items():
hints[team] |= set(slot_hints)
def attribute_item(team: int, recipient: int, item: int):
nonlocal inventory
target_item = links.get(item, item)
if item in levels: # non-progressive
inventory[team][recipient][target_item] = max(inventory[team][recipient][target_item], levels[item])
else:
inventory[team][recipient][target_item] += 1
for (team, player), locations_checked in multisave.get("location_checks", {}).items():
if player in groups:
continue
@@ -977,17 +1498,19 @@ def getTracker(tracker: UUID):
if precollected_items:
precollected = precollected_items[player]
for item_id in precollected:
attribute_item(inventory, team, player, item_id)
attribute_item(team, player, item_id)
for location in locations_checked:
if location not in player_locations or location not in player_location_to_area[player]:
continue
item, recipient, flags = player_locations[location]
if recipient in names:
attribute_item(inventory, team, recipient, item)
checks_done[team][player][player_location_to_area[player][location]] += 1
checks_done[team][player]["Total"] += 1
recipients = groups.get(recipient, [recipient])
for recipient in recipients:
attribute_item(team, recipient, item)
checks_done[team][player][player_location_to_area[player][location]] += 1
checks_done[team][player]["Total"] += 1
percent_total_checks_done[team][player] = int(
checks_done[team][player]["Total"] / seed_checks_in_area[player]["Total"] * 100) if \
seed_checks_in_area[player]["Total"] else 100
for (team, player), game_state in multisave.get("client_game_state", {}).items():
if player in groups:
@@ -1029,14 +1552,19 @@ def getTracker(tracker: UUID):
for (team, player), data in multisave.get("video", []):
video[(team, player)] = data
return render_template("tracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name,
enabled_multiworld_trackers = get_enabled_multiworld_trackers(room, "A Link to the Past")
return render_template("lttpMultiTracker.html", inventory=inventory, get_item_name_from_id=lookup_any_item_id_to_name,
lookup_id_to_name=Items.lookup_id_to_name, player_names=player_names,
tracking_names=tracking_names, tracking_ids=tracking_ids, room=room, icons=alttp_icons,
multi_items=multi_items, checks_done=checks_done, ordered_areas=ordered_areas,
checks_in_area=seed_checks_in_area, activity_timers=activity_timers,
multi_items=multi_items, checks_done=checks_done,
percent_total_checks_done=percent_total_checks_done,
ordered_areas=ordered_areas, checks_in_area=seed_checks_in_area,
activity_timers=activity_timers,
key_locations=group_key_locations, small_key_ids=small_key_ids, big_key_ids=big_key_ids,
video=video, big_key_locations=group_big_key_locations,
hints=hints, long_player_names=long_player_names)
hints=hints, long_player_names=long_player_names,
enabled_multiworld_trackers=enabled_multiworld_trackers)
game_specific_trackers: typing.Dict[str, typing.Callable] = {
@@ -1044,5 +1572,12 @@ game_specific_trackers: typing.Dict[str, typing.Callable] = {
"Ocarina of Time": __renderOoTTracker,
"Timespinner": __renderTimespinnerTracker,
"A Link to the Past": __renderAlttpTracker,
"Super Metroid": __renderSuperMetroidTracker
"ChecksFinder": __renderChecksfinder,
"Super Metroid": __renderSuperMetroidTracker,
"Starcraft 2 Wings of Liberty": __renderSC2WoLTracker
}
multi_trackers: typing.Dict[str, typing.Callable] = {
"A Link to the Past": get_LttP_multiworld_tracker,
"Factorio": get_Factorio_multiworld_tracker,
}

View File

@@ -1,82 +1,58 @@
import base64
import json
import pickle
import typing
import uuid
import zipfile
from io import BytesIO
import zlib
from flask import request, flash, redirect, url_for, session, render_template
from pony.orm import flush, select
from io import BytesIO
from flask import request, flash, redirect, url_for, session, render_template, Markup
from pony.orm import commit, flush, select, rollback
from pony.orm.core import TransactionIntegrityError
import MultiServer
from NetUtils import NetworkSlot, SlotType
from Utils import VersionException, __version__
from worlds.Files import AutoPatchRegister
from . import app
from .models import Seed, Room, Slot
from .models import Seed, Room, Slot, GameDataPackage
banned_zip_contents = (".sfc",)
banned_zip_contents = (".sfc", ".z64", ".n64", ".sms", ".gb")
def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, sid=None):
if not owner:
owner = session["_id"]
infolist = zfile.infolist()
if all(file.filename.endswith((".yaml", ".yml")) or file.is_dir() for file in infolist):
flash(Markup("Error: Your .zip file only contains .yaml files. "
'Did you mean to <a href="/generate">generate a game</a>?'))
return
slots: typing.Set[Slot] = set()
spoiler = ""
files = {}
multidata = None
# Load files.
for file in infolist:
handler = AutoPatchRegister.get_handler(file.filename)
if file.filename.endswith(banned_zip_contents):
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted."
# AP Container
elif handler:
raw = zfile.open(file, "r").read()
patch = handler(BytesIO(raw))
patch.read()
slots.add(Slot(data=raw,
player_name=patch.player_name,
player_id=patch.player,
game=patch.game))
elif file.filename.endswith(".apmc"):
data = zfile.open(file, "r").read()
metadata = json.loads(base64.b64decode(data).decode("utf-8"))
slots.add(Slot(data=data,
player_name=metadata["player_name"],
player_id=metadata["player_id"],
game="Minecraft"))
elif file.filename.endswith(".apv6"):
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3)
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="VVVVVV"))
elif file.filename.endswith(".apsm64ex"):
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3)
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="Super Mario 64"))
elif file.filename.endswith(".zip"):
# Factorio mods need a specific name or they do not function
_, seed_name, slot_id, slot_name = file.filename.rsplit("_", 1)[0].split("-", 3)
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="Factorio"))
elif file.filename.endswith(".apz5"):
# .apz5 must be named specifically since they don't contain any metadata
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('_', 3)
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="Ocarina of Time"))
elif file.filename.endswith(".json"):
_, seed_name, slot_id, slot_name = file.filename.split('.')[0].split('-', 3)
slots.add(Slot(data=zfile.open(file, "r").read(), player_name=slot_name,
player_id=int(slot_id[1:]), game="Dark Souls III"))
patch = handler(BytesIO(data))
patch.read()
files[patch.player] = data
# Spoiler
elif file.filename.endswith(".txt"):
spoiler = zfile.open(file, "r").read().decode("utf-8-sig")
# Multi-data
elif file.filename.endswith(".archipelago"):
try:
multidata = zfile.open(file).read()
@@ -84,20 +60,63 @@ 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"):
_, _, slot_id, *_ = file.filename.split('_')[0].split('-', 3)
data = zfile.open(file, "r").read()
files[int(slot_id[1:])] = data
# All other files using the standard MultiWorld.get_out_file_name_base method
else:
_, _, slot_id, *_ = file.filename.split('.')[0].split('_', 3)
data = zfile.open(file, "r").read()
files[int(slot_id[1:])] = data
# Load multi data.
if multidata:
decompressed_multidata = MultiServer.Context.decompress(multidata)
recompress = False
if "datapackage" in decompressed_multidata:
# strip datapackage from multidata, leaving only the checksums
game_data_packages: typing.List[GameDataPackage] = []
for game, game_data in decompressed_multidata["datapackage"].items():
if game_data.get("checksum"):
game_data_package = GameDataPackage(checksum=game_data["checksum"],
data=pickle.dumps(game_data))
decompressed_multidata["datapackage"][game] = {
"version": game_data.get("version", 0),
"checksum": game_data["checksum"]
}
recompress = True
try:
commit() # commit game data package
game_data_packages.append(game_data_package)
except TransactionIntegrityError:
del game_data_package
rollback()
if "slot_info" in decompressed_multidata:
player_names = {slot.player_name for slot in slots}
leftover_names: typing.Dict[int, NetworkSlot] = {
slot_id: slot_info for slot_id, slot_info in decompressed_multidata["slot_info"].items()
if slot_info.name not in player_names and slot_info.type != SlotType.group}
newslots = [(Slot(data=None, player_name=slot_info.name, player_id=slot, game=slot_info.game))
for slot, slot_info in leftover_names.items()]
for slot in newslots:
slots.add(slot)
for slot, slot_info in decompressed_multidata["slot_info"].items():
# Ignore Player Groups (e.g. item links)
if slot_info.type == SlotType.group:
continue
slots.add(Slot(data=files.get(slot, None),
player_name=slot_info.name,
player_id=slot,
game=slot_info.game))
flush() # commit slots
if recompress:
multidata = multidata[0:1] + zlib.compress(pickle.dumps(decompressed_multidata), 9)
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner, meta=json.dumps(meta),
id=sid if sid else uuid.uuid4())
flush() # create seed

393
Zelda1Client.py Normal file
View File

@@ -0,0 +1,393 @@
# Based (read: copied almost wholesale and edited) off the FF1 Client.
import asyncio
import copy
import json
import logging
import os
import subprocess
import time
import typing
from asyncio import StreamReader, StreamWriter
from typing import List
import Utils
from Utils import async_start
from worlds import lookup_any_location_id_to_name
from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \
get_base_parser
from worlds.tloz.Items import item_game_ids
from worlds.tloz.Locations import location_ids
from worlds.tloz import Items, Locations, Rom
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart Zelda_connector.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure Zelda_connector.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart Zelda_connector.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
item_ids = item_game_ids
location_ids = location_ids
items_by_id = {id: item for item, id in item_ids.items()}
locations_by_id = {id: location for location, id in location_ids.items()}
class ZeldaCommandProcessor(ClientCommandProcessor):
def _cmd_nes(self):
"""Check NES Connection State"""
if isinstance(self.ctx, ZeldaContext):
logger.info(f"NES Status: {self.ctx.nes_status}")
def _cmd_toggle_msgs(self):
"""Toggle displaying messages in bizhawk"""
global DISPLAY_MSGS
DISPLAY_MSGS = not DISPLAY_MSGS
logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}")
class ZeldaContext(CommonContext):
command_processor = ZeldaCommandProcessor
items_handling = 0b101 # get sent remote and starting items
# Infinite Hyrule compatibility
overworld_item = 0x5F
armos_item = 0x24
def __init__(self, server_address, password):
super().__init__(server_address, password)
self.bonus_items = []
self.nes_streams: (StreamReader, StreamWriter) = None
self.nes_sync_task = None
self.messages = {}
self.locations_array = None
self.nes_status = CONNECTION_INITIAL_STATUS
self.game = 'The Legend of Zelda'
self.awaiting_rom = False
self.shop_slots_left = 0
self.shop_slots_middle = 0
self.shop_slots_right = 0
self.shop_slots = [self.shop_slots_left, self.shop_slots_middle, self.shop_slots_right]
self.slot_data = dict()
async def server_auth(self, password_requested: bool = False):
if password_requested and not self.password:
await super(ZeldaContext, 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':
self.slot_data = args.get("slot_data", {})
asyncio.create_task(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 ZeldaManager(GameManager):
logging_pairs = [
("Client", "Archipelago")
]
base_title = "Archipelago Zelda 1 Client"
self.ui = ZeldaManager(self)
self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI")
def get_payload(ctx: ZeldaContext):
current_time = time.time()
bonus_items = [item for item in ctx.bonus_items]
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},
"shops": {
"left": ctx.shop_slots_left,
"middle": ctx.shop_slots_middle,
"right": ctx.shop_slots_right
},
"bonusItems": bonus_items
}
)
def reconcile_shops(ctx: ZeldaContext):
checked_location_names = [lookup_any_location_id_to_name[location] for location in ctx.checked_locations]
shops = [location for location in checked_location_names if "Shop" in location]
left_slots = [shop for shop in shops if "Left" in shop]
middle_slots = [shop for shop in shops if "Middle" in shop]
right_slots = [shop for shop in shops if "Right" in shop]
for shop in left_slots:
ctx.shop_slots_left |= get_shop_bit_from_name(shop)
for shop in middle_slots:
ctx.shop_slots_middle |= get_shop_bit_from_name(shop)
for shop in right_slots:
ctx.shop_slots_right |= get_shop_bit_from_name(shop)
def get_shop_bit_from_name(location_name):
if "Potion" in location_name:
return Rom.potion_shop
elif "Arrow" in location_name:
return Rom.arrow_shop
elif "Shield" in location_name:
return Rom.shield_shop
elif "Ring" in location_name:
return Rom.ring_shop
elif "Candle" in location_name:
return Rom.candle_shop
elif "Take" in location_name:
return Rom.take_any
return 0 # this should never be hit
async def parse_locations(locations_array, ctx: ZeldaContext, force: bool, zone="None"):
if locations_array == ctx.locations_array and not force:
return
else:
# print("New values")
ctx.locations_array = locations_array
locations_checked = []
location = None
for location in ctx.missing_locations:
location_name = lookup_any_location_id_to_name[location]
if location_name in Locations.overworld_locations and zone == "overworld":
status = locations_array[Locations.major_location_offsets[location_name]]
if location_name == "Ocean Heart Container":
status = locations_array[ctx.overworld_item]
if location_name == "Armos Knights":
status = locations_array[ctx.armos_item]
if status & 0x10:
ctx.locations_checked.add(location)
locations_checked.append(location)
elif location_name in Locations.underworld1_locations and zone == "underworld1":
status = locations_array[Locations.floor_location_game_offsets_early[location_name]]
if status & 0x10:
ctx.locations_checked.add(location)
locations_checked.append(location)
elif location_name in Locations.underworld2_locations and zone == "underworld2":
status = locations_array[Locations.floor_location_game_offsets_late[location_name]]
if status & 0x10:
ctx.locations_checked.add(location)
locations_checked.append(location)
elif (location_name in Locations.shop_locations or "Take" in location_name) and zone == "caves":
shop_bit = get_shop_bit_from_name(location_name)
slot = 0
context_slot = 0
if "Left" in location_name:
slot = "slot1"
context_slot = 0
elif "Middle" in location_name:
slot = "slot2"
context_slot = 1
elif "Right" in location_name:
slot = "slot3"
context_slot = 2
if locations_array[slot] & shop_bit > 0:
locations_checked.append(location)
ctx.shop_slots[context_slot] |= shop_bit
if locations_array["takeAnys"] and locations_array["takeAnys"] >= 4:
if "Take Any" in location_name:
short_name = None
if "Left" in location_name:
short_name = "TakeAnyLeft"
elif "Middle" in location_name:
short_name = "TakeAnyMiddle"
elif "Right" in location_name:
short_name = "TakeAnyRight"
if short_name is not None:
item_code = ctx.slot_data[short_name]
if item_code > 0:
ctx.bonus_items.append(item_code)
locations_checked.append(location)
if locations_checked:
await ctx.send_msgs([
{"cmd": "LocationChecks",
"locations": locations_checked}
])
async def nes_sync_task(ctx: ZeldaContext):
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())
if data_decoded["overworldHC"] is not None:
ctx.overworld_item = data_decoded["overworldHC"]
if data_decoded["overworldPB"] is not None:
ctx.armos_item = data_decoded["overworldPB"]
if data_decoded['gameMode'] == 19 and ctx.finished_game == False:
await ctx.send_msgs([
{"cmd": "StatusUpdate",
"status": 30}
])
ctx.finished_game = True
if ctx.game is not None and 'overworld' in data_decoded:
# Not just a keep alive ping, parse
asyncio.create_task(parse_locations(data_decoded['overworld'], ctx, False, "overworld"))
if ctx.game is not None and 'underworld1' in data_decoded:
asyncio.create_task(parse_locations(data_decoded['underworld1'], ctx, False, "underworld1"))
if ctx.game is not None and 'underworld2' in data_decoded:
asyncio.create_task(parse_locations(data_decoded['underworld2'], ctx, False, "underworld2"))
if ctx.game is not None and 'caves' in data_decoded:
asyncio.create_task(parse_locations(data_decoded['caves'], ctx, False, "caves"))
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)
reconcile_shops(ctx)
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("ZeldaClient")
options = Utils.get_options()
DISPLAY_MSGS = options["tloz_options"]["display_msgs"]
async def run_game(romfile: str) -> None:
auto_start = typing.cast(typing.Union[bool, str],
Utils.get_options()["tloz_options"].get("rom_start", True))
if auto_start is True:
import webbrowser
webbrowser.open(romfile)
elif isinstance(auto_start, str) and os.path.isfile(auto_start):
subprocess.Popen([auto_start, romfile],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
async def main(args):
if args.diff_file:
import Patch
logging.info("Patch file was supplied. Creating nes rom..")
meta, romfile = Patch.create_rom_file(args.diff_file)
if "server" in meta:
args.connect = meta["server"]
logging.info(f"Wrote rom file to {romfile}")
async_start(run_game(romfile))
ctx = ZeldaContext(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()
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a Archipelago Binary Patch file')
args = parser.parse_args()
colorama.init()
asyncio.run(main(args))
colorama.deinit()

View File

@@ -48,6 +48,9 @@ class ZillionContext(CommonContext):
command_processor: Type[ClientCommandProcessor] = ZillionCommandProcessor
items_handling = 1 # receive items from other players
known_name: Optional[str]
""" This is almost the same as `auth` except `auth` is reset to `None` when server disconnects, and this isn't. """
from_game: "asyncio.Queue[events.EventFromGame]"
to_game: "asyncio.Queue[events.EventToGame]"
ap_local_count: int
@@ -82,6 +85,7 @@ class ZillionContext(CommonContext):
server_address: str,
password: str) -> None:
super().__init__(server_address, password)
self.known_name = None
self.from_game = asyncio.Queue()
self.to_game = asyncio.Queue()
self.got_room_info = asyncio.Event()
@@ -258,6 +262,10 @@ class ZillionContext(CommonContext):
assert id_ in id_to_loc
self.loc_mem_to_id[mem] = id_
if len(self.loc_mem_to_id) != 394:
logger.warn("invalid Zillion `Connected` packet, "
f"`slot_data` missing locations in `loc_mem_to_id` - len {len(self.loc_mem_to_id)}")
self.got_slot_data.set()
payload = {
@@ -392,7 +400,8 @@ async def zillion_sync_task(ctx: ZillionContext) -> None:
game_id = memory.get_rom_to_ram_data(ram)
name, seed_end = name_seed_from_ram(game_id)
if len(name):
if name == ctx.auth:
if name == ctx.known_name:
ctx.auth = name
# this is the name we know
if ctx.server and ctx.server.socket: # type: ignore
if ctx.got_room_info.is_set():
@@ -435,6 +444,7 @@ async def zillion_sync_task(ctx: ZillionContext) -> None:
memory.reset_game_state()
ctx.auth = name
ctx.known_name = name
async_start(ctx.connect())
await asyncio.wait((
ctx.got_room_info.wait(),

Binary file not shown.

View File

@@ -1,5 +1,22 @@
<TabbedPanel>
tab_width: 200
<TextColors>:
# Hex-format RGB colors used in clients. Resets after an update/install.
# To avoid, you can copy the TextColors section into a new "user.kv" next to this file
# and it will read from there instead.
black: "000000"
red: "EE0000"
green: "00FF7F" # typically a location
yellow: "FAFAD2" # typically other slots/players
blue: "6495ED" # typically extra info (such as entrance)
magenta: "EE00EE" # typically your slot/player
cyan: "00EEEE" # typically regular item
slateblue: "6D8BE8" # typically useful item
plum: "AF99EF" # typically progression item
salmon: "FA8072" # typically trap item
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
<Label>:
color: "FFFFFF"
<TabbedPanel>:
tab_width: root.width / app.tab_count
<SelectableLabel>:
canvas.before:
Color:
@@ -13,6 +30,8 @@
font_size: dp(20)
markup: True
<UILog>:
messages: 1000 # amount of messages stored in client logs.
cols: 1
viewclass: 'SelectableLabel'
scroll_y: 0
scroll_type: ["content", "bars"]

View File

@@ -0,0 +1,851 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local SCRIPT_VERSION = 1
local APItemValue = 0xA2
local APItemRam = 0xE7
local BatAPItemValue = 0xAB
local BatAPItemRam = 0xEA
local PlayerRoomAddr = 0x8A -- if in number room, we're not in play mode
local WinAddr = 0xDE -- if not 0 (I think if 0xff specifically), we won (and should update once, immediately)
-- If any of these are 2, that dragon ate the player (should send update immediately
-- once, and reset that when none of them are 2 again)
local DragonState = {0xA8, 0xAD, 0xB2}
local last_dragon_state = {0, 0, 0}
local carryAddress = 0x9D -- uses rom object table
local batRoomAddr = 0xCB
local batCarryAddress = 0xD0 -- uses ram object location
local batInvalidCarryItem = 0x78
local batItemCheckAddr = 0xf69f
local batMatrixLen = 11 -- number of pairs
local last_carry_item = 0xB4
local frames_with_no_item = 0
local ItemTableStart = 0xfe9d
local PlayerSlotAddress = 0xfff9
local itemMessages = {}
local nullObjectId = 0xB4
local ItemsReceived = nil
local sha256hash = nil
local foreign_items = nil
local foreign_items_by_room = {}
local bat_no_touch_locations_by_room = {}
local bat_no_touch_items = {}
local autocollect_items = {}
local localItemLocations = {}
local prev_bat_room = 0xff
local prev_player_room = 0
local prev_ap_room_index = nil
local pending_foreign_items_collected = {}
local pending_local_items_collected = {}
local rendering_foreign_item = nil
local skip_inventory_items = {}
local inventory = {}
local next_inventory_item = nil
local input_button_address = 0xD7
local deathlink_rec = nil
local deathlink_send = 0
local deathlink_sent = false
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local atariSocket = nil
local frame = 0
local ItemIndex = 0
local yorgle_speed_address = 0xf725
local grundle_speed_address = 0xf740
local rhindle_speed_address = 0xf70A
local read_switch_a = 0xf780
local read_switch_b = 0xf764
local yorgle_speed = nil
local grundle_speed = nil
local rhindle_speed = nil
local slow_yorgle_id = tostring(118000000 + 0x103)
local slow_grundle_id = tostring(118000000 + 0x104)
local slow_rhindle_id = tostring(118000000 + 0x105)
local yorgle_dead = false
local grundle_dead = false
local rhindle_dead = false
local diff_a_locked = false
local diff_b_locked = false
local bat_logic = 0
local is_dead = 0
local freeincarnates_available = 0
local send_freeincarnate_used = false
local current_bat_ap_item = nil
local was_in_number_room = false
local u8 = nil
local wU8 = nil
local u16
local bizhawk_version = client.getversion()
local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_version:sub(1,3)=="2.4") or (bizhawk_version:sub(1,3)=="2.5")
local is26To28 = (bizhawk_version:sub(1,3)=="2.6") or (bizhawk_version:sub(1,3)=="2.7") or (bizhawk_version:sub(1,3)=="2.8")
u8 = memory.read_u8
wU8 = memory.write_u8
u16 = memory.read_u16_le
function uRangeRam(address, bytes)
data = memory.read_bytes_as_array(address, bytes, "Main RAM")
return data
end
function uRangeRom(address, bytes)
data = memory.read_bytes_as_array(address+0xf000, bytes, "System Bus")
return data
end
function uRangeAddress(address, bytes)
data = memory.read_bytes_as_array(address, bytes, "System Bus")
return data
end
function table.empty (self)
for _, _ in pairs(self) do
return false
end
return true
end
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
local function createForeignItemsByRoom()
foreign_items_by_room = {}
if foreign_items == nil then
return
end
for _, foreign_item in pairs(foreign_items) do
if foreign_items_by_room[foreign_item.room_id] == nil then
foreign_items_by_room[foreign_item.room_id] = {}
end
new_foreign_item = {}
new_foreign_item.room_id = foreign_item.room_id
new_foreign_item.room_x = foreign_item.room_x
new_foreign_item.room_y = foreign_item.room_y
new_foreign_item.short_location_id = foreign_item.short_location_id
table.insert(foreign_items_by_room[foreign_item.room_id], new_foreign_item)
end
end
function debugPrintNoTouchLocations()
for room_id, list in pairs(bat_no_touch_locations_by_room) do
for index, notouch_location in ipairs(list) do
print("ROOM "..tostring(room_id).. "["..tostring(index).."]: "..tostring(notouch_location.short_location_id))
end
end
end
function processBlock(block)
if block == nil then
return
end
local block_identified = 0
local msgBlock = block['messages']
if msgBlock ~= nil then
block_identified = 1
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"]
if itemsBlock ~= nil then
block_identified = 1
ItemsReceived = itemsBlock
end
local apItemsBlock = block["foreign_items"]
if apItemsBlock ~= nil then
block_identified = 1
print("got foreign items block")
foreign_items = apItemsBlock
createForeignItemsByRoom()
end
local autocollectItems = block["autocollect_items"]
if autocollectItems ~= nil then
block_identified = 1
autocollect_items = {}
for _, acitem in pairs(autocollectItems) do
if autocollect_items[acitem.room_id] == nil then
autocollect_items[acitem.room_id] = {}
end
table.insert(autocollect_items[acitem.room_id], acitem)
end
end
local localLocalItemLocations = block["local_item_locations"]
if localLocalItemLocations ~= nil then
block_identified = 1
localItemLocations = localLocalItemLocations
print("got local item locations")
end
local checkedLocationsBlock = block["checked_locations"]
if checkedLocationsBlock ~= nil then
block_identified = 1
for room_id, foreign_item_list in pairs(foreign_items_by_room) do
for i, foreign_item in pairs(foreign_item_list) do
short_id = foreign_item.short_location_id
for j, checked_id in pairs(checkedLocationsBlock) do
if checked_id == short_id then
table.remove(foreign_item_list, i)
break
end
end
end
end
if foreign_items ~= nil then
for i, foreign_item in pairs(foreign_items) do
short_id = foreign_item.short_location_id
for j, checked_id in pairs(checkedLocationsBlock) do
if checked_id == short_id then
foreign_items[i] = nil
break
end
end
end
end
end
local dragon_speeds_block = block["dragon_speeds"]
if dragon_speeds_block ~= nil then
block_identified = 1
yorgle_speed = dragon_speeds_block[slow_yorgle_id]
grundle_speed = dragon_speeds_block[slow_grundle_id]
rhindle_speed = dragon_speeds_block[slow_rhindle_id]
end
local diff_a_block = block["difficulty_a_locked"]
if diff_a_block ~= nil then
block_identified = 1
diff_a_locked = diff_a_block
end
local diff_b_block = block["difficulty_b_locked"]
if diff_b_block ~= nil then
block_identified = 1
diff_b_locked = diff_b_block
end
local freeincarnates_available_block = block["freeincarnates_available"]
if freeincarnates_available_block ~= nil then
block_identified = 1
if freeincarnates_available ~= freeincarnates_available_block then
freeincarnates_available = freeincarnates_available_block
local msg = {TTL=450, message="freeincarnates: "..tostring(freeincarnates_available), color=0xFFFF0000}
itemMessages[-2] = msg
end
end
local bat_logic_block = block["bat_logic"]
if bat_logic_block ~= nil then
block_identified = 1
bat_logic = bat_logic_block
end
local bat_no_touch_locations_block = block["bat_no_touch_locations"]
if bat_no_touch_locations_block ~= nil then
block_identified = 1
for _, notouch_location in pairs(bat_no_touch_locations_block) do
local room_id = tonumber(notouch_location.room_id)
if bat_no_touch_locations_by_room[room_id] == nil then
bat_no_touch_locations_by_room[room_id] = {}
end
table.insert(bat_no_touch_locations_by_room[room_id], notouch_location)
if notouch_location.local_item ~= nil and notouch_location.local_item ~= 255 then
bat_no_touch_items[tonumber(notouch_location.local_item)] = true
-- print("no touch: "..tostring(notouch_location.local_item))
end
end
-- debugPrintNoTouchLocations()
end
deathlink_rec = deathlink_rec or block["deathlink"]
if( block_identified == 0 ) then
print("unidentified block")
print(block)
end
end
local function clearScreen()
if is23Or24Or25 then
return
elseif is26To28 then
drawText(0, 0, "", "black")
end
end
local function getMaxMessageLength()
if is23Or24Or25 then
return client.screenwidth()/11
elseif is26To28 then
return client.screenwidth()/12
end
end
function drawText(x, y, message, color)
if is23Or24Or25 then
gui.addmessage(message)
elseif is26To28 then
gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", nil, nil, nil, "client")
end
end
local function drawMessages()
if table.empty(itemMessages) then
clearScreen()
return
end
local y = 10
found = false
maxMessageLength = getMaxMessageLength()
for k, v in pairs(itemMessages) do
if v["TTL"] > 0 then
message = v["message"]
while true do
drawText(5, y, message:sub(1, maxMessageLength), v["color"])
y = y + 16
message = message:sub(maxMessageLength + 1, message:len())
if message:len() == 0 then
break
end
end
newTTL = 0
if is26To28 then
newTTL = itemMessages[k]["TTL"] - 1
end
itemMessages[k]["TTL"] = newTTL
found = true
end
end
if found == false then
clearScreen()
end
end
function difference(a, b)
local aa = {}
for k,v in pairs(a) do aa[v]=true end
for k,v in pairs(b) do aa[v]=nil end
local ret = {}
local n = 0
for k,v in pairs(a) do
if aa[v] then n=n+1 ret[n]=v end
end
return ret
end
function getAllRam()
uRangeRAM(0,128);
return data
end
local function arrayEqual(a1, a2)
if #a1 ~= #a2 then
return false
end
for i, v in ipairs(a1) do
if v ~= a2[i] then
return false
end
end
return true
end
local function alive_mode()
return (u8(PlayerRoomAddr) ~= 0x00 and u8(WinAddr) == 0x00)
end
local function generateLocationsChecked()
list_of_locations = {}
for s, f in pairs(pending_foreign_items_collected) do
table.insert(list_of_locations, f.short_location_id + 118000000)
end
for s, f in pairs(pending_local_items_collected) do
table.insert(list_of_locations, f + 118000000)
end
return list_of_locations
end
function receive()
l, e = atariSocket:receive()
if e == 'closed' then
if curstate == STATE_OK then
print("Connection closed")
end
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
return
elseif e ~= nil then
print(e)
curstate = STATE_UNINITIALIZED
return
end
if l ~= nil then
processBlock(json.decode(l))
end
-- Determine Message to send back
newSha256 = memory.hash_region(0xF000, 0x1000, "System Bus")
if (sha256hash ~= nil and sha256hash ~= newSha256) then
print("ROM changed, quitting")
curstate = STATE_UNINITIALIZED
return
end
sha256hash = newSha256
local retTable = {}
retTable["scriptVersion"] = SCRIPT_VERSION
retTable["romhash"] = sha256hash
if (alive_mode()) then
retTable["locations"] = generateLocationsChecked()
end
if (u8(WinAddr) ~= 0x00) then
retTable["victory"] = 1
end
if( deathlink_sent or deathlink_send == 0 ) then
retTable["deathLink"] = 0
else
print("Sending deathlink "..tostring(deathlink_send))
retTable["deathLink"] = deathlink_send
deathlink_sent = true
end
deathlink_send = 0
if send_freeincarnate_used == true then
print("Sending freeincarnate used")
retTable["freeincarnate"] = true
send_freeincarnate_used = false
end
msg = json.encode(retTable).."\n"
local ret, error = atariSocket: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!")
curstate = STATE_OK
end
end
function AutocollectFromRoom()
if autocollect_items ~= nil and autocollect_items[prev_player_room] ~= nil then
for _, item in pairs(autocollect_items[prev_player_room]) do
pending_foreign_items_collected[item.short_location_id] = item
end
end
end
function SetYorgleSpeed()
if yorgle_speed ~= nil then
emu.setregister("A", yorgle_speed);
end
end
function SetGrundleSpeed()
if grundle_speed ~= nil then
emu.setregister("A", grundle_speed);
end
end
function SetRhindleSpeed()
if rhindle_speed ~= nil then
emu.setregister("A", rhindle_speed);
end
end
function SetDifficultySwitchB()
if diff_b_locked then
local a = emu.getregister("A")
if a < 128 then
emu.setregister("A", a + 128)
end
end
end
function SetDifficultySwitchA()
if diff_a_locked then
local a = emu.getregister("A")
if (a > 128 and a < 128 + 64) or (a < 64) then
emu.setregister("A", a + 64)
end
end
end
function TryFreeincarnate()
if freeincarnates_available > 0 then
freeincarnates_available = freeincarnates_available - 1
for index, state_addr in pairs(DragonState) do
if last_dragon_state[index] == 1 then
send_freeincarnate_used = true
memory.write_u8(state_addr, 1, "System Bus")
local msg = {TTL=450, message="used freeincarnate", color=0xFF00FF00}
itemMessages[-1] = msg
end
end
end
end
function GetLinkedObject()
if emu.getregister("X") == batRoomAddr then
bat_interest_item = emu.getregister("A")
-- if the bat can't touch that item, we'll switch it to the number item, which should never be
-- in the same room as the bat.
if bat_no_touch_items[bat_interest_item] ~= nil then
emu.setregister("A", 0xDD )
emu.setregister("Y", 0xDD )
end
end
end
function CheckCollectAPItem(carry_item, target_item_value, target_item_ram, rendering_foreign_item)
if( carry_item == target_item_value and rendering_foreign_item ~= nil ) then
memory.write_u8(carryAddress, nullObjectId, "System Bus")
memory.write_u8(target_item_ram, 0xFF, "System Bus")
pending_foreign_items_collected[rendering_foreign_item.short_location_id] = rendering_foreign_item
for index, fi in pairs(foreign_items_by_room[rendering_foreign_item.room_id]) do
if( fi.short_location_id == rendering_foreign_item.short_location_id ) then
table.remove(foreign_items_by_room[rendering_foreign_item.room_id], index)
break
end
end
for index, fi in pairs(foreign_items) do
if( fi.short_location_id == rendering_foreign_item.short_location_id ) then
foreign_items[index] = nil
break
end
end
prev_ap_room_index = 0
return true
end
return false
end
function BatCanTouchForeign(foreign_item, bat_room)
if bat_no_touch_locations_by_room[bat_room] == nil or bat_no_touch_locations_by_room[bat_room][1] == nil then
return true
end
for index, location in ipairs(bat_no_touch_locations_by_room[bat_room]) do
if location.short_location_id == foreign_item.short_location_id then
return false
end
end
return true;
end
function main()
memory.usememorydomain("System Bus")
if (is23Or24Or25 or is26To28) == false then
print("Must use a version of bizhawk 2.3.1 or higher")
return
end
local playerSlot = memory.read_u8(PlayerSlotAddress)
local port = 17242 + playerSlot
print("Using port"..tostring(port))
server, error = socket.bind('localhost', port)
if( error ~= nil ) then
print(error)
end
event.onmemoryexecute(SetYorgleSpeed, yorgle_speed_address);
event.onmemoryexecute(SetGrundleSpeed, grundle_speed_address);
event.onmemoryexecute(SetRhindleSpeed, rhindle_speed_address);
event.onmemoryexecute(SetDifficultySwitchA, read_switch_a)
event.onmemoryexecute(SetDifficultySwitchB, read_switch_b)
event.onmemoryexecute(GetLinkedObject, batItemCheckAddr)
-- TODO: Add an onmemoryexecute event to intercept the bat reading item rooms, and don't 'see' an item in the
-- room if it is in bat_no_touch_locations_by_room. Although realistically, I may have to handle this in the rom
-- for it to be totally reliable, because it won't work before the script connects (I might have to reset them?)
-- TODO: Also remove those items from the bat_no_touch_locations_by_room if they have been collected
while true do
frame = frame + 1
drawMessages()
if not (curstate == prevstate) then
print("Current state: "..curstate)
prevstate = curstate
end
local current_player_room = u8(PlayerRoomAddr)
local bat_room = u8(batRoomAddr)
local bat_carrying_item = u8(batCarryAddress)
local bat_carrying_ap_item = (BatAPItemRam == bat_carrying_item)
if current_player_room == 0x1E then
if u8(PlayerRoomAddr + 1) > 0x4B then
memory.write_u8(PlayerRoomAddr + 1, 0x4B)
end
end
if current_player_room == 0x00 then
if not was_in_number_room then
print("reset "..tostring(bat_carrying_ap_item).." "..tostring(bat_carrying_item))
memory.write_u8(batCarryAddress, batInvalidCarryItem)
memory.write_u8(batCarryAddress+ 1, 0)
createForeignItemsByRoom()
memory.write_u8(BatAPItemRam, 0xff)
memory.write_u8(APItemRam, 0xff)
prev_ap_room_index = 0
prev_player_room = 0
rendering_foreign_item = nil
was_in_number_room = true
end
else
was_in_number_room = false
end
if bat_room ~= prev_bat_room then
if bat_carrying_ap_item then
if foreign_items_by_room[prev_bat_room] ~= nil then
for r,f in pairs(foreign_items_by_room[prev_bat_room]) do
if f.short_location_id == current_bat_ap_item.short_location_id then
-- print("removing item from "..tostring(r).." in "..tostring(prev_bat_room))
table.remove(foreign_items_by_room[prev_bat_room], r)
break
end
end
end
if foreign_items_by_room[bat_room] == nil then
foreign_items_by_room[bat_room] = {}
end
-- print("adding item to "..tostring(bat_room))
table.insert(foreign_items_by_room[bat_room], current_bat_ap_item)
else
-- set AP item room and position for new room, or to invalid room
if foreign_items_by_room[bat_room] ~= nil and foreign_items_by_room[bat_room][1] ~= nil
and BatCanTouchForeign(foreign_items_by_room[bat_room][1], bat_room) then
if current_bat_ap_item ~= foreign_items_by_room[bat_room][1] then
current_bat_ap_item = foreign_items_by_room[bat_room][1]
-- print("Changing bat item to "..tostring(current_bat_ap_item.short_location_id))
end
memory.write_u8(BatAPItemRam, bat_room)
memory.write_u8(BatAPItemRam + 1, current_bat_ap_item.room_x)
memory.write_u8(BatAPItemRam + 2, current_bat_ap_item.room_y)
else
memory.write_u8(BatAPItemRam, 0xff)
if current_bat_ap_item ~= nil then
-- print("clearing bat item")
end
current_bat_ap_item = nil
end
end
end
prev_bat_room = bat_room
-- update foreign_items_by_room position and room id for bat item if bat carrying an item
if bat_carrying_ap_item then
-- this is setting the item using the bat's position, which is somewhat wrong, but I think
-- there will be more problems with the room not matching sometimes if I use the actual item position
current_bat_ap_item.room_id = bat_room
current_bat_ap_item.room_x = u8(batRoomAddr + 1)
current_bat_ap_item.room_y = u8(batRoomAddr + 2)
end
if (alive_mode()) then
if (current_player_room ~= prev_player_room) then
memory.write_u8(APItemRam, 0xFF, "System Bus")
prev_ap_room_index = 0
prev_player_room = current_player_room
AutocollectFromRoom()
end
local carry_item = memory.read_u8(carryAddress, "System Bus")
bat_no_touch_items[carry_item] = nil
if (next_inventory_item ~= nil) then
if ( carry_item == nullObjectId and last_carry_item == nullObjectId ) then
frames_with_no_item = frames_with_no_item + 1
if (frames_with_no_item > 10) then
frames_with_no_item = 10
local input_value = memory.read_u8(input_button_address, "System Bus")
if( input_value >= 64 and input_value < 128 ) then -- high bit clear, second highest bit set
memory.write_u8(carryAddress, next_inventory_item)
local item_ram_location = memory.read_u8(ItemTableStart + next_inventory_item)
if( memory.read_u8(batCarryAddress) ~= 0x78 and
memory.read_u8(batCarryAddress) == item_ram_location) then
memory.write_u8(batCarryAddress, batInvalidCarryItem)
memory.write_u8(batCarryAddress+ 1, 0)
memory.write_u8(item_ram_location, current_player_room)
memory.write_u8(item_ram_location + 1, memory.read_u8(PlayerRoomAddr + 1))
memory.write_u8(item_ram_location + 2, memory.read_u8(PlayerRoomAddr + 2))
end
ItemIndex = ItemIndex + 1
next_inventory_item = nil
end
end
else
frames_with_no_item = 0
end
end
if( carry_item ~= last_carry_item ) then
if ( localItemLocations ~= nil and localItemLocations[tostring(carry_item)] ~= nil ) then
pending_local_items_collected[localItemLocations[tostring(carry_item)]] =
localItemLocations[tostring(carry_item)]
table.remove(localItemLocations, tostring(carry_item))
skip_inventory_items[carry_item] = carry_item
end
end
last_carry_item = carry_item
CheckCollectAPItem(carry_item, APItemValue, APItemRam, rendering_foreign_item)
if CheckCollectAPItem(carry_item, BatAPItemValue, BatAPItemRam, current_bat_ap_item) and bat_carrying_ap_item then
memory.write_u8(batCarryAddress, batInvalidCarryItem)
memory.write_u8(batCarryAddress+ 1, 0)
end
rendering_foreign_item = nil
if( foreign_items_by_room[current_player_room] ~= nil ) then
if( foreign_items_by_room[current_player_room][prev_ap_room_index] ~= nil ) and memory.read_u8(APItemRam) ~= 0xff then
foreign_items_by_room[current_player_room][prev_ap_room_index].room_x = memory.read_u8(APItemRam + 1)
foreign_items_by_room[current_player_room][prev_ap_room_index].room_y = memory.read_u8(APItemRam + 2)
end
prev_ap_room_index = prev_ap_room_index + 1
local invalid_index = -1
if( foreign_items_by_room[current_player_room][prev_ap_room_index] == nil ) then
prev_ap_room_index = 1
end
if( foreign_items_by_room[current_player_room][prev_ap_room_index] ~= nil and current_bat_ap_item ~= nil and
foreign_items_by_room[current_player_room][prev_ap_room_index].short_location_id == current_bat_ap_item.short_location_id) then
invalid_index = prev_ap_room_index
prev_ap_room_index = prev_ap_room_index + 1
if( foreign_items_by_room[current_player_room][prev_ap_room_index] == nil ) then
prev_ap_room_index = 1
end
end
if( foreign_items_by_room[current_player_room][prev_ap_room_index] ~= nil and prev_ap_room_index ~= invalid_index ) then
memory.write_u8(APItemRam, current_player_room)
rendering_foreign_item = foreign_items_by_room[current_player_room][prev_ap_room_index]
memory.write_u8(APItemRam + 1, rendering_foreign_item.room_x)
memory.write_u8(APItemRam + 2, rendering_foreign_item.room_y)
else
memory.write_u8(APItemRam, 0xFF, "System Bus")
end
end
if is_dead == 0 then
dragons_revived = false
player_dead = false
new_dragon_state = {0,0,0}
for index, dragon_state_addr in pairs(DragonState) do
new_dragon_state[index] = memory.read_u8(dragon_state_addr, "System Bus" )
if last_dragon_state[index] == 1 and new_dragon_state[index] ~= 1 then
dragons_revived = true
elseif last_dragon_state[index] ~= 1 and new_dragon_state[index] == 1 then
dragon_real_index = index - 1
print("Killed dragon: "..tostring(dragon_real_index))
local dragon_item = {}
dragon_item["short_location_id"] = 0xD0 + dragon_real_index
pending_foreign_items_collected[dragon_item.short_location_id] = dragon_item
end
if new_dragon_state[index] == 2 then
player_dead = true
end
end
if dragons_revived and player_dead == false then
TryFreeincarnate()
end
last_dragon_state = new_dragon_state
end
elseif (u8(PlayerRoomAddr) == 0x00) then -- not alive mode, in number room
ItemIndex = 0 -- reset our inventory
next_inventory_item = nil
skip_inventory_items = {}
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 5 == 0) then
receive()
if alive_mode() then
local was_dead = is_dead
is_dead = 0
for index, dragonStateAddr in pairs(DragonState) do
local dragonstateval = memory.read_u8(dragonStateAddr, "System Bus")
if ( dragonstateval == 2) then
is_dead = index
end
end
if was_dead ~= 0 and is_dead == 0 then
TryFreeincarnate()
end
if deathlink_rec == true and is_dead == 0 then
print("setting dead from deathlink")
deathlink_rec = false
deathlink_sent = true
is_dead = 1
memory.write_u8(carryAddress, nullObjectId, "System Bus")
memory.write_u8(DragonState[1], 2, "System Bus")
end
if (is_dead > 0 and deathlink_send == 0 and not deathlink_sent) then
deathlink_send = is_dead
print("setting deathlink_send to "..tostring(is_dead))
elseif (is_dead == 0) then
deathlink_send = 0
deathlink_sent = false
end
if ItemsReceived ~= nil and ItemsReceived[ItemIndex + 1] ~= nil then
while ItemsReceived[ItemIndex + 1] ~= nil and skip_inventory_items[ItemsReceived[ItemIndex + 1]] ~= nil do
print("skip")
ItemIndex = ItemIndex + 1
end
local static_id = ItemsReceived[ItemIndex + 1]
if static_id ~= nil then
inventory[static_id] = 1
if next_inventory_item == nil then
next_inventory_item = static_id
end
end
end
end
end
elseif (curstate == STATE_UNINITIALIZED) then
if (frame % 60 == 0) then
print("Waiting for client.")
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
atariSocket = client
atariSocket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

380
data/lua/ADVENTURE/json.lua Normal file
View File

@@ -0,0 +1,380 @@
--
-- json.lua
--
-- Copyright (c) 2015 rxi
--
-- This library is free software; you can redistribute it and/or modify it
-- under the terms of the MIT license. See LICENSE for details.
--
local json = { _version = "0.1.0" }
-------------------------------------------------------------------------------
-- Encode
-------------------------------------------------------------------------------
local encode
local escape_char_map = {
[ "\\" ] = "\\\\",
[ "\"" ] = "\\\"",
[ "\b" ] = "\\b",
[ "\f" ] = "\\f",
[ "\n" ] = "\\n",
[ "\r" ] = "\\r",
[ "\t" ] = "\\t",
}
local escape_char_map_inv = { [ "\\/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function escape_char(c)
return escape_char_map[c] or string.format("\\u%04x", c:byte())
end
local function encode_nil(val)
return "null"
end
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference?
if stack[val] then error("circular reference") end
stack[val] = true
if val[1] ~= nil or next(val) == nil then
-- Treat as array -- check keys are valid and it is not sparse
local n = 0
for k in pairs(val) do
if type(k) ~= "number" then
error("invalid table: mixed or invalid key types")
end
n = n + 1
end
if n ~= #val then
error("invalid table: sparse array")
end
-- Encode
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Treat as an object
for k, v in pairs(val) do
if type(k) ~= "string" then
error("invalid table: mixed or invalid key types")
end
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
error("unexpected number value '" .. tostring(val) .. "'")
end
return string.format("%.14g", val)
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
error("unexpected type '" .. t .. "'")
end
function json.encode(val)
return ( encode(val) )
end
-------------------------------------------------------------------------------
-- Decode
-------------------------------------------------------------------------------
local parse
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")
local literal_map = {
[ "true" ] = true,
[ "false" ] = false,
[ "null" ] = nil,
}
local function next_char(str, idx, set, negate)
for i = idx, #str do
if set[str:sub(i, i)] ~= negate then
return i
end
end
return #str + 1
end
local function decode_error(str, idx, msg)
--local line_count = 1
--local col_count = 1
--for i = 1, idx - 1 do
-- col_count = col_count + 1
-- if str:sub(i, i) == "\n" then
-- line_count = line_count + 1
-- col_count = 1
-- end
-- end
-- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(3, 6), 16 )
local n2 = tonumber( s:sub(9, 12), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local has_unicode_escape = false
local has_surrogate_escape = false
local has_escape = false
local last
for j = i + 1, #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
end
if last == 92 then -- "\\" (escape char)
if x == 117 then -- "u" (unicode escape sequence)
local hex = str:sub(j + 1, j + 5)
if not hex:find("%x%x%x%x") then
decode_error(str, j, "invalid unicode escape in string")
end
if hex:find("^[dD][89aAbB]") then
has_surrogate_escape = true
else
has_unicode_escape = true
end
else
local c = string.char(x)
if not escape_chars[c] then
decode_error(str, j, "invalid escape char '" .. c .. "' in string")
end
has_escape = true
end
last = nil
elseif x == 34 then -- '"' (end of string)
local s = str:sub(i + 1, j - 1)
if has_surrogate_escape then
s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape)
end
if has_unicode_escape then
s = s:gsub("\\u....", parse_unicode_escape)
end
if has_escape then
s = s:gsub("\\.", escape_char_map_inv)
end
return s, j + 1
else
last = x
end
end
decode_error(str, i, "expected closing quote for string")
end
local function parse_number(str, i)
local x = next_char(str, i, delim_chars)
local s = str:sub(i, x - 1)
local n = tonumber(s)
if not n then
decode_error(str, i, "invalid number '" .. s .. "'")
end
return n, x
end
local function parse_literal(str, i)
local x = next_char(str, i, delim_chars)
local word = str:sub(i, x - 1)
if not literals[word] then
decode_error(str, i, "invalid literal '" .. word .. "'")
end
return literal_map[word], x
end
local function parse_array(str, i)
local res = {}
local n = 1
i = i + 1
while 1 do
local x
i = next_char(str, i, space_chars, true)
-- Empty / end of array?
if str:sub(i, i) == "]" then
i = i + 1
break
end
-- Read token
x, i = parse(str, i)
res[n] = x
n = n + 1
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "]" then break end
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
end
return res, i
end
local function parse_object(str, i)
local res = {}
i = i + 1
while 1 do
local key, val
i = next_char(str, i, space_chars, true)
-- Empty / end of object?
if str:sub(i, i) == "}" then
i = i + 1
break
end
-- Read key
if str:sub(i, i) ~= '"' then
decode_error(str, i, "expected string for key")
end
key, i = parse(str, i)
-- Read ':' delimiter
i = next_char(str, i, space_chars, true)
if str:sub(i, i) ~= ":" then
decode_error(str, i, "expected ':' after key")
end
i = next_char(str, i + 1, space_chars, true)
-- Read value
val, i = parse(str, i)
-- Set
res[key] = val
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "}" then break end
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
end
return res, i
end
local char_func_map = {
[ '"' ] = parse_string,
[ "0" ] = parse_number,
[ "1" ] = parse_number,
[ "2" ] = parse_number,
[ "3" ] = parse_number,
[ "4" ] = parse_number,
[ "5" ] = parse_number,
[ "6" ] = parse_number,
[ "7" ] = parse_number,
[ "8" ] = parse_number,
[ "9" ] = parse_number,
[ "-" ] = parse_number,
[ "t" ] = parse_literal,
[ "f" ] = parse_literal,
[ "n" ] = parse_literal,
[ "[" ] = parse_array,
[ "{" ] = parse_object,
}
parse = function(str, idx)
local chr = str:sub(idx, idx)
local f = char_func_map[chr]
if f then
return f(str, idx)
end
decode_error(str, idx, "unexpected character '" .. chr .. "'")
end
function json.decode(str)
if type(str) ~= "string" then
error("expected argument of type string, got " .. type(str))
end
return ( parse(str, next_char(str, 1, space_chars, true)) )
end
return json

View File

@@ -0,0 +1,132 @@
-----------------------------------------------------------------------------
-- LuaSocket helper module
-- Author: Diego Nehab
-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
-- Declare module and import dependencies
-----------------------------------------------------------------------------
local base = _G
local string = require("string")
local math = require("math")
local socket = require("socket.core")
module("socket")
-----------------------------------------------------------------------------
-- Exported auxiliar functions
-----------------------------------------------------------------------------
function connect(address, port, laddress, lport)
local sock, err = socket.tcp()
if not sock then return nil, err end
if laddress then
local res, err = sock:bind(laddress, lport, -1)
if not res then return nil, err end
end
local res, err = sock:connect(address, port)
if not res then return nil, err end
return sock
end
function bind(host, port, backlog)
local sock, err = socket.tcp()
if not sock then return nil, err end
sock:setoption("reuseaddr", true)
local res, err = sock:bind(host, port)
if not res then return nil, err end
res, err = sock:listen(backlog)
if not res then return nil, err end
return sock
end
try = newtry()
function choose(table)
return function(name, opt1, opt2)
if base.type(name) ~= "string" then
name, opt1, opt2 = "default", name, opt1
end
local f = table[name or "nil"]
if not f then base.error("unknown key (".. base.tostring(name) ..")", 3)
else return f(opt1, opt2) end
end
end
-----------------------------------------------------------------------------
-- Socket sources and sinks, conforming to LTN12
-----------------------------------------------------------------------------
-- create namespaces inside LuaSocket namespace
sourcet = {}
sinkt = {}
BLOCKSIZE = 2048
sinkt["close-when-done"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if not chunk then
sock:close()
return 1
else return sock:send(chunk) end
end
})
end
sinkt["keep-open"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if chunk then return sock:send(chunk)
else return 1 end
end
})
end
sinkt["default"] = sinkt["keep-open"]
sink = choose(sinkt)
sourcet["by-length"] = function(sock, length)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if length <= 0 then return nil end
local size = math.min(socket.BLOCKSIZE, length)
local chunk, err = sock:receive(size)
if err then return nil, err end
length = length - string.len(chunk)
return chunk
end
})
end
sourcet["until-closed"] = function(sock)
local done
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if done then return nil end
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
if not err then return chunk
elseif err == "closed" then
sock:close()
done = 1
return partial
else return nil, err end
end
})
end
sourcet["default"] = sourcet["until-closed"]
source = choose(sourcet)

View File

@@ -2,8 +2,8 @@ local socket = require("socket")
local json = require('json')
local math = require('math')
local last_modified_date = '2022-07-24' -- Should be the last modified date
local script_version = 2
local last_modified_date = '2022-11-27' -- Should be the last modified date
local script_version = 3
--------------------------------------------------
-- Heavily modified form of RiptideSage's tracker
@@ -25,6 +25,9 @@ local inf_table_offset = save_context_offset + 0xEF8 -- 0x11B4C8
local temp_context = nil
local collectibles_overrides = nil
local collectible_offsets = nil
-- Offsets for scenes can be found here
-- https://wiki.cloudmodding.com/oot/Scene_Table/NTSC_1.0
-- Each scene is 0x1c bits long, chests at 0x0, switches at 0x4, collectibles at 0xc
@@ -40,12 +43,16 @@ end
-- [1] is the scene id
-- [2] is the location type, which varies as input to the function
-- [3] is the location id within the scene, and represents the bit which was checked
-- REORDERED IN 7.0 TO scene id - location type - 0x00 - location id
-- Note that temp_context is 0-indexed and expected_values is 1-indexed, because consistency.
local check_temp_context = function(expected_values)
if temp_context[0] ~= 0x00 then return false end
for i=1,3 do
if temp_context[i] ~= expected_values[i] then return false end
end
-- if temp_context[0] ~= 0x00 then return false end
-- for i=1,3 do
-- if temp_context[i] ~= expected_values[i] then return false end
-- end
if temp_context[0] ~= expected_values[1] then return false end
if temp_context[1] ~= expected_values[2] then return false end
if temp_context[3] ~= expected_values[3] then return false end
return true
end
@@ -67,7 +74,7 @@ local on_the_ground_check = function(scene_offset, bit_to_check)
end
local boss_item_check = function(scene_offset)
return chest_check(scene_offset, 0x1F)
return on_the_ground_check(scene_offset, 0x1F)
or check_temp_context({scene_offset, 0x00, 0x4F})
end
@@ -226,6 +233,8 @@ local read_kokiri_forest_checks = function()
checks["KF Shop Item 6"] = shop_check(0x6, 0x1)
checks["KF Shop Item 7"] = shop_check(0x6, 0x2)
checks["KF Shop Item 8"] = shop_check(0x6, 0x3)
checks["KF Shop Blue Rupee"] = on_the_ground_check(0x2D, 0x1)
return checks
end
@@ -454,7 +463,7 @@ local read_kakariko_village_checks = function()
checks["Kak Impas House Cow"] = cow_check(0x37, 0x18)
checks["Kak GS Tree"] = skulltula_check(0x10, 0x5)
checks["Kak GS Guards House"] = skulltula_check(0x10, 0x1)
checks["Kak GS Near Gate Guard"] = skulltula_check(0x10, 0x1)
checks["Kak GS Watchtower"] = skulltula_check(0x10, 0x2)
checks["Kak GS Skulltula House"] = skulltula_check(0x10, 0x4)
checks["Kak GS House Under Construction"] = skulltula_check(0x10, 0x3)
@@ -480,7 +489,7 @@ local read_graveyard_checks = function()
checks["Graveyard Royal Familys Tomb Chest"] = chest_check(0x41, 0x00)
checks["Graveyard Freestanding PoH"] = on_the_ground_check(0x53, 0x4)
checks["Graveyard Dampe Gravedigging Tour"] = on_the_ground_check(0x53, 0x8)
checks["Graveyard Hookshot Chest"] = chest_check(0x48, 0x00)
checks["Graveyard Dampe Race Hookshot Chest"] = chest_check(0x48, 0x00)
checks["Graveyard Dampe Race Freestanding PoH"] = on_the_ground_check(0x48, 0x7)
checks["Graveyard GS Bean Patch"] = skulltula_check(0x10, 0x0)
@@ -545,7 +554,7 @@ local read_shadow_temple_checks = function(mq_table_address)
checks["Shadow Temple Boss Key Chest"] = chest_check(0x07, 0x0B)
checks["Shadow Temple Invisible Floormaster Chest"] = chest_check(0x07, 0x0D)
checks["Shadow Temple GS Like Like Room"] = skulltula_check(0x07, 0x3)
checks["Shadow Temple GS Invisible Blades Room"] = skulltula_check(0x07, 0x3)
checks["Shadow Temple GS Falling Spikes Room"] = skulltula_check(0x07, 0x1)
checks["Shadow Temple GS Single Giant Pot"] = skulltula_check(0x07, 0x0)
checks["Shadow Temple GS Near Ship"] = skulltula_check(0x07, 0x4)
@@ -723,9 +732,9 @@ local read_fire_temple_checks = function(mq_table_address)
checks["Fire Temple MQ GS Big Lava Room Open Door"] = skulltula_check(0x4, 0x0)
checks["Fire Temple MQ GS Skull On Fire"] = skulltula_check(0x4, 0x2)
checks["Fire Temple MQ GS Fire Wall Maze Center"] = skulltula_check(0x4, 0x3)
checks["Fire Temple MQ GS Fire Wall Maze Side Room"] = skulltula_check(0x4, 0x4)
checks["Fire Temple MQ GS Above Fire Wall Maze"] = skulltula_check(0x4, 0x1)
checks["Fire Temple MQ GS Flame Maze Center"] = skulltula_check(0x4, 0x3)
checks["Fire Temple MQ GS Flame Maze Side Room"] = skulltula_check(0x4, 0x4)
checks["Fire Temple MQ GS Above Flame Maze"] = skulltula_check(0x4, 0x1)
end
checks["Fire Temple Volvagia Heart"] = boss_item_check(0x15)
@@ -743,6 +752,12 @@ local read_zoras_river_checks = function()
checks["ZR Deku Scrub Grotto Front"] = scrub_sanity_check(0x15, 0x9)
checks["ZR Deku Scrub Grotto Rear"] = scrub_sanity_check(0x15, 0x8)
checks["ZR Frogs Zeldas Lullaby"] = event_check(0xD, 0x1)
checks["ZR Frogs Eponas Song"] = event_check(0xD, 0x2)
checks["ZR Frogs Suns Song"] = event_check(0xD, 0x3)
checks["ZR Frogs Sarias Song"] = event_check(0xD, 0x4)
checks["ZR Frogs Song of Time"] = event_check(0xD, 0x5)
checks["ZR GS Tree"] = skulltula_check(0x11, 0x1)
--NOTE: There is no GS in the soft soil. It's the only one that doesn't have one.
checks["ZR GS Ladder"] = skulltula_check(0x11, 0x0)
@@ -912,10 +927,10 @@ end
local read_gerudo_fortress_checks = function()
local checks = {}
checks["Hideout Jail Guard (1 Torch)"] = on_the_ground_check(0xC, 0xC)
checks["Hideout Jail Guard (2 Torches)"] = on_the_ground_check(0xC, 0xF)
checks["Hideout Jail Guard (3 Torches)"] = on_the_ground_check(0xC, 0xA)
checks["Hideout Jail Guard (4 Torches)"] = on_the_ground_check(0xC, 0xE)
checks["Hideout 1 Torch Jail Gerudo Key"] = on_the_ground_check(0xC, 0xC)
checks["Hideout 2 Torches Jail Gerudo Key"] = on_the_ground_check(0xC, 0xF)
checks["Hideout 3 Torches Jail Gerudo Key"] = on_the_ground_check(0xC, 0xA)
checks["Hideout 4 Torches Jail Gerudo Key"] = on_the_ground_check(0xC, 0xE)
checks["Hideout Gerudo Membership Card"] = membership_card_check(0xC, 0x2)
checks["GF Chest"] = chest_check(0x5D, 0x0)
checks["GF HBA 1000 Points"] = info_table_check(0x33, 0x0)
@@ -1170,9 +1185,22 @@ local check_all_locations = function(mq_table_address)
for k,v in pairs(read_ganons_castle_checks(mq_table_address)) do location_checks[k] = v end
for k,v in pairs(read_outside_ganons_castle_checks()) do location_checks[k] = v end
for k,v in pairs(read_song_checks()) do location_checks[k] = v end
-- write 0 to temp context values
mainmemory.write_u32_be(0x40002C, 0)
mainmemory.write_u32_be(0x400030, 0)
return location_checks
end
local check_collectibles = function()
local retval = {}
if collectible_offsets ~= nil then
for id, data in pairs(collectible_offsets) do
local mem = mainmemory.readbyte(collectible_overrides + data[1] + bit.rshift(data[2], 3))
retval[id] = bit.check(mem, data[2] % 8)
end
end
return retval
end
-- convenience functions
@@ -1557,9 +1585,10 @@ local outgoing_player_addr = coop_context + 18
local player_names_address = coop_context + 20
local player_name_length = 8 -- 8 bytes
local rom_name_location = player_names_address + 0x800
local rom_name_location = player_names_address + 0x800 + 0x5 -- 0x800 player names, 0x5 CFG_FILE_SELECT_HASH
local master_quest_table_address = rando_context + (mainmemory.read_u32_be(rando_context + 0x0CE0) - 0x03480000)
-- TODO: load dynamically from slot data
local master_quest_table_address = rando_context + (mainmemory.read_u32_be(rando_context + 0x0E9F) - 0x03480000)
local save_context_addr = 0x11A5D0
local internal_count_addr = save_context_addr + 0x90
@@ -1568,7 +1597,7 @@ local item_queue = {}
local first_connect = true
local game_complete = false
NUM_BIG_POES_REQUIRED = mainmemory.read_u8(rando_context + 0x0CEE)
NUM_BIG_POES_REQUIRED = mainmemory.read_u8(rando_context + 0x0EAD)
local bytes_to_string = function(bytes)
local string = ''
@@ -1718,7 +1747,7 @@ function is_game_complete()
end
function deathlink_enabled()
local death_link_flag = mainmemory.read_u16_be(coop_context + 0xA)
local death_link_flag = mainmemory.readbyte(coop_context + 0xB)
return death_link_flag > 0
end
@@ -1774,6 +1803,13 @@ function process_block(block)
mainmemory.write_u16_be(incoming_item_addr, item_queue[received_items_count+1])
end
end
-- Record collectible data if necessary
if collectible_overrides == nil and block['collectibleOverrides'] ~= 0 then
collectible_overrides = mainmemory.read_u32_be(rando_context + block['collectibleOverrides']) - 0x80000000
end
if collectible_offsets ~= block['collectibleOffsets'] then
collectible_offsets = block['collectibleOffsets']
end
return
end
@@ -1805,6 +1841,7 @@ function receive()
retTable["deathlinkActive"] = deathlink_enabled()
if InSafeState() then
retTable["locations"] = check_all_locations(master_quest_table_address)
retTable["collectibles"] = check_collectibles()
retTable["isDead"] = get_death_state()
retTable["gameComplete"] = is_game_complete()
end

View File

@@ -7,18 +7,26 @@ local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local SCRIPT_VERSION = 3
local APIndex = 0x1A6E
local APDeathLinkAddress = 0x00FD
local APItemAddress = 0x00FF
local EventFlagAddress = 0x1735
local MissableAddress = 0x161A
local HiddenItemsAddress = 0x16DE
local RodAddress = 0x1716
local InGame = 0x1A71
local DexSanityAddress = 0x1A71
local InGameAddress = 0x1A84
local ClientCompatibilityAddress = 0xFF00
local ItemsReceived = nil
local playerName = nil
local seedName = nil
local deathlink_rec = nil
local deathlink_send = false
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local gbSocket = nil
@@ -27,6 +35,7 @@ local frame = 0
local u8 = nil
local wU8 = nil
local u16
local compat = nil
local function defineMemoryFunctions()
local memDomain = {}
@@ -63,19 +72,6 @@ function slice (tbl, s, e)
return new
end
function processBlock(block)
if block == nil then
return
end
local itemsBlock = block["items"]
memDomain.wram()
if itemsBlock ~= nil then-- and u8(0x116B) ~= 0x00 then
-- print(itemsBlock)
ItemsReceived = itemsBlock
end
end
function difference(a, b)
local aa = {}
for k,v in pairs(a) do aa[v]=true end
@@ -93,6 +89,7 @@ function generateLocationsChecked()
events = uRange(EventFlagAddress, 0x140)
missables = uRange(MissableAddress, 0x20)
hiddenitems = uRange(HiddenItemsAddress, 0x0E)
dexsanity = uRange(DexSanityAddress, 19)
rod = u8(RodAddress)
data = {}
@@ -102,16 +99,12 @@ function generateLocationsChecked()
table.foreach(hiddenitems, function(k, v) table.insert(data, v) end)
table.insert(data, rod)
if compat > 1 then
table.foreach(dexsanity, function(k, v) table.insert(data, v) end)
end
return data
end
function generateSerialData()
memDomain.wram()
status = u8(0x1A73)
if status == 0 then
return nil
end
return uRange(0x1A76, u8(0x1A74))
end
local function arrayEqual(a1, a2)
if #a1 ~= #a2 then
return false
@@ -135,7 +128,6 @@ function receive()
curstate = STATE_UNINITIALIZED
return
elseif e == 'timeout' then
print("timeout")
return
elseif e ~= nil then
print(e)
@@ -143,7 +135,15 @@ function receive()
return
end
if l ~= nil then
processBlock(json.decode(l))
block = json.decode(l)
if block ~= nil then
local itemsBlock = block["items"]
if itemsBlock ~= nil then
ItemsReceived = itemsBlock
end
deathlink_rec = block["deathlink"]
end
end
-- Determine Message to send back
memDomain.rom()
@@ -157,16 +157,32 @@ function receive()
playerName = newPlayerName
seedName = newSeedName
local retTable = {}
retTable["scriptVersion"] = SCRIPT_VERSION
if compat == nil then
compat = u8(ClientCompatibilityAddress)
if compat < 2 then
InGameAddress = 0x1A71
end
end
retTable["clientCompatibilityVersion"] = compat
retTable["playerName"] = playerName
retTable["seedName"] = seedName
memDomain.wram()
if u8(InGame) == 0xAC then
in_game = u8(InGameAddress)
if in_game == 0x2A or in_game == 0xAC then
retTable["locations"] = generateLocationsChecked()
serialData = generateSerialData()
if serialData ~= nil then
retTable["serial"] = serialData
end
elseif in_game ~= 0 then
print("Game may have crashed")
curstate = STATE_UNINITIALIZED
return
end
retTable["deathLink"] = deathlink_send
deathlink_send = false
msg = json.encode(retTable).."\n"
local ret, error = gbSocket:send(msg)
if ret == nil then
@@ -195,10 +211,23 @@ function main()
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 5 == 0) then
receive()
if u8(InGame) == 0xAC and u8(APItemAddress) == 0x00 then
ItemIndex = u16(APIndex)
if ItemsReceived[ItemIndex + 1] ~= nil then
wU8(APItemAddress, ItemsReceived[ItemIndex + 1] - 172000000)
in_game = u8(InGameAddress)
if in_game == 0x2A or in_game == 0xAC then
if u8(APItemAddress) == 0x00 then
ItemIndex = u16(APIndex)
if deathlink_rec == true then
wU8(APDeathLinkAddress, 1)
elseif u8(APDeathLinkAddress) == 3 then
wU8(APDeathLinkAddress, 0)
deathlink_send = true
end
if ItemsReceived[ItemIndex + 1] ~= nil then
item_id = ItemsReceived[ItemIndex + 1] - 172000000
if item_id > 255 then
item_id = item_id - 256
end
wU8(APItemAddress, item_id)
end
end
end
end
@@ -212,7 +241,6 @@ function main()
print("Attempting to connect")
local client, timeout = server:accept()
if timeout == nil then
-- print('Initial Connection Made')
curstate = STATE_INITIAL_CONNECTION_MADE
gbSocket = client
gbSocket:settimeout(0)

View File

@@ -0,0 +1,702 @@
--Shamelessly based off the FF1 lua
local socket = require("socket")
local json = require('json')
local math = require('math')
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local itemMessages = {}
local consumableStacks = nil
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local zeldaSocket = nil
local frame = 0
local gameMode = 0
local cave_index
local triforce_byte
local game_state
local u8 = nil
local wU8 = nil
local isNesHawk = false
local shopsChecked = {}
local shopSlotLeft = 0x0628
local shopSlotMiddle = 0x0629
local shopSlotRight = 0x062A
--N.B.: you won't find these in a RAM map. They're flag values that the base patch derives from the cave ID.
local blueRingShopBit = 0x40
local potionShopBit = 0x02
local arrowShopBit = 0x08
local candleShopBit = 0x10
local shieldShopBit = 0x20
local takeAnyCaveBit = 0x01
local sword = 0x0657
local bombs = 0x0658
local maxBombs = 0x067C
local keys = 0x066E
local arrow = 0x0659
local bow = 0x065A
local candle = 0x065B
local recorder = 0x065C
local food = 0x065D
local waterOfLife = 0x065E
local magicalRod = 0x065F
local raft = 0x0660
local bookOfMagic = 0x0661
local ring = 0x0662
local stepladder = 0x0663
local magicalKey = 0x0664
local powerBracelet = 0x0665
local letter = 0x0666
local clockItem = 0x066C
local heartContainers = 0x066F
local partialHearts = 0x0670
local triforceFragments = 0x0671
local boomerang = 0x0674
local magicalBoomerang = 0x0675
local magicalShield = 0x0676
local rupeesToAdd = 0x067D
local rupeesToSubtract = 0x067E
local itemsObtained = 0x0677
local takeAnyCavesChecked = 0x0678
local localTriforce = 0x0679
local bonusItemsObtained = 0x067A
itemAPids = {
["Boomerang"] = 7100,
["Bow"] = 7101,
["Magical Boomerang"] = 7102,
["Raft"] = 7103,
["Stepladder"] = 7104,
["Recorder"] = 7105,
["Magical Rod"] = 7106,
["Red Candle"] = 7107,
["Book of Magic"] = 7108,
["Magical Key"] = 7109,
["Red Ring"] = 7110,
["Silver Arrow"] = 7111,
["Sword"] = 7112,
["White Sword"] = 7113,
["Magical Sword"] = 7114,
["Heart Container"] = 7115,
["Letter"] = 7116,
["Magical Shield"] = 7117,
["Candle"] = 7118,
["Arrow"] = 7119,
["Food"] = 7120,
["Water of Life (Blue)"] = 7121,
["Water of Life (Red)"] = 7122,
["Blue Ring"] = 7123,
["Triforce Fragment"] = 7124,
["Power Bracelet"] = 7125,
["Small Key"] = 7126,
["Bomb"] = 7127,
["Recovery Heart"] = 7128,
["Five Rupees"] = 7129,
["Rupee"] = 7130,
["Clock"] = 7131,
["Fairy"] = 7132
}
itemCodes = {
["Boomerang"] = 0x1D,
["Bow"] = 0x0A,
["Magical Boomerang"] = 0x1E,
["Raft"] = 0x0C,
["Stepladder"] = 0x0D,
["Recorder"] = 0x05,
["Magical Rod"] = 0x10,
["Red Candle"] = 0x07,
["Book of Magic"] = 0x11,
["Magical Key"] = 0x0B,
["Red Ring"] = 0x13,
["Silver Arrow"] = 0x09,
["Sword"] = 0x01,
["White Sword"] = 0x02,
["Magical Sword"] = 0x03,
["Heart Container"] = 0x1A,
["Letter"] = 0x15,
["Magical Shield"] = 0x1C,
["Candle"] = 0x06,
["Arrow"] = 0x08,
["Food"] = 0x04,
["Water of Life (Blue)"] = 0x1F,
["Water of Life (Red)"] = 0x20,
["Blue Ring"] = 0x12,
["Triforce Fragment"] = 0x1B,
["Power Bracelet"] = 0x14,
["Small Key"] = 0x19,
["Bomb"] = 0x00,
["Recovery Heart"] = 0x22,
["Five Rupees"] = 0x0F,
["Rupee"] = 0x18,
["Clock"] = 0x21,
["Fairy"] = 0x23
}
--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["ram"] = function() memory.usememorydomain("RAM") 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["ram"] = function() memory.usememorydomain("RAM") end
memDomain["saveram"] = function() memory.usememorydomain("WRAM") end
memDomain["rom"] = function() memory.usememorydomain("PRG ROM") end
end
return memDomain
end
local memDomain = defineMemoryFunctions()
u8 = memory.read_u8
wU8 = memory.write_u8
uRange = memory.readbyterange
itemIDNames = {}
for key, value in pairs(itemAPids) do
itemIDNames[value] = key
end
local function determineItem(array)
memdomain.ram()
currentItemsObtained = u8(itemsObtained)
end
local function gotSword()
local currentSword = u8(sword)
wU8(sword, math.max(currentSword, 1))
end
local function gotWhiteSword()
local currentSword = u8(sword)
wU8(sword, math.max(currentSword, 2))
end
local function gotMagicalSword()
wU8(sword, 3)
end
local function gotBomb()
local currentBombs = u8(bombs)
local currentMaxBombs = u8(maxBombs)
wU8(bombs, math.min(currentBombs + 4, currentMaxBombs))
wU8(0x505, 0x29) -- Fake bomb to show item get.
end
local function gotArrow()
local currentArrow = u8(arrow)
wU8(arrow, math.max(currentArrow, 1))
end
local function gotSilverArrow()
wU8(arrow, 2)
end
local function gotBow()
wU8(bow, 1)
end
local function gotCandle()
local currentCandle = u8(candle)
wU8(candle, math.max(currentCandle, 1))
end
local function gotRedCandle()
wU8(candle, 2)
end
local function gotRecorder()
wU8(recorder, 1)
end
local function gotFood()
wU8(food, 1)
end
local function gotWaterOfLifeBlue()
local currentWaterOfLife = u8(waterOfLife)
wU8(waterOfLife, math.max(currentWaterOfLife, 1))
end
local function gotWaterOfLifeRed()
wU8(waterOfLife, 2)
end
local function gotMagicalRod()
wU8(magicalRod, 1)
end
local function gotBookOfMagic()
wU8(bookOfMagic, 1)
end
local function gotRaft()
wU8(raft, 1)
end
local function gotBlueRing()
local currentRing = u8(ring)
wU8(ring, math.max(currentRing, 1))
memDomain.saveram()
local currentTunicColor = u8(0x0B92)
if currentTunicColor == 0x29 then
wU8(0x0B92, 0x32)
wU8(0x0804, 0x32)
end
end
local function gotRedRing()
wU8(ring, 2)
memDomain.saveram()
wU8(0x0B92, 0x16)
wU8(0x0804, 0x16)
end
local function gotStepladder()
wU8(stepladder, 1)
end
local function gotMagicalKey()
wU8(magicalKey, 1)
end
local function gotPowerBracelet()
wU8(powerBracelet, 1)
end
local function gotLetter()
wU8(letter, 1)
end
local function gotHeartContainer()
local currentHeartContainers = bit.rshift(bit.band(u8(heartContainers), 0xF0), 4)
if currentHeartContainers < 16 then
currentHeartContainers = math.min(currentHeartContainers + 1, 16)
local currentHearts = bit.band(u8(heartContainers), 0x0F) + 1
wU8(heartContainers, bit.lshift(currentHeartContainers, 4) + currentHearts)
end
end
local function gotTriforceFragment()
local triforceByte = 0xFF
local newTriforceCount = u8(localTriforce) + 1
wU8(localTriforce, newTriforceCount)
end
local function gotBoomerang()
wU8(boomerang, 1)
end
local function gotMagicalBoomerang()
wU8(magicalBoomerang, 1)
end
local function gotMagicalShield()
wU8(magicalShield, 1)
end
local function gotRecoveryHeart()
local currentHearts = bit.band(u8(heartContainers), 0x0F)
local currentHeartContainers = bit.rshift(bit.band(u8(heartContainers), 0xF0), 4)
if currentHearts < currentHeartContainers then
currentHearts = currentHearts + 1
else
wU8(partialHearts, 0xFF)
end
currentHearts = bit.bor(bit.band(u8(heartContainers), 0xF0), currentHearts)
wU8(heartContainers, currentHearts)
end
local function gotFairy()
local currentHearts = bit.band(u8(heartContainers), 0x0F)
local currentHeartContainers = bit.rshift(bit.band(u8(heartContainers), 0xF0), 4)
if currentHearts < currentHeartContainers then
currentHearts = currentHearts + 3
if currentHearts > currentHeartContainers then
currentHearts = currentHeartContainers
wU8(partialHearts, 0xFF)
end
else
wU8(partialHearts, 0xFF)
end
currentHearts = bit.bor(bit.band(u8(heartContainers), 0xF0), currentHearts)
wU8(heartContainers, currentHearts)
end
local function gotClock()
wU8(clockItem, 1)
end
local function gotFiveRupees()
local currentRupeesToAdd = u8(rupeesToAdd)
wU8(rupeesToAdd, math.min(currentRupeesToAdd + 5, 255))
end
local function gotSmallKey()
wU8(keys, math.min(u8(keys) + 1, 9))
end
local function gotItem(item)
--Write itemCode to itemToLift
--Write 128 to itemLiftTimer
--Write 4 to sound effect queue
itemName = itemIDNames[item]
itemCode = itemCodes[itemName]
wU8(0x505, itemCode)
wU8(0x506, 128)
wU8(0x602, 4)
numberObtained = u8(itemsObtained) + 1
wU8(itemsObtained, numberObtained)
if itemName == "Boomerang" then gotBoomerang() end
if itemName == "Bow" then gotBow() end
if itemName == "Magical Boomerang" then gotMagicalBoomerang() end
if itemName == "Raft" then gotRaft() end
if itemName == "Stepladder" then gotStepladder() end
if itemName == "Recorder" then gotRecorder() end
if itemName == "Magical Rod" then gotMagicalRod() end
if itemName == "Red Candle" then gotRedCandle() end
if itemName == "Book of Magic" then gotBookOfMagic() end
if itemName == "Magical Key" then gotMagicalKey() end
if itemName == "Red Ring" then gotRedRing() end
if itemName == "Silver Arrow" then gotSilverArrow() end
if itemName == "Sword" then gotSword() end
if itemName == "White Sword" then gotWhiteSword() end
if itemName == "Magical Sword" then gotMagicalSword() end
if itemName == "Heart Container" then gotHeartContainer() end
if itemName == "Letter" then gotLetter() end
if itemName == "Magical Shield" then gotMagicalShield() end
if itemName == "Candle" then gotCandle() end
if itemName == "Arrow" then gotArrow() end
if itemName == "Food" then gotFood() end
if itemName == "Water of Life (Blue)" then gotWaterOfLifeBlue() end
if itemName == "Water of Life (Red)" then gotWaterOfLifeRed() end
if itemName == "Blue Ring" then gotBlueRing() end
if itemName == "Triforce Fragment" then gotTriforceFragment() end
if itemName == "Power Bracelet" then gotPowerBracelet() end
if itemName == "Small Key" then gotSmallKey() end
if itemName == "Bomb" then gotBomb() end
if itemName == "Recovery Heart" then gotRecoveryHeart() end
if itemName == "Five Rupees" then gotFiveRupees() end
if itemName == "Fairy" then gotFairy() end
if itemName == "Clock" then gotClock() end
end
local function StateOKForMainLoop()
memDomain.ram()
local gameMode = u8(0x12)
return gameMode == 5
end
local function checkCaveItemObtained()
memDomain.ram()
local returnTable = {}
returnTable["slot1"] = u8(shopSlotLeft)
returnTable["slot2"] = u8(shopSlotMiddle)
returnTable["slot3"] = u8(shopSlotRight)
returnTable["takeAnys"] = u8(takeAnyCavesChecked)
return returnTable
end
function table.empty (self)
for _, _ in pairs(self) do
return false
end
return true
end
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
local bizhawk_version = client.getversion()
local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_version:sub(1,3)=="2.4") or (bizhawk_version:sub(1,3)=="2.5")
local is26To28 = (bizhawk_version:sub(1,3)=="2.6") or (bizhawk_version:sub(1,3)=="2.7") or (bizhawk_version:sub(1,3)=="2.8")
local function getMaxMessageLength()
if is23Or24Or25 then
return client.screenwidth()/11
elseif is26To28 then
return client.screenwidth()/12
end
end
local function drawText(x, y, message, color)
if is23Or24Or25 then
gui.addmessage(message)
elseif is26To28 then
gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", "middle", "bottom", nil, "client")
end
end
local function clearScreen()
if is23Or24Or25 then
return
elseif is26To28 then
drawText(0, 0, "", "black")
end
end
local function drawMessages()
if table.empty(itemMessages) then
clearScreen()
return
end
local y = 10
found = false
maxMessageLength = getMaxMessageLength()
for k, v in pairs(itemMessages) do
if v["TTL"] > 0 then
message = v["message"]
while true do
drawText(5, y, message:sub(1, maxMessageLength), v["color"])
y = y + 16
message = message:sub(maxMessageLength + 1, message:len())
if message:len() == 0 then
break
end
end
newTTL = 0
if is26To28 then
newTTL = itemMessages[k]["TTL"] - 1
end
itemMessages[k]["TTL"] = newTTL
found = true
end
end
if found == false then
clearScreen()
end
end
function generateOverworldLocationChecked()
memDomain.ram()
data = uRange(0x067E, 0x81)
data[0] = nil
return data
end
function getHCLocation()
memDomain.rom()
data = u8(0x1789A)
return data
end
function getPBLocation()
memDomain.rom()
data = u8(0x10CB2)
return data
end
function generateUnderworld16LocationChecked()
memDomain.ram()
data = uRange(0x06FE, 0x81)
data[0] = nil
return data
end
function generateUnderworld79LocationChecked()
memDomain.ram()
data = uRange(0x077E, 0x81)
data[0] = nil
return data
end
function updateTriforceFragments()
memDomain.ram()
local triforceByte = 0xFF
totalTriforceCount = u8(localTriforce)
local currentPieces = bit.rshift(triforceByte, 8 - math.min(8, totalTriforceCount))
wU8(triforceFragments, currentPieces)
end
function processBlock(block)
if block ~= nil then
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 bonusItems = block["bonusItems"]
if bonusItems ~= nil and isInGame then
for i, item in ipairs(bonusItems) do
memDomain.ram()
if i > u8(bonusItemsObtained) then
if u8(0x505) == 0 then
gotItem(item)
wU8(itemsObtained, u8(itemsObtained) - 1)
wU8(bonusItemsObtained, u8(bonusItemsObtained) + 1)
end
end
end
end
local itemsBlock = block["items"]
memDomain.saveram()
isInGame = StateOKForMainLoop()
updateTriforceFragments()
if itemsBlock ~= nil and isInGame then
memDomain.ram()
--get item from item code
--get function from item
--do function
for i, item in ipairs(itemsBlock) do
memDomain.ram()
if u8(0x505) == 0 then
if i > u8(itemsObtained) then
gotItem(item)
end
end
end
end
local shopsBlock = block["shops"]
if shopsBlock ~= nil then
wU8(shopSlotLeft, bit.bor(u8(shopSlotLeft), shopsBlock["left"]))
wU8(shopSlotMiddle, bit.bor(u8(shopSlotMiddle), shopsBlock["middle"]))
wU8(shopSlotRight, bit.bor(u8(shopSlotRight), shopsBlock["right"]))
end
end
end
function difference(a, b)
local aa = {}
for k,v in pairs(a) do aa[v]=true end
for k,v in pairs(b) do aa[v]=nil end
local ret = {}
local n = 0
for k,v in pairs(a) do
if aa[v] then n=n+1 ret[n]=v end
end
return ret
end
function receive()
l, e = zeldaSocket: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(0x1F, 0x11)
playerName[0] = nil
local retTable = {}
retTable["playerName"] = playerName
if StateOKForMainLoop() then
retTable["overworld"] = generateOverworldLocationChecked()
retTable["underworld1"] = generateUnderworld16LocationChecked()
retTable["underworld2"] = generateUnderworld79LocationChecked()
end
retTable["caves"] = checkCaveItemObtained()
memDomain.ram()
if gameMode ~= 19 then
gameMode = u8(0x12)
end
retTable["gameMode"] = gameMode
retTable["overworldHC"] = getHCLocation()
retTable["overworldPB"] = getPBLocation()
retTable["itemsObtained"] = u8(itemsObtained)
msg = json.encode(retTable).."\n"
local ret, error = zeldaSocket: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 (is23Or24Or25 or is26To28) == false then
print("Must use a version of bizhawk 2.3.1 or higher")
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 Zelda1Client.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
zeldaSocket = client
zeldaSocket:settimeout(0)
end
end
end
emu.frameadvance()
end
end
main()

BIN
data/lua/TLoZ/core.dll Normal file

Binary file not shown.

380
data/lua/TLoZ/json.lua Normal file
View File

@@ -0,0 +1,380 @@
--
-- json.lua
--
-- Copyright (c) 2015 rxi
--
-- This library is free software; you can redistribute it and/or modify it
-- under the terms of the MIT license. See LICENSE for details.
--
local json = { _version = "0.1.0" }
-------------------------------------------------------------------------------
-- Encode
-------------------------------------------------------------------------------
local encode
local escape_char_map = {
[ "\\" ] = "\\\\",
[ "\"" ] = "\\\"",
[ "\b" ] = "\\b",
[ "\f" ] = "\\f",
[ "\n" ] = "\\n",
[ "\r" ] = "\\r",
[ "\t" ] = "\\t",
}
local escape_char_map_inv = { [ "\\/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function escape_char(c)
return escape_char_map[c] or string.format("\\u%04x", c:byte())
end
local function encode_nil(val)
return "null"
end
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference?
if stack[val] then error("circular reference") end
stack[val] = true
if val[1] ~= nil or next(val) == nil then
-- Treat as array -- check keys are valid and it is not sparse
local n = 0
for k in pairs(val) do
if type(k) ~= "number" then
error("invalid table: mixed or invalid key types")
end
n = n + 1
end
if n ~= #val then
error("invalid table: sparse array")
end
-- Encode
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Treat as an object
for k, v in pairs(val) do
if type(k) ~= "string" then
error("invalid table: mixed or invalid key types")
end
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
error("unexpected number value '" .. tostring(val) .. "'")
end
return string.format("%.14g", val)
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
error("unexpected type '" .. t .. "'")
end
function json.encode(val)
return ( encode(val) )
end
-------------------------------------------------------------------------------
-- Decode
-------------------------------------------------------------------------------
local parse
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")
local literal_map = {
[ "true" ] = true,
[ "false" ] = false,
[ "null" ] = nil,
}
local function next_char(str, idx, set, negate)
for i = idx, #str do
if set[str:sub(i, i)] ~= negate then
return i
end
end
return #str + 1
end
local function decode_error(str, idx, msg)
--local line_count = 1
--local col_count = 1
--for i = 1, idx - 1 do
-- col_count = col_count + 1
-- if str:sub(i, i) == "\n" then
-- line_count = line_count + 1
-- col_count = 1
-- end
-- end
-- emu.message( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(3, 6), 16 )
local n2 = tonumber( s:sub(9, 12), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local has_unicode_escape = false
local has_surrogate_escape = false
local has_escape = false
local last
for j = i + 1, #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
end
if last == 92 then -- "\\" (escape char)
if x == 117 then -- "u" (unicode escape sequence)
local hex = str:sub(j + 1, j + 5)
if not hex:find("%x%x%x%x") then
decode_error(str, j, "invalid unicode escape in string")
end
if hex:find("^[dD][89aAbB]") then
has_surrogate_escape = true
else
has_unicode_escape = true
end
else
local c = string.char(x)
if not escape_chars[c] then
decode_error(str, j, "invalid escape char '" .. c .. "' in string")
end
has_escape = true
end
last = nil
elseif x == 34 then -- '"' (end of string)
local s = str:sub(i + 1, j - 1)
if has_surrogate_escape then
s = s:gsub("\\u[dD][89aAbB]..\\u....", parse_unicode_escape)
end
if has_unicode_escape then
s = s:gsub("\\u....", parse_unicode_escape)
end
if has_escape then
s = s:gsub("\\.", escape_char_map_inv)
end
return s, j + 1
else
last = x
end
end
decode_error(str, i, "expected closing quote for string")
end
local function parse_number(str, i)
local x = next_char(str, i, delim_chars)
local s = str:sub(i, x - 1)
local n = tonumber(s)
if not n then
decode_error(str, i, "invalid number '" .. s .. "'")
end
return n, x
end
local function parse_literal(str, i)
local x = next_char(str, i, delim_chars)
local word = str:sub(i, x - 1)
if not literals[word] then
decode_error(str, i, "invalid literal '" .. word .. "'")
end
return literal_map[word], x
end
local function parse_array(str, i)
local res = {}
local n = 1
i = i + 1
while 1 do
local x
i = next_char(str, i, space_chars, true)
-- Empty / end of array?
if str:sub(i, i) == "]" then
i = i + 1
break
end
-- Read token
x, i = parse(str, i)
res[n] = x
n = n + 1
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "]" then break end
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
end
return res, i
end
local function parse_object(str, i)
local res = {}
i = i + 1
while 1 do
local key, val
i = next_char(str, i, space_chars, true)
-- Empty / end of object?
if str:sub(i, i) == "}" then
i = i + 1
break
end
-- Read key
if str:sub(i, i) ~= '"' then
decode_error(str, i, "expected string for key")
end
key, i = parse(str, i)
-- Read ':' delimiter
i = next_char(str, i, space_chars, true)
if str:sub(i, i) ~= ":" then
decode_error(str, i, "expected ':' after key")
end
i = next_char(str, i + 1, space_chars, true)
-- Read value
val, i = parse(str, i)
-- Set
res[key] = val
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "}" then break end
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
end
return res, i
end
local char_func_map = {
[ '"' ] = parse_string,
[ "0" ] = parse_number,
[ "1" ] = parse_number,
[ "2" ] = parse_number,
[ "3" ] = parse_number,
[ "4" ] = parse_number,
[ "5" ] = parse_number,
[ "6" ] = parse_number,
[ "7" ] = parse_number,
[ "8" ] = parse_number,
[ "9" ] = parse_number,
[ "-" ] = parse_number,
[ "t" ] = parse_literal,
[ "f" ] = parse_literal,
[ "n" ] = parse_literal,
[ "[" ] = parse_array,
[ "{" ] = parse_object,
}
parse = function(str, idx)
local chr = str:sub(idx, idx)
local f = char_func_map[chr]
if f then
return f(str, idx)
end
decode_error(str, idx, "unexpected character '" .. chr .. "'")
end
function json.decode(str)
if type(str) ~= "string" then
error("expected argument of type string, got " .. type(str))
end
return ( parse(str, next_char(str, 1, space_chars, true)) )
end
return json

132
data/lua/TLoZ/socket.lua Normal file
View File

@@ -0,0 +1,132 @@
-----------------------------------------------------------------------------
-- LuaSocket helper module
-- Author: Diego Nehab
-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
-- Declare module and import dependencies
-----------------------------------------------------------------------------
local base = _G
local string = require("string")
local math = require("math")
local socket = require("socket.core")
module("socket")
-----------------------------------------------------------------------------
-- Exported auxiliar functions
-----------------------------------------------------------------------------
function connect(address, port, laddress, lport)
local sock, err = socket.tcp()
if not sock then return nil, err end
if laddress then
local res, err = sock:bind(laddress, lport, -1)
if not res then return nil, err end
end
local res, err = sock:connect(address, port)
if not res then return nil, err end
return sock
end
function bind(host, port, backlog)
local sock, err = socket.tcp()
if not sock then return nil, err end
sock:setoption("reuseaddr", true)
local res, err = sock:bind(host, port)
if not res then return nil, err end
res, err = sock:listen(backlog)
if not res then return nil, err end
return sock
end
try = newtry()
function choose(table)
return function(name, opt1, opt2)
if base.type(name) ~= "string" then
name, opt1, opt2 = "default", name, opt1
end
local f = table[name or "nil"]
if not f then base.error("unknown key (".. base.tostring(name) ..")", 3)
else return f(opt1, opt2) end
end
end
-----------------------------------------------------------------------------
-- Socket sources and sinks, conforming to LTN12
-----------------------------------------------------------------------------
-- create namespaces inside LuaSocket namespace
sourcet = {}
sinkt = {}
BLOCKSIZE = 2048
sinkt["close-when-done"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if not chunk then
sock:close()
return 1
else return sock:send(chunk) end
end
})
end
sinkt["keep-open"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if chunk then return sock:send(chunk)
else return 1 end
end
})
end
sinkt["default"] = sinkt["keep-open"]
sink = choose(sinkt)
sourcet["by-length"] = function(sock, length)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if length <= 0 then return nil end
local size = math.min(socket.BLOCKSIZE, length)
local chunk, err = sock:receive(size)
if err then return nil, err end
length = length - string.len(chunk)
return chunk
end
})
end
sourcet["until-closed"] = function(sock)
local done
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if done then return nil end
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
if not err then return chunk
elseif err == "closed" then
sock:close()
done = 1
return partial
else return nil, err end
end
})
end
sourcet["default"] = sourcet["until-closed"]
source = choose(sourcet)

View File

@@ -0,0 +1,137 @@
-- SPDX-FileCopyrightText: 2023 Wilhelm Schürmann <wimschuermann@googlemail.com>
--
-- SPDX-License-Identifier: MIT
-- This script attempts to implement the basic functionality needed in order for
-- the LADXR Archipelago client to be able to talk to BizHawk instead of RetroArch
-- by reproducing the RetroArch API with BizHawk's Lua interface.
--
-- RetroArch UDP API: https://github.com/libretro/RetroArch/blob/master/command.c
--
-- Only
-- VERSION
-- GET_STATUS
-- READ_CORE_MEMORY
-- WRITE_CORE_MEMORY
-- commands are supported right now.
--
-- USAGE:
-- Load this script in BizHawk ("Tools" -> "Lua Console" -> "Script" -> "Open Script")
--
-- All inconsistencies (like missing newlines for some commands) of the RetroArch
-- UDP API (network_cmd_enable) are reproduced as-is in order for clients written to work with
-- RetroArch's current API to "just work"(tm).
--
-- This script has only been tested on GB(C). If you have made sure it works for N64 or other
-- cores supported by BizHawk, please let me know. Note that GET_STATUS, at the very least, will
-- have to be adjusted.
--
--
-- NOTE:
-- BizHawk's Lua API is very trigger-happy on throwing exceptions.
-- Emulation will continue fine, but the RetroArch API layer will stop working. This
-- is indicated only by an exception visible in the Lua console, which most players
-- will probably not have in the foreground.
--
-- pcall(), the usual way to catch exceptions in Lua, doesn't appear to be supported at all,
-- meaning that error/exception handling is not easily possible.
--
-- This means that a lot more error checking would need to happen before e.g. reading/writing
-- memory. Since the end goal, according to AP's Discord, seems to be SNI integration of GB(C),
-- no further fault-proofing has been done on this.
--
local socket = require("socket")
local udp = socket.udp()
udp:setsockname('127.0.0.1', 55355)
udp:settimeout(0)
while true do
-- Attempt to lessen the CPU load by only polling the UDP socket every x frames.
-- x = 10 is entirely arbitrary, very little thought went into it.
-- We could try to make use of client.get_approx_framerate() here, but the values returned
-- seemed more or less arbitrary as well.
--
-- NOTE: Never mind the above, the LADXR Archipelago client appears to run into problems with
-- interwoven GET_STATUS calls, leading to stopped communication.
-- For GB(C), polling the socket on every frame is OK-ish, so we just do that.
--
--while emu.framecount() % 10 ~= 0 do
-- emu.frameadvance()
--end
local data, msg_or_ip, port_or_nil = udp:receivefrom()
if data then
-- "data" format is "COMMAND [PARAMETERS] [...]"
local command = string.match(data, "%S+")
if command == "VERSION" then
-- 1.14 is the latest RetroArch release at the time of writing this, no other reason
-- for choosing this here.
udp:sendto("1.14.0\n", msg_or_ip, port_or_nil)
elseif command == "GET_STATUS" then
local status = "PLAYING"
if client.ispaused() then
status = "PAUSED"
end
if emu.getsystemid() == "GBC" then
-- Actual reply from RetroArch's API:
-- "GET_STATUS PLAYING game_boy,AP_62468482466172374046_P1_Lonk,crc32=3ecb7b6f"
-- CRC32 isn't readily available through the Lua API. We could calculate
-- it ourselves, but since LADXR doesn't make use of this field it is
-- simply replaced by the hash that BizHawk _does_ make available.
udp:sendto(
"GET_STATUS " .. status .. " game_boy," ..
string.gsub(gameinfo.getromname(), "[%s,]", "_") ..
",romhash=" ..
gameinfo.getromhash() .. "\n",
msg_or_ip, port_or_nil
)
else -- No ROM loaded
-- NOTE: No newline is intentional here for 1:1 RetroArch compatibility
udp:sendto("GET_STATUS CONTENTLESS", msg_or_ip, port_or_nil)
end
elseif command == "READ_CORE_MEMORY" then
local _, address, length = string.match(data, "(%S+) (%S+) (%S+)")
address = tonumber(address, 16)
length = tonumber(length)
-- NOTE: mainmemory.read_bytes_as_array() would seem to be the obvious choice
-- here instead, but it isn't. At least for Sameboy and Gambatte, the "main"
-- memory differs (ROM vs WRAM).
-- Using memory.read_bytes_as_array() and explicitly using the System Bus
-- as the active memory domain solves this incompatibility, allowing us
-- to hopefully use whatever GB(C) emulator we want.
local mem = memory.read_bytes_as_array(address, length, "System Bus")
local hex_string = ""
for _, v in ipairs(mem) do
hex_string = hex_string .. string.format("%02X ", v)
end
hex_string = hex_string:sub(1, -2) -- Hang head in shame, remove last " "
local reply = string.format("%s %02x %s\n", command, address, hex_string)
udp:sendto(reply, msg_or_ip, port_or_nil)
elseif command == "WRITE_CORE_MEMORY" then
local _, address = string.match(data, "(%S+) (%S+)")
address = tonumber(address, 16)
local to_write = {}
local i = 1
for byte_str in string.gmatch(data, "%S+") do
if i > 2 then
table.insert(to_write, tonumber(byte_str, 16))
end
i = i + 1
end
memory.write_bytes_as_array(address, to_write, "System Bus")
local reply = string.format("%s %02x %d\n", command, address, i - 3)
udp:sendto(reply, msg_or_ip, port_or_nil)
end
end
emu.frameadvance()
end

BIN
data/lua/core.dll Normal file

Binary file not shown.

132
data/lua/socket.lua Normal file
View File

@@ -0,0 +1,132 @@
-----------------------------------------------------------------------------
-- LuaSocket helper module
-- Author: Diego Nehab
-- RCS ID: $Id: socket.lua,v 1.22 2005/11/22 08:33:29 diego Exp $
-----------------------------------------------------------------------------
-----------------------------------------------------------------------------
-- Declare module and import dependencies
-----------------------------------------------------------------------------
local base = _G
local string = require("string")
local math = require("math")
local socket = require("socket.core")
module("socket")
-----------------------------------------------------------------------------
-- Exported auxiliar functions
-----------------------------------------------------------------------------
function connect(address, port, laddress, lport)
local sock, err = socket.tcp()
if not sock then return nil, err end
if laddress then
local res, err = sock:bind(laddress, lport, -1)
if not res then return nil, err end
end
local res, err = sock:connect(address, port)
if not res then return nil, err end
return sock
end
function bind(host, port, backlog)
local sock, err = socket.tcp()
if not sock then return nil, err end
sock:setoption("reuseaddr", true)
local res, err = sock:bind(host, port)
if not res then return nil, err end
res, err = sock:listen(backlog)
if not res then return nil, err end
return sock
end
try = newtry()
function choose(table)
return function(name, opt1, opt2)
if base.type(name) ~= "string" then
name, opt1, opt2 = "default", name, opt1
end
local f = table[name or "nil"]
if not f then base.error("unknown key (".. base.tostring(name) ..")", 3)
else return f(opt1, opt2) end
end
end
-----------------------------------------------------------------------------
-- Socket sources and sinks, conforming to LTN12
-----------------------------------------------------------------------------
-- create namespaces inside LuaSocket namespace
sourcet = {}
sinkt = {}
BLOCKSIZE = 2048
sinkt["close-when-done"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if not chunk then
sock:close()
return 1
else return sock:send(chunk) end
end
})
end
sinkt["keep-open"] = function(sock)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function(self, chunk, err)
if chunk then return sock:send(chunk)
else return 1 end
end
})
end
sinkt["default"] = sinkt["keep-open"]
sink = choose(sinkt)
sourcet["by-length"] = function(sock, length)
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if length <= 0 then return nil end
local size = math.min(socket.BLOCKSIZE, length)
local chunk, err = sock:receive(size)
if err then return nil, err end
length = length - string.len(chunk)
return chunk
end
})
end
sourcet["until-closed"] = function(sock)
local done
return base.setmetatable({
getfd = function() return sock:getfd() end,
dirty = function() return sock:dirty() end
}, {
__call = function()
if done then return nil end
local chunk, err, partial = sock:receive(socket.BLOCKSIZE)
if not err then return chunk
elseif err == "closed" then
sock:close()
done = 1
return partial
else return nil, err end
end
})
end
sourcet["default"] = sourcet["until-closed"]
source = choose(sourcet)

Binary file not shown.

Binary file not shown.

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