Compare commits

...

426 Commits
0.3.7 ... 0.4.1

Author SHA1 Message Date
Exempt-Medic
18127a75f5 Blasphemous: Fixed logic errors in WotHP 2023-05-19 11:05:52 +02:00
espeon65536
899de428df ALttP: fix dungeon fill failures properly (#1812) 2023-05-18 15:31:12 +02:00
Fabian Dill
f401702e7c Core: skip ModuleUpdate in subprocess 2023-05-18 15:29:17 +02:00
JaredWeakStrike
68bfe1705d KH2: AntipointReset (#1815) 2023-05-18 15:28:35 +02:00
Fabian Dill
98b0bf7456 LttP: use local_early_items for Small Key HC in Standard Keyshuffle (#1799) 2023-05-15 09:34:56 +02:00
NewSoupVi
7674e62ba7 The Witness: Logic fix in response to broken seed (Expert Swamp) 2023-05-15 08:54:12 +02:00
zig-for
0b33c25b39 Fix pokemon lua on bizhawk 2.9 (#1794)
---------

Co-authored-by: Alchav <59858495+Alchav@users.noreply.github.com>
2023-05-11 17:52:29 +02:00
Scipio Wright
62f4e62d71 Docs: Add location count specifics to Noita (#1805)
Added specifics about the number of checks in the pool.
2023-05-10 17:49:05 +02:00
espeon65536
48add4687c alttp: remove triforce during dungeon item fill (#1801)
This ensures that even for minimal worlds, the locations will be checked appropriately.
2023-05-10 13:06:25 +02:00
JaredWeakStrike
cc08e853a0 KH2: Ability Sync Fix (#1804) 2023-05-10 13:04:43 +02:00
lordlou
7e3fa5058d SM: door color rando option doc (#1803)
Added precision in DoorsColorsRando docstring about beams being forced local items if enabled.
2023-05-09 03:12:24 +02:00
t3hf1gm3nt
c74577d708 [TLOZ] Fix start weapon locations (#1802)
* Fix starting weapon locations usage

Makes a fresh copy of starting weapon locations when get_pool_core is ran
Should fix the issue of dangerous_weapon_locations getting appended to the list for other worlds past the first world that has dangerous StartingPosition, as well as running into the error if ExpandedPool was different between players
Credit for fix goes to @Silvris in the AP Discord
2023-05-08 22:36:35 +02:00
JaredWeakStrike
a8b76b1310 KH2: Async fix and linter cleanup (#1796) 2023-05-07 04:49:37 +02:00
Zach Parks
c8ebad1dfe WebHost: Prevent dict type options with valid_keys from exporting with [] on weighted settings. (#1762)
Thanks HK.
2023-05-06 19:07:57 -05:00
Cyb3R
d3447a3983 Launcher: Fix multiprocessing in built Launcher (#1792) 2023-05-05 00:53:57 +02:00
Fabian Dill
11b2b5ed2f Core: call stages in sorted order (#1791) 2023-05-04 14:14:20 +02:00
Fabian Dill
a0464ecea1 Core: fix start_inventory_from_pool breaking if it's removing the last instance of an item from pool.
Core: fix start_inventory_from_pool removing arbitrary items from pool if quick abort branch is entered.
2023-05-04 03:10:52 +02:00
zig-for
97fd78ba1b LADX: fix bizhawk 2.9 (#1784) 2023-05-03 23:35:14 +02:00
Fabian Dill
a60f370224 Test: fix setting seed from the hash of the seed method, rather than calling it 2023-05-03 04:31:35 +02:00
Scipio Wright
0363630f61 Noita - Docs updates (#1789)
* Docs updates

* Update worlds/noita/docs/setup_en.md

Co-authored-by: Adam Heinermann <aheinerm@gmail.com>

---------

Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
Co-authored-by: Adam Heinermann <aheinerm@gmail.com>
2023-05-03 00:35:50 +02:00
black-sliver
39c7c7291e Core: store multidata/multisave enums by value on py3.11 (#1783)
This is what py3.10 did and should be better for AP than the new default
2023-05-02 08:23:39 +02:00
TheLynk
a368520200 Add New Translation for Adventure and Archipidle in french (#1749)
* Add new translation for Adventure and Archipidle in french

Add new translation for Adventure and Archipidle in french

* Add more store in setup page subnautica for more fairness

Add more store in setup page subnautica for more fairness

* tweak update merge #1685 for lua file

tweak update merge #1685 for lua file

* fix text

fix text

* fix wrong translation

fix wrong translation

* Yes it's better

Yes it's better

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

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
2023-05-01 02:03:31 +02:00
Fabian Dill
5d25f908a4 Launcher: fix loading of mcicon (#1779)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-04-30 18:10:58 +02:00
Fabian Dill
9edab76567 WebHost: recycle generator processes 2023-04-30 16:34:03 +02:00
kindasneaki
91b60f2e21 [RoR2] Classic mode logic fix (#1775) 2023-04-29 09:07:42 +02:00
Jarno
41b59488e3 [Docs] Added lua lib (#1751)
* [Docs] Added lua lib

* Update docs/network protocol.md

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-04-29 00:10:43 +02:00
Fabian Dill
42da24cb5e WebHost: offer room owner log download link 2023-04-28 05:31:19 +02:00
zig-for
28c5e9ee65 LADX: Rework dungeon item fill (#1763) 2023-04-28 05:30:13 +02:00
alwaysintreble
b55174ccdf Docs: document option alias in the options doc (#1755)
* Docs: document option alias in the options doc

* give an example of alias and move it under option creation.

* use clearer example names
2023-04-27 09:33:49 +02:00
lordlou
7bcf299412 SM: missing foreign item filter fix (#1774) 2023-04-27 02:23:52 +02:00
Abacys
a7816d186f Launcher: Correcting minor formatting error (#1768)
Reformatting comment to comply with PEP format
2023-04-26 13:43:23 +02:00
Fabian Dill
9d40471dee Subnautica: add free samples option 2023-04-26 10:50:22 +02:00
zig-for
b704070de5 LADX: Fix palettes (#1767) 2023-04-26 10:49:38 +02:00
Fabian Dill
6c459066a7 Core: add generator_version to network protocol 2023-04-26 10:48:57 +02:00
Fabian Dill
4c3eaf2996 LttP: fix that collect can bypass requirements for ganon ped goal (#1771)
LttP: more pep8
2023-04-26 10:48:08 +02:00
TheBigSalarius
bb56f7b400 FF1: Added URange fix for Bizhawk 2.9 support
URange wasn't moved to common.lua (and no longer exists in connector_ff1.lua) when the lua files were changed for Bizhawk 2.9 socket change.
2023-04-26 10:47:25 +02:00
Doug Hoskisson
22ed7ff9c3 Zillion: fix empty 1st Sphere (#1770)
There was a low probability that the Zillion 1st sphere could be empty.
caused this test failure: https://github.com/ArchipelagoMW/Archipelago/actions/runs/4791795268/jobs/8522615992
2023-04-26 07:24:47 +02:00
axe-y
173513c9f4 DLCQuest: Generation bug fix (#1757)
* Fix documentation error

* init_creation

somehow this fix bug

* item_shuffle_fix

also a count as been corrected

* Fix_early_generation

* Update __init__.py

* Update __init__.py

fix version specific bug

* fix rule set for final boss

and did some reformation
(thanks kaito)

* Update Rules.py

the sword trio can now be in itself if before or actually themself

* Core: correct typing info for item_in_locations
Core: rename item_in_locations to item_name_in_location_names
Core: add actual item_name_in_locations

* item_shuffle_fix

also a count as been corrected

* Fix_early_generation

* fix rule set for final boss

and did some reformation
(thanks kaito)

* Update Rules.py

the sword trio can now be in itself if before or actually themself

* Fix the missing []
and switch to the good function

* - Cleanup and Black Sliver's suggestions

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: Alex Gilbert <alexgilbert@yahoo.com>
2023-04-25 09:06:58 +02:00
Bicoloursnake
c0cf35edda StarCraft 2 macOS documentation (#1747)
* Adding SC2 macOS instructions

A few hours ago, I tested whether the client would run successfully on macOS (Send/Receive items, load maps, download maps, etc.). After the successful testing, I thought adding some documentation would be nice for those who want to play Archipelago on a macOS system.

* Don't need sudo

Turns out you don't need sudo to do the download_data, oops.

* Removed Extraneous Parantheses
2023-04-25 00:53:33 +02:00
Fabian Dill
dcc628f878 Core: correct typing info for item_in_locations
Core: rename item_in_locations to item_name_in_location_names
Core: add actual item_name_in_locations
2023-04-24 23:14:13 +02:00
Fabian Dill
b950af09a6 Factorio: remove tech_tree_layout_prerequisites from core 2023-04-24 23:11:25 +02:00
Fabian Dill
58aea7ca58 Multiserver: cleaner exit (#1743) 2023-04-23 22:21:28 +02:00
JaredWeakStrike
06a25a903e KH2: New Unit Test and better keyblade fill (#1744)
__init__:
 - Added exception for if the player has too many excluded abilities on keyblades.
 - Fixed Action Abilities only on keyblades from breaking.
 - Added proper support for ability quantity's instead of 1 of the ability 
 - Moved filling the localitems slot data to init instead of generate_output so I could easily unit test it

TestSlotData:
- Checks if the "localItems" part of slot data is filled. This is used for keeping track of local items and making sure nothing dupes
2023-04-23 22:20:43 +02:00
Alchav
62a265cc31 Pokémon R/B: logic and location name fixes (#1752)
Corrects incorrect name listed for location in rules.py leading to logic rules failing to apply.
Swaps location names for incorrectly-named trainersanity checks in Viridian Gym
2023-04-23 22:17:03 +02:00
lordlou
67c3076572 SM: comeback fix6 and some refactor (#1756)
refactored and cleaned a bit SMWorld class for best practices:
- moved content of Regions.py and Rules.py in SMWorld
- moved appropiate code to their dedicated World core functions
- moved some Entrances being created in generate_basic to create_regions
more comeback check fixes:
- fixed setting progression door openers items local if doors_colors_rando is used
- enable comeback check only for filling stage as later stages (progression balancing, accessibility and spoiler playthrough) are prone to fail with it
2023-04-23 22:16:01 +02:00
agilbert1412
ab5cb7adad Stardew Valley - Add alias for renamed Fishsanity option (#1758) 2023-04-23 21:59:46 +02:00
lordlou
5e84f91d2f SM: comeback fix5 (#1746) 2023-04-22 02:57:31 +02:00
zig-for
a7a17a5a4d Fix OOT? (#1721) 2023-04-20 09:23:04 +02:00
Ziktofel
a6ea3e1953 Sc2wol logic update (#1715)
Fixes some issues with typos and oversights logic generation in SC2

Rebalancing
Adds Vultures to Advanced tactics as train killers
Drops Firebats from basic unit pool and moves Goliaths from advanced tactics to standard
2023-04-20 09:13:34 +02:00
zig-for
0515acc8fe LADX: no pre fill (#1673)
* no pre fill
* redo trade logic
2023-04-20 09:12:53 +02:00
Fabian Dill
bf5c1cbbbf WebHost: add spoiler level field to generate form 2023-04-20 09:12:07 +02:00
alwaysintreble
c8fb46a5e6 The Messenger: Throw error for invalid names and replace _ with (#1728)
* Replace '_' with ' ' and throw error for other invalid names

Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
2023-04-20 09:11:15 +02:00
lordlou
a38a2903d5 SM: comeback fix4 (#1741) 2023-04-20 09:10:21 +02:00
JaredWeakStrike
4ef7e43521 KH2: client bug fixes (#1742)
Use item index instead of location and player to determine if the player should get the item.
Fixed getting stat increases on the title screen breaking stuff.
Changed local locations list from a list organized by world-id to a set.
Fixed the inventory slots to be the actual back of inventory.
Fixed recaching when not closing the client but switching slots .
Fixed getting a ability faster than the game so it dupes.
Removed verify location since it was never used.
2023-04-20 09:08:59 +02:00
Fabian Dill
e1f17fadfc Launcher: add icons where present and add headers to table 2023-04-20 05:59:49 +02:00
Adam Heinermann
4dc934729d Noita: implement new game (#1676)
* Noita: implement new game (#1676)

---------

Co-authored-by: DaftBrit <87314354+DaftBrit@users.noreply.github.com>
Co-authored-by: l.kelsall@b4rn.org.uk <l.kelsall@b4rn.org.uk>
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: Scipio Wright <scipiowright@gmail.com>
Co-authored-by: Scipio Wright <lightdemonjoe4@gmail.com>
Co-authored-by: Zach Parks <zach@alliware.com>
2023-04-19 22:21:56 -05:00
Zach Parks
722757e18a Clique: Better, less-obtuse, docs for Clique. (#1719) 2023-04-19 21:17:16 -05:00
kindasneaki
0ca3c5e6a2 [kivy] change the sizing for macOS (#1732) 2023-04-19 23:16:13 +02:00
agilbert1412
664bbd86bb Stardew Valley: Fix Mistake and Formatting in settings page (#1737) 2023-04-19 23:15:22 +02:00
axe-y
7a9d4272be Generation bug fix (#1740) 2023-04-19 23:14:46 +02:00
alwaysintreble
7559adbb14 LTTP: fix bad parens in logging 2023-04-19 04:32:15 +02:00
Trevor L
1a7bc4ffd4 Blasphemous: Fix logic for Laudes (for real this time) (#1727) 2023-04-18 02:03:06 +02:00
Fabian Dill
be74a4a71a LttP: update xxtea to 3.0.0 2023-04-17 22:56:54 +02:00
lordlou
cb634fa8d4 SM: failing generation fixes (#1726)
- fixed wrong condition in Collect to assign lastAP
- fixed possible infinite loop in generating output when many SM worlds are present
- fixed new VARIA code that changed a list used for every SM worlds and would throw if many SM worlds uses Aea rando and not AreaLayout
2023-04-17 05:46:19 +02:00
axe-y
f6758524d5 DLCQuest: fix loader bug (#1729) 2023-04-17 02:38:48 +02:00
Fabian Dill
f395a6d184 Docs: has_all and has_any (#1725)
Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
2023-04-16 12:59:53 +02:00
agilbert1412
ea03c90152 Stardew Valley: Fix a logic bug and a documentation typo (#1722)
Typo in the documentation
Logic error for help wanted quests
2023-04-16 11:22:33 +02:00
Trevor L
50d9ab041a Blasphemous: Fix logic for Laudes (#1724) 2023-04-16 10:53:42 +02:00
axe-y
acd3cb45bf DLCQuest: Fix documentation error (#1720) 2023-04-16 05:04:47 +02:00
JaredWeakStrike
8a78062825 KH2: fixed lucky emblem required>lucky emblem amount (#1718) 2023-04-16 01:59:01 +02:00
Fabian Dill
599cd2c82e Launcher: add Discord links and generate yamls (#1716) 2023-04-16 01:57:52 +02:00
espeon65536
89ec31708e oot: bugfixes (#1709)
* oot client: check types of tables coming from lua script for safety
There was a reported bug with corrupted (?) slot data preventing locations sending. This should safeguard against any instances of that happening in the future, if it ever happens again.

* oot: repair minor hint issues
SMW has # in some location names which breaks ootr's poor text formatting system, so those need to be filtered out.
Also replaces "[X] for [player Y]" with "[player Y]'s X" as frequently requested.

* oot: update required client version

* oot client: fix patching filename bug

* oot: fix broken poachers saw item
how was I this stupid, seriously

* oot: sanitize player, location, and item names everywhere
2023-04-16 01:45:31 +02:00
Fabian Dill
ef211da27f Core: update modules 2023-04-16 01:43:24 +02:00
JaredWeakStrike
0122eb38ab KH2: Fix validAbilitys (#1714) 2023-04-15 22:04:08 +02:00
JaredWeakStrike
3d8bc0bb67 KH2: Init Cleanup and Keyblade Fix (#1713) 2023-04-15 21:17:23 +02:00
JusticePS
d85c13ef0e Adventure: Fix error in connector lua that was fairly harmless in BizHawk 2.8 but throws in 2.9 2023-04-15 21:16:11 +02:00
Doug Hoskisson
27cb93d319 Asset: make icon 512 x 512 (#1710) 2023-04-15 10:37:31 -05:00
black-sliver
b0e8c8db6b MultiServer: fix broken ping (#1711)
Ping argument seems to have changed in an update of websockets.
This uses the lib's default of 20s now.
2023-04-15 17:10:33 +02:00
zig-for
5a7d20d393 Lua: Further centralize code, fix Bizhawk 2.9 (#1685)
* Fixes the socket library for bizhawk 2.9/lua 5.4 by including another one in parallel
* Fixes lua 5.4 support by making socket.lua into a "modern" module (the `module` keyword is gone)
* Adds the linux version and 32 bit windows socket dlls because why not
* Merges common functions into `common.lua` - the only functional change of this should be that:
  * Some things that were locals are globals now - this can be changed, I just was lazy and it likely doesn't matter
  * `drawText` now uses middle/bottom for all prints - feel free to do what you like with that change
2023-04-15 09:17:33 +02:00
zig-for
808203a50f LADX: fix egg sprite in non-patched gfx mode (#1699) 2023-04-15 07:47:45 +02:00
zig-for
d8f79b4a42 LADX: Fix useless item being marked as progression (#1700) 2023-04-15 06:48:05 +02:00
zig-for
02ef6cee47 LADX: Support magpie tracker's sendfull button (#1701) 2023-04-15 06:47:36 +02:00
JaredWeakStrike
e716b50f8c KH2: Fixed Non Deterministic Generation (#1707) 2023-04-15 06:15:04 +02:00
agilbert1412
3fdf07677c Stardew Valley: Removed Pytest Requirement from everything (#1696) 2023-04-15 05:42:02 +02:00
t3hf1gm3nt
d3baca9251 [TLOZ] Add FileNotFoundError handling for base rom (#1708) 2023-04-15 05:40:48 +02:00
alwaysintreble
f52ca2571f Tests: Add tests for location name groups (#1706) 2023-04-14 20:11:01 +02:00
Fabian Dill
469807ba01 Core: remove outdated assert on push_item 2023-04-14 20:05:12 +02:00
alwaysintreble
8ada91939c LTTP: Fix Location Name Groups (#1705) 2023-04-14 20:04:20 +02:00
axe-y
054d14baa4 Fix DLCQuest Errors Generating on Latest Source, without any DLCQuest YAMLs (#1704) 2023-04-14 07:09:50 +02:00
Magnemania
f0324e60f8 SC2: Removed extra space from location (#1697) 2023-04-11 11:50:26 -05:00
zig-for
70ff19ac8c LADX: AP egg title screen (#1683) 2023-04-11 09:18:33 +02:00
Fabian Dill
b02b329181 DLCQuest: fix data_version 2023-04-11 08:47:19 +02:00
Nyx-Edelstein
8b7ffaf671 ALTTP: Add "oof" sound customization option (#709)
Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: Fabian Dill <fabian.dill@web.de>
Co-authored-by: Zach Parks <zach@alliware.com>
2023-04-10 21:31:57 -05:00
toasterparty
c711d803f8 [OC2] Enabled DLC Option (#1688)
- New OC2 option `DLCOptionSet`, which is a list of DLCs whose levels should or shouldn't be used for entrance randomizer (and mention in documentation). By default, DLC owners now need to enable DLCs in weighted settings.
- Throw user-friendly exceptions when contradictory settings are enabled
- Slightly relax generation requirements for sphere 1/2 level permutations
- Write entrance randomizer info in spoiler log
- Skip adding "Dark Green Ramp" to item pool if Kevin Levels are disabled
2023-04-11 03:43:29 +02:00
Zach Parks
3c3954f5e8 Core: Band-aid fixes start_inventory_from_pool causing generation failures if any world doesn't utilize it. (#1694)
* Core: Band-aid fixes `start_inventory_from_pool` causing generation failures if any world does utilize it.

* Core: Slightly better(?) solution

* Set default so it doesn't fail on WebHost.
2023-04-10 20:18:29 -05:00
Alchav
05d398a51d Pokémon R/B: Missing game corner logic (#1693)
* Game corner logic fix

* Fix for the fix
2023-04-10 20:16:38 -05:00
agilbert1412
5eadbc9840 Major Game Update: Stardew Valley v3.x.x - The BK Update (#1686)
This is a major update for Stardew Valley, for version 3.x.x.
Changes include a large number of new features, including Seasons Randomizer, SeedShuffle, Museumsanity, Friendsanity, Complete Collection Goal, Full House Goal, friendship multiplier

Co-authored-by: Jouramie <jouramie@hotmail.com>
2023-04-11 01:44:59 +02:00
Zach Parks
0c1e3097c3 WebHost: Set defaults for lists/sets on Weighted Settings page (#1692) 2023-04-10 18:01:54 -04:00
Fabian Dill
cdf7ca1dcc Core: allow ordered valid keys (#1690)
Co-authored-by: el-u <109771707+el-u@users.noreply.github.com>
2023-04-10 23:54:56 +02:00
alwaysintreble
77fbd0eb2b MultiServer: Notify clients of hint points (#1548)
* notify clients of their amount of hint points on initial connection and when hinting

* send in connect packet instead of sending a RoomUpdate on connect

* send hint_points update in `on_new_hint`

* add to connected packet docs

* hint_points isn't a new variable on RoomUpdate now

* note roomupdate can contain connected members

* add the hint point stuff to commonclient

* only show hint points when relevant and default to 0

* Revert "note roomupdate can contain connected members"

* remove hint_points from roomupdate args list and condense explanation of possible packet args

* updates from phar's review

* Small tweak to wording in RoomUpdate

---------

Co-authored-by: Fabian Dill <Berserker66@users.noreply.github.com>
Co-authored-by: Phar <zach@alliware.com>
2023-04-10 14:44:20 -05:00
Fabian Dill
c7284f90d9 Core: implement start_inventory_from_pool (#1170)
* Core: implement start_inventory_from_pool

* Factorio/LttP/Subnautica: add start_inventory_from_pool Option
2023-04-10 21:13:33 +02:00
alwaysintreble
8d559daa35 LTTP: use server for counting locations instead of having 249/216 (#1648) 2023-04-10 21:12:06 +02:00
Ziktofel
e49ffc64f2 SC2: Rebalance item classes (#1512)
It's been a month, if Condor disapproves this can be reverted.
I don't really know what to do about the SC2WOL situation going forward.
2023-04-10 21:08:49 +02:00
alwaysintreble
94a02510c0 core: Region management helpers (#761) 2023-04-10 21:07:37 +02:00
Fabian Dill
9d73988030 Tests: check that options have a docstring (#823) 2023-04-10 04:33:47 +02:00
JaredWeakStrike
81411a191c KH2: init cleanup and random visit locking fix and docs update. (#1652)
Random Visit Locking wasn't copying correctly
init cleanup and moved itempool population to create_items
Updated docs due to a lot of people having issues setting it up.
2023-04-10 04:12:23 +02:00
lordlou
6059b5ef66 SM: 20221101 update (#1479)
This adds support to most of Varia's 20221101 update. Notably, added Options for:
- Objectives
- Tourian
- RelaxedRoundRobinCF

As well as previously unsupported Options:
- EscapeRando
- RemoveEscapeEnemies
- HideItems
2023-04-10 00:35:46 +02:00
black-sliver
0bc5a3bc8d Readme: add missing game DLC Quest (#1682) 2023-04-09 15:53:23 -05:00
black-sliver
11fdb29357 Doc: apworlds have to be all lower case
https://discord.com/channels/731205301247803413/731214280439103580/1094655639600508999
2023-04-09 21:48:22 +02:00
axe-y
bbef7a4cbc DLCQuest : implement new game (#1628)
adding DLC Quest as a new game
2023-04-09 21:06:59 +02:00
Fabian Dill
8e7bbb4ea8 Factorio: flatten science pack curve (#1660)
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2023-04-09 20:58:24 +02:00
alwaysintreble
6628e8c85d Docs: Add some more details to running from source doc (#1680)
* make build tools step more obviously optional and give better directions

* review commit
2023-04-09 15:53:14 +02:00
lordlou
84402a1b55 SM and SMZ3 apworld support (#1677) 2023-04-08 22:52:34 +02:00
Fabian Dill
f4035b8621 MultiServer: remove remaining forfeit compat from network layer 2023-04-08 20:10:07 +02:00
Fabian Dill
bbf8546867 MultiServer: Flag for saving on datastore, create_as_hint scout and client state change 2023-04-08 20:09:51 +02:00
Zach Parks
67a22b8b43 Clique: Force priority location for "final location" and other minor tweaks. (#1583)
* Clique: The greatest game of all time

* Fix failing test

* Increment data_version for backwards compat

* Clique: Tweaked names and forced progression for "The Button"

* Clique: Just location/item definitions to class

* Clique: More tweaks.

* Clique: Fix derp moment.

* Clique: Fix derp moment, part dos.

* Clique: Suggested changes.

* Clique: Update domain

* Clique: Create derived classes for Item and Location

* Clique: simplify line
2023-04-07 19:05:16 -05:00
Trevor L
8e6ec85532 Blasphemous: Update docs, item creation (#1670)
* Blasphemous: Update docs, item creation

* Blasphemous: Remove get_pre_fill_items
2023-04-07 19:04:34 -05:00
Yussur Mustafa Oraji
8fc50510a0 sm64ex,v6: Use create_items for itempool modification (#1674) 2023-04-07 19:03:28 -05:00
Trevor L
aa6ad5d34f Hylics 2: Update item creation (#1671) 2023-04-07 20:01:26 +02:00
zig-for
ccb89dd65c WebHost: Fix upload of .archipelago file (#1657)
* Fix upload

* simple fix for slot data

* remove extra patch.data check

* remove extra parens

* Update WebHostLib/upload.py

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

* Update WebHostLib/upload.py

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

* parse -> process

* Update WebHostLib/upload.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>
2023-04-07 00:24:03 +02:00
agilbert1412
a86c0aa37d Stardew: Fix link in Setup Guide (#1672) 2023-04-06 20:07:09 +02:00
zig-for
ece6598b09 LADX: apworld (#1665) 2023-04-06 20:06:34 +02:00
alwaysintreble
eef8f7af1a The Messenger: Add Mega Time Shards and Quest 1 boss locations (#1661)
* implement mega shards

* create the option and locations, add to slot data and tests

* add boss refights as locations

* remove barma'thazel. it's apparently impossible to get to him

* remove barma'thazel again

* up max shard count to 85

* increment version

* dynamically alter the power seal pool

* revert host.yaml change

* two mega shards were missing from the maps

* add new checks to the info page

* add some more rules to skylands

* forgot to update my tests

* explicit imports, remove unnecessary typing, lower required client ver

* use generators for shard and seal creation
2023-04-06 10:48:30 +02:00
Chris Wilson
c626618221 Update ArchipIDLE's documentation and create items in create_items func. (#1669) 2023-04-06 00:08:41 -04:00
kindasneaki
47989325f8 [Webhost] header closing tag moved after mobile menu (#1650)
* Change archipelago mod download page

* Docs: change connecting to archipelago in RoR2 setup guide

* /header off by one
2023-04-05 19:27:56 -04:00
black-sliver
815e7e6b0a Core: default data_version to 0 (#1668)
* Core: default data_version to 0

This allows new (ap-)worlds to function with old clients without having to define a version.

* Blasphemous: fix data_version
2023-04-06 01:19:58 +02:00
Fabian Dill
a61a1f58c6 Network: allow filtering checked and missing by text fragment 2023-04-05 19:36:32 +02:00
black-sliver
4c24872264 CI: update ubuntu to 20.04
18.04 will not be supported starting 2023-04-01
2023-04-05 19:16:32 +02:00
Zach Parks
e778e49574 Core: Fix ZeroDivisionError if a world is contained 100% locked locations. (#1659)
* Core: Fix divide by zero error if all locations are priority

* Core: 100% makes more sense than 0% in this context

* Core: Revert a some "autoformatter" damage

* Core: Move comparisons a bit earlier.

Worlds that are 100% filled with locked locations should now be completely skipped in the progression balancing step now as we filter them out at the very beginning of the stage now. Also skips progression balancing if it turns out all locations are locked early before attempting to balance.
2023-04-05 12:11:34 -05:00
FlySniper
25f7413881 Wargroove: Client is not added to the Start Menu. (#1664)
* Wargroove - Fixed client not showing up in the windows search menu.
2023-04-05 00:07:15 -05:00
Alchav
397ce8343e Pokémon R/B: Pokédex option fixes (#1666)
* Pokémon R/B: Pokedex option fixes

* Pokémon R/B: Missing option display names
2023-04-04 23:59:59 -05:00
Trevor L
37fdc00517 Blasphemous: Small logic fixes (#1667) 2023-04-04 23:54:51 -05:00
alwaysintreble
03aa9b3604 Update AP icons (#1637) 2023-04-04 23:38:23 -05:00
JusticePS
8f52e4654f Adventure: Change user_path to local_path for locating basepatch file (#1639) 2023-04-04 19:32:03 +02:00
alwaysintreble
a86fd37860 Core: set convert_name_groups to true for LocationSet (#1663) 2023-04-04 19:29:20 +02:00
toasterparty
eb503adb13 [OC2] Only Calculate Priority Locations Once (#1662) 2023-04-04 06:53:05 +02:00
Fabian Dill
cbf72becc1 Subnautica: group items and removal of item pool option 2023-04-04 06:46:54 +02:00
alwaysintreble
cdd460ae15 The Messenger: some miscellaneous cleanup. Also found a logic bug. oops. (#1654) 2023-04-04 02:27:36 +02:00
toasterparty
ffd968d89d [OC2] Fix Overworld Access Logic for World 3 (#1655) 2023-04-04 02:26:24 +02:00
toasterparty
8d73746d5b [OC2] Spelling Mistakes (#1656) 2023-04-04 02:25:59 +02:00
zig-for
5ed56db48a LADX: Fix crash in item pick up with > 100 players (#1658) 2023-04-04 02:23:39 +02:00
PoryGone
5f447f4e6b SMW: Fix Option Tooltip (#1651) 2023-04-02 03:54:07 +02:00
Fabian Dill
f015cf4298 MultiServer: compat fix if checksum is not present (#1642) 2023-04-01 22:40:14 +02:00
Freya Arbjerg
e43bb99622 Fix broken styling of multitracker navigation (#1644) 2023-04-01 19:56:08 +02:00
JaredWeakStrike
34de5a57af KH2: updated the docs for options and faq (#1641) 2023-04-01 19:55:43 +02:00
Fabian Dill
510a460d84 Multiserver: location name groups fix (#1643) 2023-04-01 19:54:44 +02:00
Justin LaLone
6e271b643d Adventure: Added examples for automatically loading adventure_connector.lua
in BizHawk
2023-04-01 19:54:02 +02:00
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
849 changed files with 81394 additions and 35250 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,24 +46,24 @@ 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 }}
retention-days: 7 # keep for 7 days, should be enough
build-ubuntu1804:
runs-on: ubuntu-18.04
build-ubuntu2004:
runs-on: ubuntu-20.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
@@ -30,20 +29,20 @@ jobs:
# build-release-windows: # this is done by hand because of signing
# build-release-macos: # LF volunteer
build-release-ubuntu1804:
runs-on: ubuntu-18.04
build-release-ubuntu2004:
runs-on: ubuntu-20.04
steps:
- 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,17 +41,19 @@ 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: |

6
.gitignore vendored
View File

@@ -8,6 +8,7 @@
*.apm3
*.apmc
*.apz5
*.aptloz
*.pyc
*.pyd
*.sfc
@@ -25,6 +26,7 @@
*multisave
*.archipelago
*.apsave
*.BIN
build
bundle/components.wxs
@@ -50,6 +52,8 @@ Output Logs/
/Archipelago.zip
/setup.ini
/installdelete.iss
/data/user.kv
/datapackage
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -137,6 +141,7 @@ ENV/
env.bak/
venv.bak/
.code-workspace
shell.nix
# Spyder project settings
.spyderproject
@@ -166,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 connector_adventure.lua"
CONNECTION_REFUSED_STATUS = \
"Connection Refused. Please start your emulator and make sure connector_adventure.lua is running"
CONNECTION_RESET_STATUS = \
"Connection was reset. Please restart your emulator, then restart connector_adventure.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.local_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 ChainMap, Counter, OrderedDict, deque
from enum import IntEnum, IntFlag
from typing import Any, Callable, Dict, Iterable, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union
import NetUtils
import Options
import Utils
import NetUtils
class Group(TypedDict, total=False):
@@ -29,6 +28,20 @@ class Group(TypedDict, total=False):
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():
debug_types = False
player_name: Dict[int, str]
@@ -48,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]]
@@ -57,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
@@ -68,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
@@ -90,7 +113,6 @@ class MultiWorld():
self.dark_world_light_cone = False
self.rupoor_cost = 10
self.aga_randomness = True
self.lock_aga_door_in_escape = False
self.save_and_quit_from_boss = True
self.custom = False
self.customitemarray = []
@@ -99,6 +121,7 @@ class MultiWorld():
self.early_items = {player: {} for player in self.player_ids}
self.local_early_items = {player: {} for player in self.player_ids}
self.indirect_connections = {}
self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {}
self.fix_trock_doors = self.AttributeProxy(
lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted')
self.fix_skullwoods_exit = self.AttributeProxy(
@@ -112,7 +135,6 @@ class MultiWorld():
def set_player_attr(attr, val):
self.__dict__.setdefault(attr, {})[player] = val
set_player_attr('tech_tree_layout_prerequisites', {})
set_player_attr('_region_cache', {})
set_player_attr('shuffle', "vanilla")
set_player_attr('logic', "noglitches")
@@ -159,7 +181,8 @@ 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) -> Tuple[int, ...]:
return self.player_ids + tuple(self.groups)
@@ -204,8 +227,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:
@@ -289,7 +312,7 @@ 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
@@ -312,7 +335,7 @@ 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 """
@@ -391,7 +414,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]
@@ -399,7 +427,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]
@@ -411,7 +444,6 @@ class MultiWorld():
self.state.collect(item, True)
def push_item(self, location: Location, item: Item, collect: bool = True):
assert location.can_fill(self.state, item, False), f"Cannot place {item} into {location}."
location.item = item
item.location = location
if collect:
@@ -708,9 +740,11 @@ class CollectionState():
return self.prog_items[item, player] >= count
def has_all(self, items: Set[str], player: int) -> bool:
"""Returns True if each item name of items is in state at least once."""
return all(self.prog_items[item, player] for item in items)
def has_any(self, items: Set[str], player: int) -> bool:
"""Returns True if at least one item name of items is in state at least once."""
return any(self.prog_items[item, player] for item in items)
def count(self, item: str, player: int) -> int:
@@ -730,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)
@@ -919,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:
@@ -973,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):
@@ -980,6 +836,29 @@ class Region:
for entrance in self.entrances: # BFS might be better here, trying DFS for now.
return entrance.parent_region.get_connecting_entrance(is_main_entrance)
def add_locations(self, locations: Dict[str, Optional[int]], location_type: Optional[typing.Type[Location]] = None) -> None:
"""Adds locations to the Region object, where location_type is your Location class and locations is a dict of
location names to address."""
if location_type is None:
location_type = Location
for location, address in locations.items():
self.locations.append(location_type(self.player, location, address, self))
def add_exits(self, exits: Dict[str, Optional[str]], rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None:
"""
Connects current region to regions in exit dictionary. Passed region names must exist first.
:param exits: exits from the region. format is {"connecting_region", "exit_name"}
:param rules: rules for the exits from this region. format is {"connecting_region", rule}
"""
for exiting_region, name in exits.items():
ret = Entrance(self.player, name, self) if name \
else Entrance(self.player, f"{self.name} -> {exiting_region}", self)
if rules and exiting_region in rules:
ret.access_rule = rules[exiting_region]
self.exits.append(ret)
ret.connect(self.multiworld.get_region(exiting_region, self.player))
def __repr__(self):
return self.__str__()
@@ -1110,7 +989,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))))
@@ -1242,13 +1121,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:
@@ -1258,117 +1133,6 @@ 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]
self.locations = OrderedDict()
listed_locations = set()
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)
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)
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)
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)
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)
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']]}"
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
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
self.bosses[str(player)]["Ganons Tower"] = "Agahnim 2"
self.bosses[str(player)]["Ganon"] = "Ganon"
def create_playthrough(self, create_paths: bool = True):
"""Destructive to the world while it is run, damage gets repaired afterwards."""
from itertools import chain
@@ -1467,7 +1231,7 @@ class Spoiler():
raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}')
# we can finally output our playthrough
self.playthrough = {"0": sorted([str(item) for item in
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])}
@@ -1520,35 +1284,12 @@ class Spoiler():
self.paths[str(multiworld.get_region('Inverted Big Bomb Shop', player))] = \
get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player))
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
return json.dumps(out)
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
@@ -1558,52 +1299,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"])}: '
@@ -1612,30 +1321,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 [
@@ -1674,6 +1367,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,19 +63,22 @@ 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
def _cmd_missing(self) -> bool:
"""List all missing location checks, from your local game state"""
def _cmd_missing(self, filter_text = "") -> bool:
"""List all missing location checks, from your local game state.
Can be given text, which will be used as filter."""
if not self.ctx.game:
self.output("No game set, cannot determine missing checks.")
return False
count = 0
checked_count = 0
for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items():
if filter_text and filter_text not in location:
continue
if location_id < 0:
continue
if location_id not in self.ctx.locations_checked:
@@ -136,7 +139,7 @@ class CommonContext:
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})')
@@ -154,6 +157,7 @@ class CommonContext:
disconnected_intentionally: bool = False
server: typing.Optional[Endpoint] = None
server_version: Version = Version(0, 0, 0)
generator_version: Version = Version(0, 0, 0)
current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server
last_death_link: float = time.time() # last send/received death link on AP layer
@@ -163,6 +167,7 @@ class CommonContext:
server_address: typing.Optional[str]
password: typing.Optional[str]
hint_cost: typing.Optional[int]
hint_points: typing.Optional[int]
player_names: typing.Dict[int, str]
finished_game: bool
@@ -193,7 +198,7 @@ class CommonContext:
self.hint_cost = None
self.slot_info = {}
self.permissions = {
"forfeit": "disabled",
"release": "disabled",
"collect": "disabled",
"remaining": "disabled",
}
@@ -223,7 +228,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")
@@ -256,11 +261,12 @@ class CommonContext:
self.items_received = []
self.locations_info = {}
self.server_version = Version(0, 0, 0)
self.generator_version = Version(0, 0, 0)
self.server = None
self.server_task = None
self.hint_cost = None
self.permissions = {
"forfeit": "disabled",
"release": "disabled",
"collect": "disabled",
"remaining": "disabled",
}
@@ -341,6 +347,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" \
@@ -394,32 +405,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)}])
@@ -429,15 +448,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
@@ -494,7 +515,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})
@@ -580,14 +601,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:
@@ -619,11 +648,16 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
logger.info('Room Information:')
logger.info('--------------------------------')
version = args["version"]
ctx.server_version = tuple(version)
version = ".".join(str(item) for item in version)
ctx.server_version = Version(*version)
logger.info(f'Server protocol version: {version}')
logger.info("Server protocol tags: " + ", ".join(args["tags"]))
if "generator_version" in args:
ctx.generator_version = Version(*args["generator_version"])
logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, '
f'generator version: {ctx.generator_version.as_simple_string()}, '
f'tags: {", ".join(args["tags"])}')
else:
logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, '
f'tags: {", ".join(args["tags"])}')
if args['password']:
logger.info('Password required')
ctx.update_permissions(args.get("permissions", {}))
@@ -648,14 +682,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"]
@@ -683,6 +719,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict):
ctx.slot = args["slot"]
# int keys get lost in JSON transfer
ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()}
ctx.hint_points = args.get("hint_points", 0)
ctx.consume_players_package(args["players"])
msgs = []
if ctx.locations_checked:
@@ -813,6 +850,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

@@ -13,9 +13,9 @@ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandP
SYSTEM_MESSAGE_ID = 0
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart ff1_connector.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure ff1_connector.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart ff1_connector.lua"
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"

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)

66
Fill.py
View File

@@ -1,11 +1,10 @@
import logging
import typing
import collections
import itertools
import logging
import typing
from collections import Counter, deque
from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item, ItemClassification
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld
from worlds.AutoWorld import call_all
from worlds.generic.Rules import add_item_rule
@@ -23,15 +22,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,
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) -> 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:
@@ -39,9 +50,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)
@@ -111,7 +122,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
@@ -133,6 +144,21 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations:
if on_place:
on_place(spot_to_fill)
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():
@@ -142,7 +168,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,
@@ -499,16 +525,16 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
checked_locations: typing.Set[Location] = set()
unchecked_locations: typing.Set[Location] = set(world.get_locations())
reachable_locations_count: typing.Dict[int, int] = {
player: 0
for player in world.player_ids
if len(world.get_filled_locations(player)) != 0
}
total_locations_count: typing.Counter[int] = Counter(
location.player
for location in world.get_locations()
if not location.locked
)
reachable_locations_count: typing.Dict[int, int] = {
player: 0
for player in world.player_ids
if total_locations_count[player] and len(world.get_filled_locations(player)) != 0
}
balanceable_players = {
player: balanceable_players[player]
for player in balanceable_players
@@ -525,6 +551,10 @@ def balance_multiworld_progression(world: MultiWorld) -> None:
def item_percentage(player: int, num: int) -> float:
return num / total_locations_count[player]
# If there are no locations that aren't locked, there's no point in attempting to balance progression.
if len(total_locations_count) == 0:
return
while True:
# Gather non-locked locations.
# This ensures that only shuffled locations get counted for progression balancing,
@@ -798,7 +828,6 @@ def distribute_planned(world: MultiWorld) -> None:
for player in worlds:
locations += non_early_locations[player]
block['locations'] = locations
if not block['count']:
@@ -840,8 +869,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)}, "
@@ -506,12 +471,12 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings
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)):
@@ -626,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", [])
@@ -638,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)):

894
KH2Client.py Normal file
View File

@@ -0,0 +1,894 @@
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 = []
# list used to keep track of locations+items player has. Used for disoneccting
self.kh2seedsave = None
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, 0x25DA],
"Quick Run": [0x62, 0x65, 0x25DC],
"Dodge Roll": [0x234, 0x237, 0x25DE],
"Aerial Dodge": [0x066, 0x069, 0x25E0],
"Glide": [0x6A, 0x6D, 0x25E2]}
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"):
self.kh2seedsave = {"itemIndex": -1,
# back of soras invo is 0x25E2. Growth should be moved there
# Character: [back of invo, front of invo]
"SoraInvo": [0x25D8, 0x2546],
"DonaldInvo": [0x26F4, 0x2658],
"GoofyInvo": [0x280A, 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
"LocationsChecked": [],
"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}
}
with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"),
'wt') as f:
pass
self.locations_checked = set()
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)
self.locations_checked = set(self.kh2seedsave["LocationsChecked"])
self.serverconneced = True
if cmd in {"Connected"}:
self.kh2slotdata = args['slot_data']
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 == 0:
# resetting everything that were sent from the server
self.kh2seedsave["SoraInvo"][0] = 0x25D8
self.kh2seedsave["DonaldInvo"][0] = 0x26F4
self.kh2seedsave["GoofyInvo"][0] = 0x280A
self.kh2seedsave["itemIndex"] = - 1
self.kh2seedsave["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": {},
}
if start_index > self.kh2seedsave["itemIndex"]:
self.kh2seedsave["itemIndex"] = start_index
for item in args['items']:
asyncio.create_task(self.give_item(item.item))
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():
locationId = kh2_loc_name_to_id[location]
if locationId 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.sending = self.sending + [(int(locationId))]
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")
locationId = kh2_loc_name_to_id[location]
if locationId not in self.locations_checked \
and currentLevel >= data.bitIndex:
if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel:
self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel
self.sending = self.sending + [(int(locationId))]
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")
locationId = kh2_loc_name_to_id[location]
if locationId 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.sending = self.sending + [(int(locationId))]
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():
locationId = kh2_loc_name_to_id[location]
if locationId 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.sending = self.sending + [(int(locationId))]
for location, data in formSlots.items():
locationId = kh2_loc_name_to_id[location]
if locationId 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
self.sending = self.sending + [(int(locationId))]
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:
for location in self.locations_checked:
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)
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):
if current - 0x8000 > 0:
self.kh2.write_short(self.kh2.base_address + self.Save + slot, (0x8000 + itemData.memaddr))
else:
self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr)
# removes the duped ability if client gave faster than the game.
for charInvo in {"SoraInvo", "DonaldInvo", "GoofyInvo"}:
if self.kh2.read_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1]) != 0 and \
self.kh2seedsave[charInvo][1] + 2 < self.kh2seedsave[charInvo][0]:
self.kh2.write_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1], 0)
# remove the dummy level 1 growths if they are in these invo slots.
for inventorySlot in {0x25CE, 0x25D0, 0x25D2, 0x25D4, 0x25D6, 0x25D8}:
current = self.kh2.read_short(self.kh2.base_address + self.Save + inventorySlot)
ability = current & 0x0FFF
if 0x05E <= ability <= 0x06D:
self.kh2.write_short(self.kh2.base_address + self.Save + inventorySlot, 0)
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:
# when getting a form anti points should be reset to 0 but bit-shift doesn't trigger the game.
if itemName in {"Valor Form", "Wisdom Form", "Limit Form", "Master Form", "Final Form"}:
self.kh2.write_bytes(self.kh2.base_address + self.Save + 0x3410,
(0).to_bytes(1, 'big'), 1)
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]
# 0x130293 is Crit_1's location id for touching the computer
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 and int.from_bytes(
self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x23DF, 1),
"big") > 0:
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:
if location not in ctx.locations_checked:
ctx.locations_checked.add(location)
ctx.kh2seedsave["LocationsChecked"].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

@@ -11,13 +11,17 @@ Scroll down to components= to add components to the launcher as well as setup.py
import argparse
import itertools
import multiprocessing
import shlex
import subprocess
import sys
from enum import Enum, auto
import webbrowser
from os.path import isfile
from shutil import which
from typing import Iterable, Sequence, Callable, Union, Optional
from typing import Sequence, Union, Optional
import Utils
from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths
if __name__ == "__main__":
import ModuleUpdate
@@ -37,7 +41,6 @@ def open_host_yaml():
exe = which("open")
subprocess.Popen([exe, file])
else:
import webbrowser
webbrowser.open(file)
@@ -57,112 +60,38 @@ def open_patch():
launch([*get_exe(component), file], component.cli)
def generate_yamls():
from Options import generate_yaml_templates
target = Utils.user_path("Players", "Templates")
generate_yaml_templates(target, False)
open_folder(target)
def browse_files():
file = user_path()
open_folder(user_path())
def open_folder(folder_path):
if is_linux:
exe = which('xdg-open') or which('gnome-open') or which('kde-open')
subprocess.Popen([exe, file])
subprocess.Popen([exe, folder_path])
elif is_macos:
exe = which("open")
subprocess.Popen([exe, file])
subprocess.Popen([exe, folder_path])
else:
import webbrowser
webbrowser.open(file)
webbrowser.open(folder_path)
# 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')
}
Component("Open host.yaml", func=open_host_yaml),
Component("Open Patch", func=open_patch),
Component("Generate Template Settings", func=generate_yamls),
Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")),
Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")),
Component("Browse Files", func=browse_files),
])
def identify(path: Union[None, str]):
@@ -218,6 +147,8 @@ def launch(exe, in_terminal=False):
def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label
from kivy.uix.image import AsyncImage
from kivy.uix.relativelayout import RelativeLayout
class Launcher(App):
base_title: str = "Archipelago Launcher"
@@ -239,24 +170,44 @@ def run_gui():
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General"))
self.grid.add_widget(Label(text="Clients"))
button_layout = self.grid # make buttons fill the window
def build_button(component: Component):
"""
Builds a button widget for a given component.
Args:
component (Component): The component associated with the button.
Returns:
None. The button is added to the parent grid layout.
"""
button = Button(text=component.display_name)
button.component = component
button.bind(on_release=self.component_action)
if component.icon != "icon":
image = AsyncImage(source=icon_paths[component.icon],
size=(38, 38), size_hint=(None, 1), pos=(5, 0))
box_layout = RelativeLayout()
box_layout.add_widget(button)
box_layout.add_widget(image)
button_layout.add_widget(box_layout)
else:
button_layout.add_widget(button)
for (tool, client) in itertools.zip_longest(itertools.chain(
self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()):
# column 1
if tool:
button = Button(text=tool[0])
button.component = tool[1]
button.bind(on_release=self.component_action)
button_layout.add_widget(button)
build_button(tool[1])
else:
button_layout.add_widget(Label())
# column 2
if client:
button = Button(text=client[0])
button.component = client[1]
button.bind(on_press=self.component_action)
button_layout.add_widget(button)
build_button(client[1])
else:
button_layout.add_widget(Label())
@@ -295,6 +246,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None):
if __name__ == '__main__':
init_logging('Launcher')
multiprocessing.freeze_support()
parser = argparse.ArgumentParser(description='Archipelago Launcher')
parser.add_argument('Patch|Game|Component', type=str, nargs='?',
help="Pass either a patch file, a generated game or the name of a component to run.")

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):
@@ -107,6 +107,12 @@ def main():
Alternatively, can be a ALttP Rom patched with a Link
sprite that will be extracted.
''')
parser.add_argument('--oof', help='''\
Path to a sound effect to replace Link's "oof" sound.
Needs to be in a .brr format and have a length of no
more than 2673 bytes, created from a 16-bit signed PCM
.wav at 12khz. https://github.com/boldowa/snesbrr
''')
parser.add_argument('--names', default='', type=str)
parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.')
args = parser.parse_args()
@@ -126,6 +132,13 @@ def main():
if args.sprite is not None and not os.path.isfile(args.sprite) and not Sprite.get_sprite_from_name(args.sprite):
input('Could not find link sprite sheet at given location. \nPress Enter to exit.')
sys.exit(1)
if args.oof is not None and not os.path.isfile(args.oof):
input('Could not find oof sound effect at given location. \nPress Enter to exit.')
sys.exit(1)
if args.oof is not None and os.path.getsize(args.oof) > 2673:
input('"oof" sound effect cannot exceed 2673 bytes. \nPress Enter to exit.')
sys.exit(1)
args, path = adjust(args=args)
if isinstance(args.sprite, Sprite):
@@ -165,7 +178,7 @@ def adjust(args):
world = getattr(args, "world")
apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music,
args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
args.sprite, args.oof, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world,
deathlink=args.deathlink, allowcollect=args.allowcollect)
path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc')
rom.write_to_file(path)
@@ -227,6 +240,7 @@ def adjustGUI():
guiargs.sprite = rom_vars.sprite
if rom_vars.sprite_pool:
guiargs.world = AdjusterWorld(rom_vars.sprite_pool)
guiargs.oof = rom_vars.oof
try:
guiargs, path = adjust(args=guiargs)
@@ -265,6 +279,7 @@ def adjustGUI():
else:
guiargs.sprite = rom_vars.sprite
guiargs.sprite_pool = rom_vars.sprite_pool
guiargs.oof = rom_vars.oof
persistent_store("adjuster", GAME_ALTTP, guiargs)
messagebox.showinfo(title="Success", message="Settings saved to persistent storage")
@@ -481,6 +496,36 @@ class BackgroundTaskProgressNullWindow(BackgroundTask):
self.stop()
class AttachTooltip(object):
def __init__(self, parent, text):
self._parent = parent
self._text = text
self._window = None
parent.bind('<Enter>', lambda event : self.show())
parent.bind('<Leave>', lambda event : self.hide())
def show(self):
if self._window or not self._text:
return
self._window = Toplevel(self._parent)
#remove window bar controls
self._window.wm_overrideredirect(1)
#adjust positioning
x, y, *_ = self._parent.bbox("insert")
x = x + self._parent.winfo_rootx() + 20
y = y + self._parent.winfo_rooty() + 20
self._window.wm_geometry("+{0}+{1}".format(x,y))
#show text
label = Label(self._window, text=self._text, justify=LEFT)
label.pack(ipadx=1)
def hide(self):
if self._window:
self._window.destroy()
self._window = None
def get_rom_frame(parent=None):
adjuster_settings = get_adjuster_settings(GAME_ALTTP)
if not adjuster_settings:
@@ -522,6 +567,7 @@ def get_rom_options_frame(parent=None):
"reduceflashing": True,
"deathlink": False,
"sprite": None,
"oof": None,
"quickswap": True,
"menuspeed": 'normal',
"heartcolor": 'red',
@@ -598,12 +644,50 @@ def get_rom_options_frame(parent=None):
spriteEntry.pack(side=LEFT)
spriteSelectButton.pack(side=LEFT)
oofDialogFrame = Frame(romOptionsFrame)
oofDialogFrame.grid(row=1, column=1)
baseOofLabel = Label(oofDialogFrame, text='"OOF" Sound:')
vars.oofNameVar = StringVar()
vars.oof = adjuster_settings.oof
def set_oof(oof_param):
nonlocal vars
if isinstance(oof_param, str) and os.path.isfile(oof_param) and os.path.getsize(oof_param) <= 2673:
vars.oof = oof_param
vars.oofNameVar.set(oof_param.rsplit('/',1)[-1])
else:
vars.oof = None
vars.oofNameVar.set('(unchanged)')
set_oof(adjuster_settings.oof)
oofEntry = Label(oofDialogFrame, textvariable=vars.oofNameVar)
def OofSelect():
nonlocal vars
oof_file = filedialog.askopenfilename(
filetypes=[("BRR files", ".brr"),
("All Files", "*")])
try:
set_oof(oof_file)
except Exception:
set_oof(None)
oofSelectButton = Button(oofDialogFrame, text='...', command=OofSelect)
AttachTooltip(oofSelectButton,
text="Select a .brr file no more than 2673 bytes.\n" + \
"This can be created from a <=0.394s 16-bit signed PCM .wav file at 12khz using snesbrr.")
baseOofLabel.pack(side=LEFT)
oofEntry.pack(side=LEFT)
oofSelectButton.pack(side=LEFT)
vars.quickSwapVar = IntVar(value=adjuster_settings.quickswap)
quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar)
quickSwapCheckbutton.grid(row=1, column=0, sticky=E)
menuspeedFrame = Frame(romOptionsFrame)
menuspeedFrame.grid(row=1, column=1, sticky=E)
menuspeedFrame.grid(row=6, column=1, sticky=E)
menuspeedLabel = Label(menuspeedFrame, text='Menu speed')
menuspeedLabel.pack(side=LEFT)
vars.menuspeedVar = StringVar()
@@ -1056,7 +1140,6 @@ class SpriteSelector():
def custom_sprite_dir(self):
return user_path("data", "sprites", "custom")
def get_image_for_sprite(sprite, gif_only: bool = False):
if not sprite.valid:
return None

118
Main.py
View File

@@ -1,22 +1,24 @@
import collections
import concurrent.futures
import logging
import os
import time
import zlib
import concurrent.futures
import pickle
import tempfile
import time
import zipfile
from typing import Dict, List, Tuple, Optional, Set
import zlib
from typing import Dict, List, Optional, Set, Tuple
from BaseClasses import Item, MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location
import worlds
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 Utils import output_path, get_options, __version__, version_tuple
from worlds.generic.Rules import locality_rules, exclusion_rules
from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region
from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items
from Options import StartInventoryPool
from Utils import __version__, get_options, output_path, version_tuple
from worlds import AutoWorld
from worlds.alttp.Regions import is_main_entrance
from worlds.alttp.Shops import FillDisabledShopSlots
from worlds.alttp.SubClasses import LTTPRegionType
from worlds.generic.Rules import exclusion_rules, locality_rules
ordered_areas = (
'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace',
@@ -37,7 +39,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()
@@ -51,7 +54,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()
@@ -77,7 +79,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
@@ -115,12 +117,20 @@ 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 item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items():
for _ in range(count):
world.push_precollected(world.create_item(item_name, player))
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:
@@ -144,6 +154,37 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
AutoWorld.call_all(world, "generate_basic")
# remove starting inventory from pool items.
# Because some worlds don't actually create items during create_items this has to be as late as possible.
if any(world.start_inventory_from_pool[player].value for player in world.player_ids):
new_items: List[Item] = []
depletion_pool: Dict[int, Dict[str, int]] = {
player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids}
for player, items in depletion_pool.items():
player_world: AutoWorld.World = world.worlds[player]
for count in items.values():
new_items.append(player_world.create_filler())
target: int = sum(sum(items.values()) for items in depletion_pool.values())
for i, item in enumerate(world.itempool):
if depletion_pool[item.player].get(item.name, 0):
target -= 1
depletion_pool[item.player][item.name] -= 1
# quick abort if we have found all items
if not target:
new_items.extend(world.itempool[i+1:])
break
else:
new_items.append(item)
# leftovers?
if target:
for player, remaining_items in depletion_pool.items():
remaining_items = {name: count for name, count in remaining_items.items() if count}
if remaining_items:
raise Exception(f"{world.get_player_name(player)}"
f" is trying to remove items from their pool that don't exist: {remaining_items}")
world.itempool[:] = new_items
# temporary home for item links, should be moved out of Main
for group_id, group in world.groups.items():
def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[
@@ -186,7 +227,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:
@@ -246,6 +287,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()
@@ -281,37 +326,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():
@@ -367,18 +391,16 @@ 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)
# custom datapackage
datapackage = {}
for game_world in world.worlds.values():
if game_world.data_version == 0 and game_world.game not in datapackage:
datapackage[game_world.game] = worlds.network_data_package["games"][game_world.game]
datapackage[game_world.game]["item_name_groups"] = game_world.item_name_groups
# 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()},
"locations": locations_data,
"checks_in_area": checks_in_area,
@@ -390,7 +412,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
"tags": ["AP"],
"minimum_versions": minimum_versions,
"seed_name": world.seed_name,
"datapackage": datapackage,
"datapackage": data_package,
}
AutoWorld.call_all(world, "modify_multidata", multidata)

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,8 @@
import os
import sys
import subprocess
import pkg_resources
import multiprocessing
import warnings
local_dir = os.path.dirname(__file__)
requirements_files = {os.path.join(local_dir, 'requirements.txt')}
@@ -9,7 +10,8 @@ requirements_files = {os.path.join(local_dir, 'requirements.txt')}
if sys.version_info < (3, 8, 6):
raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.")
update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled
# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess)
update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process()
if not update_ran:
for entry in os.scandir(os.path.join(local_dir, "worlds")):
@@ -21,24 +23,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 +82,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 +96,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

View File

@@ -2,26 +2,30 @@ from __future__ import annotations
import argparse
import asyncio
import functools
import logging
import zlib
import copy
import collections
import typing
import inspect
import weakref
import datetime
import threading
import random
import pickle
import itertools
import time
import operator
import functools
import hashlib
import inspect
import itertools
import logging
import operator
import pickle
import random
import threading
import time
import typing
import weakref
import zlib
import ModuleUpdate
ModuleUpdate.update()
if typing.TYPE_CHECKING:
import ssl
import websockets
import colorama
try:
@@ -37,9 +41,30 @@ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, Networ
SlotType
min_client_version = Version(0, 1, 6)
print_command_compatability_threshold = Version(0, 3, 5) # Remove backwards compatibility around 0.3.7
colorama.init()
def remove_from_list(container, value):
try:
container.remove(value)
except ValueError:
pass
return container
def pop_from_container(container, value):
try:
container.pop(value)
except ValueError:
pass
return container
def update_dict(dictionary, entries):
dictionary.update(entries)
return dictionary
# functions callable on storable data on the server by clients
modify_functions = {
"add": operator.add, # add together two objects, using python's "+" operator (works on strings and lists as append)
@@ -56,6 +81,10 @@ modify_functions = {
"and": operator.and_,
"left_shift": operator.lshift,
"right_shift": operator.rshift,
# lists/dicts
"remove": remove_from_list,
"pop": pop_from_container,
"update": update_dict,
}
@@ -116,7 +145,7 @@ class Context:
"location_check_points": int,
"server_password": str,
"password": str,
"forfeit_mode": str,
"release_mode": str,
"remaining_mode": str,
"collect_mode": str,
"item_cheat": bool,
@@ -129,20 +158,23 @@ class Context:
stored_data: typing.Dict[str, object]
read_data: typing.Dict[str, object]
stored_data_notification_clients: typing.Dict[str, typing.Set[Client]]
slot_info: typing.Dict[int, NetworkSlot]
generator_version = Version(0, 0, 0)
checksums: typing.Dict[str, str]
item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})')
item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})')
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
forced_auto_forfeits: typing.Dict[str, bool]
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
non_hintable_names: typing.Dict[str, typing.Set[str]]
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled",
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
log_network: bool = False):
super(Context, self).__init__()
self.slot_info: typing.Dict[int, NetworkSlot] = {}
self.slot_info = {}
self.log_network = log_network
self.endpoints = []
self.clients = {}
@@ -154,7 +186,7 @@ class Context:
self.player_names: typing.Dict[team_slot, str] = {}
self.player_name_lookup: typing.Dict[str, team_slot] = {}
self.connect_names = {} # names of slots clients can connect to
self.allow_forfeits = {}
self.allow_releases = {}
# player location_id item_id target_player_id
self.locations = {}
self.host = host
@@ -171,7 +203,7 @@ class Context:
self.location_check_points = location_check_points
self.hints_used = collections.defaultdict(int)
self.hints: typing.Dict[team_slot, typing.Set[NetUtils.Hint]] = collections.defaultdict(set)
self.forfeit_mode: str = forfeit_mode
self.release_mode: str = release_mode
self.remaining_mode: str = remaining_mode
self.collect_mode: str = collect_mode
self.item_cheat = item_cheat
@@ -191,7 +223,7 @@ class Context:
self.save_dirty = False
self.tags = ['AP']
self.games: typing.Dict[int, str] = {}
self.minimum_client_versions: typing.Dict[int, Utils.Version] = {}
self.minimum_client_versions: typing.Dict[int, Version] = {}
self.seed_name = ""
self.groups = {}
self.group_collected: typing.Dict[int, typing.Set[int]] = {}
@@ -202,32 +234,39 @@ class Context:
# init empty to satisfy linter, I suppose
self.gamespackage = {}
self.checksums = {}
self.item_name_groups = {}
self.location_name_groups = {}
self.all_item_and_group_names = {}
self.forced_auto_forfeits = collections.defaultdict(lambda: False)
self.all_location_and_group_names = {}
self.non_hintable_names = collections.defaultdict(frozenset)
self._load_game_data()
# Datapackage retrieval
# Data package retrieval
def _load_game_data(self):
import worlds
self.gamespackage = worlds.network_data_package["games"]
self.item_name_groups = {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()}
self.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():
self.forced_auto_forfeits[world_name] = world.forced_auto_forfeit
self.non_hintable_names[world_name] = world.hint_blacklist
def _init_game_data(self):
for game_name, game_package in self.gamespackage.items():
if "checksum" in game_package:
self.checksums[game_name] = game_package["checksum"]
for item_name, item_id in game_package["item_name_to_id"].items():
self.item_names[item_id] = item_name
for location_name, location_id in game_package["location_name_to_id"].items():
self.location_names[location_id] = location_name
self.all_item_and_group_names[game_name] = \
set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name])
self.all_location_and_group_names[game_name] = \
set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, []))
def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]:
return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None
@@ -282,6 +321,10 @@ class Context:
endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth)
async_start(self.broadcast_send_encoded_msgs(endpoints, msgs))
def broadcast_text_all(self, text: str, additional_arguments: dict = {}):
logging.info("Notice (all): %s" % text)
self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
def broadcast_team(self, team: int, msgs: typing.List[dict]):
msgs = self.dumper(msgs)
endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values()))
@@ -298,32 +341,20 @@ class Context:
self.clients[endpoint.team][endpoint.slot].remove(endpoint)
await on_client_disconnected(self, endpoint)
# text
def notify_all(self, text: str):
logging.info("Notice (all): %s" % text)
broadcast_text_all(self, text)
def notify_client(self, client: Client, text: str):
def notify_client(self, client: Client, text: str, additional_arguments: dict = {}):
if not client.auth:
return
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
if client.version >= print_command_compatability_threshold:
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}]))
else:
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text}]))
async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}]))
def notify_client_multiple(self, client: Client, texts: typing.List[str]):
def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}):
if not client.auth:
return
if client.version >= print_command_compatability_threshold:
async_start(self.send_msgs(client,
[{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts]))
else:
async_start(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts]))
async_start(self.send_msgs(client,
[{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}
for text in texts]))
# loading
def load(self, multidatapath: str, use_embedded_server_options: bool = False):
if multidatapath.lower().endswith(".zip"):
import zipfile
@@ -338,7 +369,7 @@ class Context:
with open(multidatapath, 'rb') as f:
data = f.read()
self._load(self.decompress(data), use_embedded_server_options)
self._load(self.decompress(data), {}, use_embedded_server_options)
self.data_filename = multidatapath
@staticmethod
@@ -348,26 +379,37 @@ class Context:
raise Utils.VersionException("Incompatible multidata.")
return restricted_loads(zlib.decompress(data[1:]))
def _load(self, decoded_obj: dict, use_embedded_server_options: bool):
def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any],
use_embedded_server_options: bool):
self.read_data = {}
mdata_ver = decoded_obj["minimum_versions"]["server"]
if mdata_ver > Utils.version_tuple:
if mdata_ver > version_tuple:
raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver},"
f"however this server is of version {Utils.version_tuple}")
f"however this server is of version {version_tuple}")
self.generator_version = Version(*decoded_obj["version"])
clients_ver = decoded_obj["minimum_versions"].get("clients", {})
self.minimum_client_versions = {}
for player, version in clients_ver.items():
self.minimum_client_versions[player] = max(Utils.Version(*version), min_client_version)
self.minimum_client_versions[player] = max(Version(*version), min_client_version)
self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
if slot_info.type == SlotType.group}
self.clients = {0: {}}
slot_info: NetworkSlot
slot_id: int
team_0 = self.clients[0]
for slot_id, slot_info in self.slot_info.items():
team_0[slot_id] = []
self.player_names[0, slot_id] = slot_info.name
self.player_name_lookup[slot_info.name] = 0, slot_id
self.read_data[f"hints_{0}_{slot_id}"] = lambda local_team=0, local_player=slot_id: \
list(self.get_rechecked_hints(local_team, local_player))
self.clients = {}
for team, names in enumerate(decoded_obj['names']):
self.clients[team] = {}
for player, name in enumerate(names, 1):
self.clients[team][player] = []
self.player_names[team, player] = name
self.player_name_lookup[name] = team, player
self.read_data[f"hints_{team}_{player}"] = lambda local_team=team, local_player=player: \
list(self.get_rechecked_hints(local_team, local_player))
self.seed_name = decoded_obj["seed_name"]
self.random.seed(self.seed_name)
self.connect_names = decoded_obj['connect_names']
@@ -382,29 +424,9 @@ class Context:
for slot, item_codes in decoded_obj["precollected_items"].items():
self.start_inventory[slot] = [NetworkItem(item_code, -2, 0) for item_code in item_codes]
for team in range(len(decoded_obj['names'])):
for slot, hints in decoded_obj["precollected_hints"].items():
self.hints[team, slot].update(hints)
if "slot_info" in decoded_obj:
self.slot_info = decoded_obj["slot_info"]
self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()}
self.groups = {slot: slot_info.group_members for slot, slot_info in self.slot_info.items()
if slot_info.type == SlotType.group}
else:
self.games = decoded_obj["games"]
self.groups = {}
self.slot_info = {
slot: NetworkSlot(
self.player_names[0, slot],
self.games[slot],
SlotType(int(bool(locations))))
for slot, locations in self.locations.items()
}
# locations may need converting
for slot, locations in self.locations.items():
for location, item_data in locations.items():
if len(item_data) < 3:
locations[location] = (*item_data, 0)
for slot, hints in decoded_obj["precollected_hints"].items():
self.hints[0, slot].update(hints)
# declare slots that aren't players as done
for slot, slot_info in self.slot_info.items():
if slot_info.type.always_goal:
@@ -415,15 +437,22 @@ class Context:
server_options = decoded_obj.get("server_options", {})
self._set_options(server_options)
# custom datapackage
# embedded data package
for game_name, data in decoded_obj.get("datapackage", {}).items():
logging.info(f"Loading custom datapackage for game {game_name}")
if game_name in game_data_packages:
data = game_data_packages[game_name]
logging.info(f"Loading embedded data package for game {game_name}")
self.gamespackage[game_name] = data
self.item_name_groups[game_name] = data["item_name_groups"]
del data["item_name_groups"] # remove from datapackage, but keep in self.item_name_groups
if "location_name_groups" in data:
self.location_name_groups[game_name] = data["location_name_groups"]
del data["location_name_groups"]
del data["item_name_groups"] # remove from data package, but keep in self.item_name_groups
self._init_game_data()
for game_name, data in self.item_name_groups.items():
self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame]
for game_name, data in self.location_name_groups.items():
self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame]
# saving
@@ -513,9 +542,10 @@ class Context:
"group_collected": dict(self.group_collected),
"stored_data": self.stored_data,
"game_options": {"hint_cost": self.hint_cost, "location_check_points": self.location_check_points,
"server_password": self.server_password, "password": self.password, "forfeit_mode":
self.forfeit_mode, "remaining_mode": self.remaining_mode, "collect_mode":
self.collect_mode, "item_cheat": self.item_cheat, "compatibility": self.compatibility}
"server_password": self.server_password, "password": self.password,
"release_mode": self.release_mode,
"remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode,
"item_cheat": self.item_cheat, "compatibility": self.compatibility}
}
@@ -546,7 +576,7 @@ class Context:
self.location_check_points = savedata["game_options"]["location_check_points"]
self.server_password = savedata["game_options"]["server_password"]
self.password = savedata["game_options"]["password"]
self.forfeit_mode = savedata["game_options"]["forfeit_mode"]
self.release_mode = savedata["game_options"].get("release_mode", savedata["game_options"].get("forfeit_mode", "goal"))
self.remaining_mode = savedata["game_options"]["remaining_mode"]
self.collect_mode = savedata["game_options"]["collect_mode"]
self.item_cheat = savedata["game_options"]["item_cheat"]
@@ -566,7 +596,7 @@ class Context:
def get_hint_cost(self, slot):
if self.hint_cost:
return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot])))
return 0
def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None):
@@ -591,6 +621,8 @@ class Context:
def _set_options(self, server_options: dict):
for key, value in server_options.items():
if key == "forfeit_mode":
key = "release_mode"
data_type = self.simple_options.get(key, None)
if data_type is not None:
if value not in {False, True, None}: # some can be boolean OR text, such as password
@@ -655,13 +687,11 @@ class Context:
def on_goal_achieved(self, client: Client):
finished_msg = f'{self.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1})' \
f' has completed their goal.'
self.notify_all(finished_msg)
self.broadcast_text_all(finished_msg, {"type": "Goal", "team": client.team, "slot": client.slot})
if "auto" in self.collect_mode:
collect_player(self, client.team, client.slot)
if "auto" in self.forfeit_mode:
forfeit_player(self, client.team, client.slot)
elif self.forced_auto_forfeits[self.games[client.slot]]:
forfeit_player(self, client.team, client.slot)
if "auto" in self.release_mode:
release_player(self, client.team, client.slot)
self.save() # save goal completion flag
def on_new_hint(self, team: int, slot: int):
@@ -669,6 +699,10 @@ class Context:
targets: typing.Set[Client] = set(self.stored_data_notification_clients[key])
if targets:
self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}])
self.broadcast(self.clients[team][slot], [{
"cmd": "RoomUpdate",
"hint_points": get_slot_points(self, team, slot)
}])
def update_aliases(ctx: Context, team: int):
@@ -714,19 +748,24 @@ async def on_client_connected(ctx: Context, client: Client):
NetworkPlayer(team, slot,
ctx.name_aliases.get((team, slot), name), name)
)
games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)}
games.add("Archipelago")
await ctx.send_msgs(client, [{
'cmd': 'RoomInfo',
'password': bool(ctx.password),
'games': {ctx.games[x] for x in range(1, len(ctx.games) + 1)},
'games': games,
# tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate.
'tags': ctx.tags,
'version': Utils.version_tuple,
'version': version_tuple,
'generator_version': ctx.generator_version,
'permissions': get_permissions(ctx),
'hint_cost': ctx.hint_cost,
'location_check_points': ctx.location_check_points,
'datapackage_versions': {game: game_data["version"] for game, game_data
in ctx.gamespackage.items()},
in ctx.gamespackage.items() if game in games},
'datapackage_checksums': {game: game_data["checksum"] for game, game_data
in ctx.gamespackage.items() if game in games and "checksum" in game_data},
'seed_name': ctx.seed_name,
'time': time.time(),
}])
@@ -734,7 +773,7 @@ async def on_client_connected(ctx: Context, client: Client):
def get_permissions(ctx) -> typing.Dict[str, Permission]:
return {
"forfeit": Permission.from_text(ctx.forfeit_mode),
"release": Permission.from_text(ctx.release_mode),
"remaining": Permission.from_text(ctx.remaining_mode),
"collect": Permission.from_text(ctx.collect_mode)
}
@@ -746,58 +785,47 @@ async def on_client_disconnected(ctx: Context, client: Client):
async def on_client_joined(ctx: Context, client: Client):
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
if ctx.client_game_state[client.team, client.slot] == ClientStatus.CLIENT_UNKNOWN:
update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED)
version_str = '.'.join(str(x) for x in client.version)
verb = "tracking" if "Tracker" in client.tags else "playing"
ctx.notify_all(
ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) "
f"{verb} {ctx.games[client.slot]} has joined. "
f"Client({version_str}), {client.tags}).")
f"Client({version_str}), {client.tags}).",
{"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags})
ctx.notify_client(client, "Now that you are connected, "
"you can use !help to list commands to run via the server. "
"If your client supports it, "
"you may have additional local commands you can list with /help.")
"you may have additional local commands you can list with /help.",
{"type": "Tutorial"})
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
async def on_client_left(ctx: Context, client: Client):
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
ctx.notify_all(
"%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1))
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
if len(ctx.clients[client.team][client.slot]) < 1:
update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN)
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
ctx.broadcast_text_all(
"%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1),
{"type": "Part", "team": client.team, "slot": client.slot})
async def countdown(ctx: Context, timer: int):
broadcast_countdown(ctx, timer, f"[Server]: Starting countdown of {timer}s")
ctx.broadcast_text_all(f"[Server]: Starting countdown of {timer}s", {"type": "Countdown", "countdown": timer})
if ctx.countdown_timer:
ctx.countdown_timer = timer # timer is already running, set it to a different time
else:
ctx.countdown_timer = timer
while ctx.countdown_timer > 0:
broadcast_countdown(ctx, ctx.countdown_timer, f"[Server]: {ctx.countdown_timer}")
ctx.broadcast_text_all(f"[Server]: {ctx.countdown_timer}",
{"type": "Countdown", "countdown": ctx.countdown_timer})
ctx.countdown_timer -= 1
await asyncio.sleep(1)
broadcast_countdown(ctx, 0, f"[Server]: GO")
ctx.broadcast_text_all(f"[Server]: GO", {"type": "Countdown", "countdown": 0})
ctx.countdown_timer = 0
def broadcast_text_all(ctx: Context, text: str, additional_arguments: dict = {}):
old_clients, new_clients = [], []
for teams in ctx.clients.values():
for clients in teams.values():
for client in clients:
new_clients.append(client) if client.version >= print_command_compatability_threshold \
else old_clients.append(client)
ctx.broadcast(old_clients, [{"cmd": "Print", "text": text }])
ctx.broadcast(new_clients, [{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}])
def broadcast_countdown(ctx: Context, timer: int, message: str):
broadcast_text_all(ctx, message, {"type": "Countdown", "countdown": timer})
def get_players_string(ctx: Context):
auth_clients = {(c.team, c.slot) for c in ctx.endpoints if c.auth}
@@ -862,10 +890,12 @@ def update_checked_locations(ctx: Context, team: int, slot: int):
[{"cmd": "RoomUpdate", "checked_locations": get_checked_checks(ctx, team, slot)}])
def forfeit_player(ctx: Context, team: int, slot: int):
def release_player(ctx: Context, team: int, slot: int):
"""register any locations that are in the multidata"""
all_locations = set(ctx.locations[slot])
ctx.notify_all("%s (Team #%d) has released all remaining items from their world." % (ctx.player_names[(team, slot)], team + 1))
ctx.broadcast_text_all("%s (Team #%d) has released all remaining items from their world."
% (ctx.player_names[(team, slot)], team + 1),
{"type": "Release", "team": team, "slot": slot})
register_location_checks(ctx, team, slot, all_locations)
update_checked_locations(ctx, team, slot)
@@ -878,7 +908,9 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False):
if values[1] == slot:
all_locations[source_slot].add(location_id)
ctx.notify_all("%s (Team #%d) has collected their items from other worlds." % (ctx.player_names[(team, slot)], team + 1))
ctx.broadcast_text_all("%s (Team #%d) has collected their items from other worlds."
% (ctx.player_names[(team, slot)], team + 1),
{"type": "Collect", "team": team, "slot": slot})
for source_player, location_ids in all_locations.items():
register_location_checks(ctx, team, source_player, location_ids, count_activity=False)
update_checked_locations(ctx, team, source_player)
@@ -1148,11 +1180,15 @@ class ClientMessageProcessor(CommonCommandProcessor):
def __call__(self, raw: str) -> typing.Optional[bool]:
if not raw.startswith("!admin"):
self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + raw)
self.ctx.broadcast_text_all(self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + raw,
{"type": "Chat", "team": self.client.team, "slot": self.client.slot, "message": raw})
return super(ClientMessageProcessor, self).__call__(raw)
def output(self, text):
self.ctx.notify_client(self.client, text)
def output(self, text: str):
self.ctx.notify_client(self.client, text, {"type": "CommandResult"})
def output_multiple(self, texts: typing.List[str]):
self.ctx.notify_client_multiple(self.client, texts, {"type": "CommandResult"})
def default(self, raw: str):
pass # default is client sending just text
@@ -1175,9 +1211,8 @@ class ClientMessageProcessor(CommonCommandProcessor):
# disallow others from knowing what the new remote administration password is.
"!admin /option server_password"):
output = f"!admin /option server_password {('*' * random.randint(4, 16))}"
# Otherwise notify the others what is happening.
self.ctx.notify_all(self.ctx.get_aliased_name(self.client.team,
self.client.slot) + ': ' + output)
self.ctx.broadcast_text_all(self.ctx.get_aliased_name(self.client.team, self.client.slot) + ': ' + output,
{"type": "Chat", "team": self.client.team, "slot": self.client.slot, "message": output})
if not self.ctx.server_password:
self.output("Sorry, Remote administration is disabled")
@@ -1214,7 +1249,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_players(self) -> bool:
"""Get information about connected and missing players."""
if len(self.ctx.player_names) < 10:
self.ctx.notify_all(get_players_string(self.ctx))
self.ctx.broadcast_text_all(get_players_string(self.ctx), {"type": "CommandResult"})
else:
self.output(get_players_string(self.ctx))
return True
@@ -1228,23 +1263,19 @@ class ClientMessageProcessor(CommonCommandProcessor):
def _cmd_release(self) -> bool:
"""Sends remaining items in your world to their recipients."""
return self._cmd_forfeit()
def _cmd_forfeit(self) -> bool:
"""Surrender and send your remaining items out to their recipients. Use release in the future."""
if self.ctx.allow_forfeits.get((self.client.team, self.client.slot), False):
forfeit_player(self.ctx, self.client.team, self.client.slot)
if self.ctx.allow_releases.get((self.client.team, self.client.slot), False):
release_player(self.ctx, self.client.team, self.client.slot)
return True
if "enabled" in self.ctx.forfeit_mode:
forfeit_player(self.ctx, self.client.team, self.client.slot)
if "enabled" in self.ctx.release_mode:
release_player(self.ctx, self.client.team, self.client.slot)
return True
elif "disabled" in self.ctx.forfeit_mode:
elif "disabled" in self.ctx.release_mode:
self.output("Sorry, client item releasing has been disabled on this server. "
"You can ask the server admin for a /release")
return False
else: # is auto or goal
if self.ctx.client_game_state[self.client.team, self.client.slot] == ClientStatus.CLIENT_GOAL:
forfeit_player(self.ctx, self.client.team, self.client.slot)
release_player(self.ctx, self.client.team, self.client.slot)
return True
else:
self.output(
@@ -1299,28 +1330,42 @@ class ClientMessageProcessor(CommonCommandProcessor):
"Sorry, !remaining requires you to have beaten the game on this server")
return False
def _cmd_missing(self) -> bool:
"""List all missing location checks from the server's perspective"""
def _cmd_missing(self, filter_text="") -> bool:
"""List all missing location checks from the server's perspective.
Can be given text, which will be used as filter."""
locations = get_missing_checks(self.ctx, self.client.team, self.client.slot)
if locations:
texts = [f'Missing: {self.ctx.location_names[location]}' for location in locations]
texts.append(f"Found {len(locations)} missing location checks")
self.ctx.notify_client_multiple(self.client, texts)
names = [self.ctx.location_names[location] for location in locations]
if filter_text:
names = [name for name in names if filter_text in name]
texts = [f'Missing: {name}' for name in names]
if filter_text:
texts.append(f"Found {len(locations)} missing location checks, displaying {len(names)} of them.")
else:
texts.append(f"Found {len(locations)} missing location checks")
self.output_multiple(texts)
else:
self.output("No missing location checks found.")
return True
def _cmd_checked(self) -> bool:
"""List all done location checks from the server's perspective"""
def _cmd_checked(self, filter_text="") -> bool:
"""List all done location checks from the server's perspective.
Can be given text, which will be used as filter."""
locations = get_checked_checks(self.ctx, self.client.team, self.client.slot)
if locations:
texts = [f'Checked: {self.ctx.location_names[location]}' for location in locations]
texts.append(f"Found {len(locations)} done location checks")
self.ctx.notify_client_multiple(self.client, texts)
names = [self.ctx.location_names[location] for location in locations]
if filter_text:
names = [name for name in names if filter_text in name]
texts = [f'Checked: {name}' for name in names]
if filter_text:
texts.append(f"Found {len(locations)} done location checks, displaying {len(names)} of them.")
else:
texts.append(f"Found {len(locations)} done location checks")
self.output_multiple(texts)
else:
self.output("No done location checks found.")
return True
@@ -1356,9 +1401,10 @@ class ClientMessageProcessor(CommonCommandProcessor):
new_item = NetworkItem(names[item_name], -1, self.client.slot)
get_received_items(self.ctx, self.client.team, self.client.slot, False).append(new_item)
get_received_items(self.ctx, self.client.team, self.client.slot, True).append(new_item)
self.ctx.notify_all(
self.ctx.broadcast_text_all(
'Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team,
self.client.slot))
self.client.slot),
{"type": "ItemCheat", "team": self.client.team, "receiving": self.client.slot, "item": new_item})
send_new_items(self.ctx)
return True
else:
@@ -1402,7 +1448,7 @@ class ClientMessageProcessor(CommonCommandProcessor):
if game not in self.ctx.all_item_and_group_names:
self.output("Can't look up item/location for unknown game. Hint for ID instead.")
return False
names = self.ctx.location_names_for_game(game) \
names = self.ctx.all_location_and_group_names[game] \
if for_location else \
self.ctx.all_item_and_group_names[game]
hint_name, usable, response = get_intended_text(input_text, names)
@@ -1418,6 +1464,11 @@ class ClientMessageProcessor(CommonCommandProcessor):
hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name))
elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name
hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name)
elif hint_name in self.ctx.location_name_groups[game]: # location group name
hints = []
for loc_name in self.ctx.location_name_groups[game][hint_name]:
if loc_name in self.ctx.location_names_for_game(game):
hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name))
else: # location name
hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name)
@@ -1592,7 +1643,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
"players": ctx.get_players_package(),
"missing_locations": get_missing_checks(ctx, team, slot),
"checked_locations": get_checked_checks(ctx, team, slot),
"slot_info": ctx.slot_info
"slot_info": ctx.slot_info,
"hint_points": get_slot_points(ctx, team, slot),
}
reply = [connected_packet]
start_inventory = get_start_inventory(ctx, slot, client.remote_start_inventory)
@@ -1657,9 +1709,10 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
client.tags = args["tags"]
if set(old_tags) != set(client.tags):
client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags
ctx.notify_all(
ctx.broadcast_text_all(
f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags "
f"from {old_tags} to {client.tags}.")
f"from {old_tags} to {client.tags}.",
{"type": "TagsChanged", "team": client.team, "slot": client.slot, "tags": client.tags})
elif cmd == 'Sync':
start_inventory = get_start_inventory(ctx, client.slot, client.remote_start_inventory)
@@ -1693,6 +1746,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location))
locs.append(NetworkItem(target_item, location, target_player, flags))
ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2)
if locs and create_as_hint:
ctx.save()
await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}])
elif cmd == 'StatusUpdate':
@@ -1741,7 +1796,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
return
args["cmd"] = "SetReply"
value = ctx.stored_data.get(args["key"], args.get("default", 0))
args["original_value"] = value
args["original_value"] = copy.copy(value)
for operation in args["operations"]:
func = modify_functions[operation["operation"]]
value = func(value, operation["value"])
@@ -1751,6 +1806,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
targets.add(client)
if targets:
ctx.broadcast(targets, [args])
ctx.save()
elif cmd == "SetNotify":
if "keys" not in args or type(args["keys"]) != list:
@@ -1768,6 +1824,7 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus)
ctx.on_goal_achieved(client)
ctx.client_game_state[client.team, client.slot] = new_status
ctx.save()
class ServerCommandProcessor(CommonCommandProcessor):
@@ -1777,11 +1834,11 @@ class ServerCommandProcessor(CommonCommandProcessor):
def output(self, text: str):
if self.client:
self.ctx.notify_client(self.client, text)
self.ctx.notify_client(self.client, text, {"type": "AdminCommandResult"})
super(ServerCommandProcessor, self).output(text)
def default(self, raw: str):
self.ctx.notify_all('[Server]: ' + raw)
self.ctx.broadcast_text_all('[Server]: ' + raw, {"type": "ServerChat", "message": raw})
def _cmd_save(self) -> bool:
"""Save current state to multidata"""
@@ -1808,7 +1865,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_exit(self) -> bool:
"""Shutdown the server"""
async_start(self.ctx.server.ws_server._close())
self.ctx.server.ws_server.close()
if self.ctx.shutdown_task:
self.ctx.shutdown_task.cancel()
self.ctx.exit_event.set()
@@ -1872,28 +1929,23 @@ class ServerCommandProcessor(CommonCommandProcessor):
@mark_raw
def _cmd_release(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients."""
return self._cmd_forfeit(player_name)
@mark_raw
def _cmd_forfeit(self, player_name: str) -> bool:
"""Send out the remaining items from a player to their intended recipients."""
player = self.resolve_player(player_name)
if player:
team, slot, _ = player
forfeit_player(self.ctx, team, slot)
release_player(self.ctx, team, slot)
return True
self.output(f"Could not find player {player_name} to release")
return False
@mark_raw
def _cmd_allow_forfeit(self, player_name: str) -> bool:
def _cmd_allow_release(self, player_name: str) -> bool:
"""Allow the specified player to use the !release command."""
player = self.resolve_player(player_name)
if player:
team, slot, name = player
self.ctx.allow_forfeits[(team, slot)] = True
self.ctx.allow_releases[(team, slot)] = True
self.output(f"Player {name} is now allowed to use the !release command at any time.")
return True
@@ -1901,12 +1953,12 @@ class ServerCommandProcessor(CommonCommandProcessor):
return False
@mark_raw
def _cmd_forbid_forfeit(self, player_name: str) -> bool:
def _cmd_forbid_release(self, player_name: str) -> bool:
""""Disallow the specified player from using the !release command."""
player = self.resolve_player(player_name)
if player:
team, slot, name = player
self.ctx.allow_forfeits[(team, slot)] = False
self.ctx.allow_releases[(team, slot)] = False
self.output(f"Player {name} has to follow the server restrictions on use of the !release command.")
return True
@@ -1927,7 +1979,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
send_items_to(self.ctx, team, slot, *new_items)
send_new_items(self.ctx)
self.ctx.notify_all(
self.ctx.broadcast_text_all(
'Cheat console: sending ' + ('' if amount == 1 else f'{amount} of ') +
f'"{item_name}" to {self.ctx.get_aliased_name(team, slot)}')
return True
@@ -2061,7 +2113,7 @@ class ServerCommandProcessor(CommonCommandProcessor):
return input_text
setattr(self.ctx, option_name, attrtype(option))
self.output(f"Set option {option_name} to {getattr(self.ctx, option_name)}")
if option_name in {"forfeit_mode", "remaining_mode", "collect_mode"}:
if option_name in {"release_mode", "remaining_mode", "collect_mode"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", 'permissions': get_permissions(self.ctx)}])
elif option_name in {"hint_cost", "location_check_points"}:
self.ctx.broadcast_all([{"cmd": "RoomUpdate", option_name: getattr(self.ctx, option_name)}])
@@ -2101,19 +2153,21 @@ def parse_args() -> argparse.Namespace:
parser.add_argument('--password', default=defaults["password"])
parser.add_argument('--savefile', default=defaults["savefile"])
parser.add_argument('--disable_save', default=defaults["disable_save"], action='store_true')
parser.add_argument('--cert', help="Path to a SSL Certificate for encryption.")
parser.add_argument('--cert_key', help="Path to SSL Certificate Key file")
parser.add_argument('--loglevel', default=defaults["loglevel"],
choices=['debug', 'info', 'warning', 'error', 'critical'])
parser.add_argument('--location_check_points', default=defaults["location_check_points"], type=int)
parser.add_argument('--hint_cost', default=defaults["hint_cost"], type=int)
parser.add_argument('--disable_item_cheat', default=defaults["disable_item_cheat"], action='store_true')
parser.add_argument('--forfeit_mode', default=defaults["forfeit_mode"], nargs='?',
parser.add_argument('--release_mode', default=defaults["release_mode"], nargs='?',
choices=['auto', 'enabled', 'disabled', "goal", "auto-enabled"], help='''\
Select !forfeit Accessibility. (default: %(default)s)
auto: Automatic "forfeit" on goal completion
enabled: !forfeit is always available
disabled: !forfeit is never available
goal: !forfeit can be used after goal completion
auto-enabled: !forfeit is available and automatically triggered on goal completion
Select !release Accessibility. (default: %(default)s)
auto: Automatic "release" on goal completion
enabled: !release is always available
disabled: !release is never available
goal: !release can be used after goal completion
auto-enabled: !release is available and automatically triggered on goal completion
''')
parser.add_argument('--collect_mode', default=defaults["collect_mode"], nargs='?',
choices=['auto', 'enabled', 'disabled', "goal", "auto-enabled"], help='''\
@@ -2135,7 +2189,7 @@ def parse_args() -> argparse.Namespace:
help="automatically shut down the server after this many minutes without new location checks. "
"0 to keep running. Not yet implemented.")
parser.add_argument('--use_embedded_options', action="store_true",
help='retrieve forfeit, remaining and hint options from the multidata file,'
help='retrieve release, remaining and hint options from the multidata file,'
' instead of host.yaml')
parser.add_argument('--compatibility', default=defaults["compatibility"], type=int,
help="""
@@ -2152,7 +2206,7 @@ async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(ctx.auto_shutdown)
while not ctx.exit_event.is_set():
if not ctx.client_activity_timers.values():
async_start(ctx.server.ws_server._close())
ctx.server.ws_server.close()
ctx.exit_event.set()
if to_cancel:
for task in to_cancel:
@@ -2163,7 +2217,7 @@ async def auto_shutdown(ctx, to_cancel=None):
delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity
seconds = ctx.auto_shutdown - delta.total_seconds()
if seconds < 0:
async_start(ctx.server.ws_server._close())
ctx.server.ws_server.close()
ctx.exit_event.set()
if to_cancel:
for task in to_cancel:
@@ -2173,11 +2227,19 @@ async def auto_shutdown(ctx, to_cancel=None):
await asyncio.sleep(seconds)
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":
import ssl
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ssl_context.load_default_certs()
ssl_context.load_cert_chain(path, cert_key if cert_key else path)
return ssl_context
async def main(args: argparse.Namespace):
Utils.init_logging("Server", loglevel=args.loglevel.lower())
ctx = Context(args.host, args.port, args.server_password, args.password, args.location_check_points,
args.hint_cost, not args.disable_item_cheat, args.forfeit_mode, args.collect_mode,
args.hint_cost, not args.disable_item_cheat, args.release_mode, args.collect_mode,
args.remaining_mode,
args.auto_shutdown, args.compatibility, args.log_network)
data_filename = args.multidata
@@ -2208,8 +2270,9 @@ async def main(args: argparse.Namespace):
ctx.init_save(not args.disable_save)
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ping_timeout=None,
ping_interval=None)
ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None
ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context)
ip = args.host if args.host else Utils.get_public_ipv4()
logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port,
'No password' if not ctx.password else 'Password: %s' % ctx.password))

View File

@@ -6,7 +6,7 @@ from json import JSONEncoder, JSONDecoder
import websockets
from Utils import Version
from Utils import ByValue, Version
class JSONMessagePart(typing.TypedDict, total=False):
@@ -20,7 +20,7 @@ class JSONMessagePart(typing.TypedDict, total=False):
flags: int
class ClientStatus(enum.IntEnum):
class ClientStatus(ByValue, enum.IntEnum):
CLIENT_UNKNOWN = 0
CLIENT_CONNECTED = 5
CLIENT_READY = 10
@@ -28,22 +28,22 @@ class ClientStatus(enum.IntEnum):
CLIENT_GOAL = 30
class SlotType(enum.IntFlag):
class SlotType(ByValue, enum.IntFlag):
spectator = 0b00
player = 0b01
group = 0b10
@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
class Permission(enum.IntFlag):
class Permission(ByValue, 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

@@ -197,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()):

View File

@@ -17,9 +17,9 @@ from worlds.oot.N64Patch import apply_patch_file
from worlds.oot.Utils import data_path
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart oot_connector.lua"
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure oot_connector.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart oot_connector.lua"
CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_oot.lua"
CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure connector_oot.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_oot.lua"
CONNECTION_TENTATIVE_STATUS = "Initial Connection Made"
CONNECTION_CONNECTED_STATUS = "Connected"
CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
@@ -179,6 +179,12 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool):
locations = payload['locations']
collectibles = payload['collectibles']
# The Lua JSON library serializes an empty table into a list instead of a dict. Verify types for safety:
if isinstance(locations, list):
locations = {}
if isinstance(collectibles, list):
collectibles = {}
if ctx.location_table != locations or ctx.collectible_table != collectibles:
ctx.location_table = locations
ctx.collectible_table = collectibles
@@ -289,11 +295,19 @@ async def patch_and_run_game(apz5_file):
decomp_path = base_name + '-decomp.z64'
comp_path = base_name + '.z64'
# Load vanilla ROM, patch file, compress ROM
rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"]))
apply_patch_file(rom, apz5_file,
sub_file=(os.path.basename(base_name) + '.zpf'
if zipfile.is_zipfile(apz5_file)
else None))
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)
sub_file = None
if zipfile.is_zipfile(apz5_file):
for name in zipfile.ZipFile(apz5_file).namelist():
if name.endswith('.zpf'):
sub_file = name
break
apply_patch_file(rom, apz5_file, sub_file=sub_file)
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,11 @@ 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
import pathlib
class AssembleOptions(abc.ABCMeta):
def __new__(mcs, name, bases, attrs):
@@ -79,9 +85,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 +101,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 +115,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 +139,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 +172,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 +434,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 +445,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 +460,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 +583,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]
@@ -709,8 +716,16 @@ class SpecialRange(Range):
f"random-range-high-<min>-<max>, or random-range-<min>-<max>.")
class VerifyKeys:
valid_keys = frozenset()
class FreezeValidKeys(AssembleOptions):
def __new__(mcs, name, bases, attrs):
if "valid_keys" in attrs:
attrs["_valid_keys"] = frozenset(attrs["valid_keys"])
return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs)
class VerifyKeys(metaclass=FreezeValidKeys):
valid_keys: typing.Iterable = []
_valid_keys: frozenset # gets created by AssembleOptions from valid_keys
valid_keys_casefold: bool = False
convert_name_groups: bool = False
verify_item_name: bool = False
@@ -718,21 +733,26 @@ 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)
extra = dataset - cls.valid_keys
extra = dataset - cls._valid_keys
if extra:
raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. "
f"Allowed keys: {cls.valid_keys}.")
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:
@@ -781,6 +801,10 @@ class ItemDict(OptionDict):
class OptionList(Option[typing.List[typing.Any]], VerifyKeys):
# Supports duplicate entries and ordering.
# If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead.
# Not a docstring so it doesn't get grabbed by the options system.
default: typing.List[typing.Any] = []
supports_weighting = False
@@ -832,7 +856,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 +894,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"
@@ -889,27 +910,36 @@ class StartInventory(ItemDict):
display_name = "Start Inventory"
class StartInventoryPool(StartInventory):
"""Start with these items and don't place them in the world.
The game decides what the replacement items will be."""
verify_item_name = True
display_name = "Start Inventory from Pool"
class StartHints(ItemSet):
"""Start with these item's locations prefilled into the !hint command."""
display_name = "Start Hints"
class StartLocationHints(OptionSet):
class LocationSet(OptionSet):
verify_location_name = True
convert_name_groups = 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):
@@ -950,7 +980,7 @@ 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()
@@ -993,6 +1023,64 @@ per_game_common_options = {
"item_links": ItemLinks
}
def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True):
import os
import yaml
from jinja2 import Template
from worlds import AutoWorldRegister
from Utils import local_path, __version__
full_path: str
os.makedirs(target_folder, exist_ok=True)
# clean out old
for file in os.listdir(target_folder):
full_path = os.path.join(target_folder, file)
if os.path.isfile(full_path) and full_path.endswith(".yaml"):
os.unlink(full_path)
def dictify_range(option: typing.Union[Range, SpecialRange]):
data = {option.default: 50}
for sub_option in ["random", "random-low", "random-high"]:
if sub_option != option.default:
data[sub_option] = 0
notes = {}
for name, number in getattr(option, "special_range_names", {}).items():
notes[name] = f"equivalent to {number}"
if number in data:
data[name] = data[number]
del data[number]
else:
data[name] = 0
return data, notes
for game_name, world in AutoWorldRegister.world_types.items():
if not world.hidden or generate_hidden:
all_options: typing.Dict[str, AssembleOptions] = {
**per_game_common_options,
**world.option_definitions
}
with open(local_path("data", "options.yaml")) as f:
file_data = f.read()
res = Template(file_data).render(
options=all_options,
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
dictify_range=dictify_range,
)
del file_data
with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f:
f.write(res)
if __name__ == "__main__":
from worlds.alttp.Options import Logic

View File

@@ -17,7 +17,7 @@ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandP
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:
@@ -40,7 +40,7 @@ CONNECTION_INITIAL_STATUS = "Connection has not been initiated"
DISPLAY_MSGS = True
SCRIPT_VERSION = 1
SCRIPT_VERSION = 3
class GBCommandProcessor(ClientCommandProcessor):
@@ -70,6 +70,8 @@ class GBContext(CommonContext):
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:
@@ -124,7 +126,8 @@ def get_payload(ctx: GBContext):
"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},
"deathlink": ctx.deathlink_pending
"deathlink": ctx.deathlink_pending,
"options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled'))
}
)
ctx.deathlink_pending = False
@@ -134,10 +137,13 @@ def get_payload(ctx: GBContext):
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]}
if len(flags['Rod']) > 1:
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():
@@ -207,6 +213,16 @@ async def gb_sync_task(ctx: GBContext):
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:

View File

@@ -34,6 +34,17 @@ Currently, the following games are supported:
* 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
* DLC Quest
* Noita
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
@@ -1003,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.")

104
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
@@ -37,8 +38,11 @@ class Version(typing.NamedTuple):
minor: int
build: int
def as_simple_string(self) -> str:
return ".".join(str(item) for item in self)
__version__ = "0.3.7"
__version__ = "0.4.1"
version_tuple = tuplize_version(__version__)
is_linux = sys.platform.startswith("linux")
@@ -87,7 +91,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():
@@ -142,6 +149,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 +213,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 +231,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
@@ -248,6 +266,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 +281,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,
@@ -310,6 +331,20 @@ def get_default_options() -> OptionsType:
"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
@@ -377,6 +412,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
@@ -434,6 +508,15 @@ def restricted_loads(s):
return RestrictedUnpickler(io.BytesIO(s)).load()
class ByValue:
"""
Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent.
See https://github.com/python/cpython/pull/26658 for why this exists.
"""
def __reduce_ex__(self, prot):
return self.__class__, (self._value_, )
class KeyedDefaultDict(collections.defaultdict):
"""defaultdict variant that uses the missing key as argument to default_factory"""
default_factory: typing.Callable[[typing.Any], typing.Any]
@@ -505,7 +588,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
except Exception as e:
logging.exception(e)
else:
logging.info(f"Deleted old logfile {file.path}")
logging.debug(f"Deleted old logfile {file.path}")
import threading
threading.Thread(target=_cleanup, name="LogCleaner").start()
import platform
@@ -662,7 +745,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()

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
@@ -32,7 +34,7 @@ app.config['MAX_CONTENT_LENGTH'] = 64 * 1024 * 1024 # 64 megabyte limit
# if you want to deploy, make sure you have a non-guessable secret key
app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8")
# at what amount of worlds should scheduling be used, instead of rolling in the web-thread
app.config["JOB_THRESHOLD"] = 2
app.config["JOB_THRESHOLD"] = 1
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
app.config['SESSION_PERMANENT'] = True
@@ -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

@@ -39,12 +39,21 @@ 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()}
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
from . import generate, user # trigger registration

View File

@@ -48,9 +48,8 @@ def generate_api():
if len(options) > app.config["MAX_ROLL"]:
return {"text": "Max size of multiworld exceeded",
"detail": app.config["MAX_ROLL"]}, 409
meta = get_meta(meta_options_source)
meta["race"] = race
results, gen_options = roll_options(options, meta["plando_options"])
meta = get_meta(meta_options_source, race)
results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
return {"text": str(results),
"detail": results}, 400

View File

@@ -135,7 +135,7 @@ def autogen(config: dict):
with Locker("autogen"):
with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
initargs=(config["PONY"],)) as generator_pool:
initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
with db_session:
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
@@ -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

@@ -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
@@ -52,11 +52,12 @@ def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
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>?')
'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."
return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \
"Your file was deleted."
elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")):
options[file.filename] = zfile.open(file, "r").read()
else:
@@ -69,7 +70,7 @@ def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]:
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,21 @@ 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.get("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:
row = GameDataPackage.get(checksum=game_data["checksum"])
if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete
game_data_packages[game] = Utils.restricted_loads(row.data)
return self._load(multidata, game_data_packages, True)
@db_session
def init_save(self, enabled: bool = True):
@@ -126,21 +141,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 +167,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 +204,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:
@@ -88,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

@@ -6,13 +6,13 @@ import tempfile
import zipfile
import concurrent.futures
from collections import Counter
from typing import Dict, Optional, Any
from typing import Dict, Optional, Any, Union, List
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
@@ -22,7 +22,7 @@ from .models import Generation, STATE_ERROR, STATE_QUEUED, Seed, UUID
from .upload import upload_zip_to_db
def get_meta(options_source: dict) -> dict:
def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]:
plando_options = {
options_source.get("plando_bosses", ""),
options_source.get("plando_items", ""),
@@ -33,13 +33,27 @@ 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))),
"server_password": options_source.get("server_password", None),
}
return {"server_options": server_options, "plando_options": list(plando_options)}
generator_options = {
"spoiler": int(options_source.get("spoiler", 0)),
"race": race
}
if race:
server_options["item_cheat"] = False
server_options["remaining_mode"] = "disabled"
generator_options["spoiler"] = 0
return {
"server_options": server_options,
"plando_options": list(plando_options),
"generator_options": generator_options,
}
@app.route('/generate', methods=['GET', 'POST'])
@@ -55,13 +69,8 @@ def generate(race=False):
if isinstance(options, str):
flash(options)
else:
meta = get_meta(request.form)
meta["race"] = race
results, gen_options = roll_options(options, meta["plando_options"])
if race:
meta["server_options"]["item_cheat"] = False
meta["server_options"]["remaining_mode"] = "disabled"
meta = get_meta(request.form, race)
results, gen_options = roll_options(options, set(meta["plando_options"]))
if any(type(result) == str for result in results.values()):
return render_template("checkResult.html", results=results)
@@ -97,7 +106,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
meta: Dict[str, Any] = {}
meta.setdefault("server_options", {}).setdefault("hint_cost", 10)
race = meta.setdefault("race", False)
race = meta["generator_options"].setdefault("race", False)
def task():
target = tempfile.TemporaryDirectory()
@@ -114,13 +123,13 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non
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 3
erargs.spoiler = meta["generator_options"]["spoiler"]
erargs.race = race
erargs.outputname = seedname
erargs.outputpath = target.name
erargs.teams = 1
erargs.plando_options = PlandoSettings.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options",
{"bosses", "items", "connections", "texts"}))
name_counter = Counter()
for player, (playerfile, settings) in enumerate(gen_options.items(), 1):

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")
@@ -119,7 +116,11 @@ def display_log(room: UUID):
if room is None:
return abort(404)
if room.owner == session["_id"]:
return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8")
file_path = os.path.join("logs", str(room.id) + ".txt")
if os.path.exists(file_path):
return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8")
return "Log File does not exist."
return "Access Denied", 403
@@ -167,8 +168,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,35 +11,14 @@ 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():
target_folder = local_path("WebHostLib", "static", "generated")
yaml_folder = os.path.join(target_folder, "configs")
os.makedirs(yaml_folder, exist_ok=True)
for file in os.listdir(yaml_folder):
full_path: str = os.path.join(yaml_folder, file)
if os.path.isfile(full_path):
os.unlink(full_path)
def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]):
data = {option.default: 50}
for sub_option in ["random", "random-low", "random-high"]:
if sub_option != option.default:
data[sub_option] = 0
notes = {}
for name, number in getattr(option, "special_range_names", {}).items():
notes[name] = f"equivalent to {number}"
if number in data:
data[name] = data[number]
del data[number]
else:
data[name] = 0
return data, notes
Options.generate_yaml_templates(yaml_folder)
def get_html_doc(option_type: type(Options.Option)) -> str:
if not option_type.__doc__:
@@ -61,18 +40,6 @@ def create():
**Options.per_game_common_options,
**world.option_definitions
}
with open(local_path("WebHostLib", "templates", "options.yaml")) as f:
file_data = f.read()
res = Template(file_data).render(
options=all_options,
__version__=__version__, game=game_name, yaml_dump=yaml.dump,
dictify_range=dictify_range,
)
del file_data
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
player_settings = {
@@ -88,7 +55,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 +65,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,27 +93,30 @@ 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),
"defaultValue": list(option.default)
}
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),
"defaultValue": list(option.default)
}
elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet):
elif issubclass(option, Options.VerifyKeys) and not issubclass(option, Options.OptionDict):
if option.valid_keys:
game_options[option_name] = {
"type": "custom-list",
"displayName": option.display_name if hasattr(option, "display_name") else option_name,
"description": get_html_doc(option),
"options": list(option.valid_keys),
"defaultValue": list(option.default) if hasattr(option, "default") else []
}
else:
@@ -160,6 +130,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.8.1
bokeh>=3.0.2
Flask-Limiter>=3.3.0
bokeh>=3.1.0

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

@@ -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;
@@ -18,7 +20,8 @@ window.addEventListener('load', () => {
info: false,
dom: "t",
stateSave: true,
stateSaveCallback: function(settings,data) {
stateSaveCallback: function(settings, data) {
delete data.search;
localStorage.setItem(`DataTables_${settings.sInstance}_/tracker`, JSON.stringify(data));
},
stateLoadCallback: function(settings) {
@@ -70,10 +73,30 @@ 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;
@@ -87,7 +110,7 @@ window.addEventListener('load', () => {
const update = () => {
const target = $("<div></div>");
console.log("Updating Tracker...");
target.load("/tracker/" + tracker, function (response, status) {
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");
@@ -114,10 +137,5 @@ window.addEventListener('load', () => {
tables.draw();
});
$(".table-wrapper").scrollsync({
y_sync: true,
x_sync: true
});
adjustTableHeight();
});

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;
@@ -93,7 +91,7 @@ const createDefaultSettings = (settingData) => {
case 'items-list':
case 'locations-list':
case 'custom-list':
newSettings[game][gameSetting] = [];
newSettings[game][gameSetting] = setting.defaultValue;
break;
default:
@@ -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

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

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

@@ -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>
@@ -119,6 +119,28 @@
</select>
</td>
</tr>
<tr>
<td>
<label for="spoiler">Spoiler Log:
<span class="interactive" data-tooltip="Generates a text listing all randomized elements.
Warning: playthrough can take a significant amount of time for larger multiworlds.">
(?)
</span>
</label>
</td>
<td>
<select name="spoiler" id="spoiler">
{% if race -%}
<option value="0">Disabled in Race mode</option>
{%- else -%}
<option value="3">Enabled with playthrough and traversal</option>
<option value="2">Enabled with playthrough</option>
<option value="1">Enabled</option>
<option value="0">Disabled</option>
{%- endif -%}
</select>
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -4,7 +4,7 @@
<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 %}

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,10 +11,32 @@
</a>
</div>
<div id="base-header-right">
<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>
<div id="base-header-mobile-menu">
<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>
<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>
</header>

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,20 +25,25 @@
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 %}
{{ macros.list_patches_room(room) }}
{% if room.owner == session["_id"] %}
<form method=post>
<div class="form-group">
<label for="cmd"></label>
<input class="form-control" type="text" id="cmd" name="cmd"
placeholder="Server Command. /help to list them, list gets appended to log.">
</div>
</form>
<div style="display: flex; align-items: center;">
<form method=post style="flex-grow: 1; margin-right: 1em;">
<div class="form-group">
<label for="cmd"></label>
<input class="form-control" type="text" id="cmd" name="cmd"
placeholder="Server Command. /help to list them, list gets appended to log.">
</div>
</form>
<a href="{{ url_for("display_log", room=room.id) }}">
Open Log File...
</a>
</div>
<div id="logger"></div>
<script type="application/ecmascript">
let xmlhttp = new XMLHttpRequest();

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"/>
@@ -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>
@@ -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 -%}

View File

@@ -22,30 +22,37 @@
{% 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" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMC File...</a>
{% elif patch.game == "Factorio" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Factorio Mod...</a>
{% elif patch.game == "Ocarina of Time" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APZ5 File...</a>
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APV6 File...</a>
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APSM64EX File...</a>
{% elif patch.game | supports_apdeltapatch %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a>
{% elif patch.game == "Dark Souls III" and patch.data %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download JSON File...</a>
{% if patch.data %}
{% if patch.game == "Minecraft" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APMC File...</a>
{% elif patch.game == "Factorio" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Factorio Mod...</a>
{% elif patch.game == "Kingdom Hearts 2" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download Kingdom Hearts 2 Mod...</a>
{% elif patch.game == "Ocarina of Time" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APZ5 File...</a>
{% elif patch.game == "VVVVVV" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APV6 File...</a>
{% elif patch.game == "Super Mario 64" and room.seed.slots|length == 1 %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download APSM64EX File...</a>
{% elif patch.game | supports_apdeltapatch %}
<a href="{{ url_for("download_patch", patch_id=patch.id, room_id=room.id) }}" download>
Download Patch File...</a>
{% elif patch.game == "Dark Souls III" %}
<a href="{{ url_for("download_slot_file", room_id=room.id, player_id=patch.player_id) }}" download>
Download JSON File...</a>
{% else %}
No file to download for this game.
{% endif %}
{% else %}
No file to download for this game.
{% endif %}

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

@@ -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, 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
@@ -282,7 +312,8 @@ def get_static_room_data(room: Room):
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, saving_second
multidata["precollected_items"], games, multidata["slot_data"], groups, saving_second, \
custom_locations, custom_items
_multidata_cache[room.seed.id] = result
return result
@@ -309,7 +340,8 @@ def _get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, w
# 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, saving_second = 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()
@@ -351,7 +383,7 @@ def _get_player_tracker(tracker: UUID, tracked_team: int, tracked_player: int, w
seed_checks_in_area, checks_done, slot_data[tracked_player], saving_second)
else:
tracker = __renderGenericTracker(multisave, room, locations, inventory, tracked_team, tracked_player, player_name,
seed_checks_in_area, checks_done, saving_second)
seed_checks_in_area, checks_done, saving_second, custom_locations, custom_items)
return (saving_second - datetime.datetime.now().second) % 60 or 60, tracker
@@ -457,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",
@@ -465,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],
@@ -627,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
@@ -645,12 +677,11 @@ 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, 67258), (67264, 67280), (67747, 68024), (68054, 68062)),
"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)),
@@ -662,7 +693,7 @@ def __renderOoTTracker(multisave: Dict[str, Any], room: Room, locations: Dict[in
"Spirit Temple": ((67533, 67582), (68566, 68625)),
"Ice Cavern": ((67583, 67596), (68626, 68649)),
"Gerudo Training Ground": ((67597, 67635), (68650, 68656)),
"Thieves' Hideout": ((67259, 67263), (68025, 68053)),
"Thieves' Hideout": ((67264, 67268), (68025, 68053)),
"Ganon's Castle": ((67636, 67673), (68657, 68705)),
}
@@ -775,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,
@@ -796,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,
@@ -1190,11 +1221,89 @@ def __renderSC2WoLTracker(multisave: Dict[str, Any], room: Room, locations: Dict
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],
saving_second: int) -> str:
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 = {}
@@ -1212,26 +1321,45 @@ def __renderGenericTracker(multisave: Dict[str, Any], room: Room, locations: Dic
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)
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=1) # 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, saving_second = 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)
@@ -1241,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
@@ -1248,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:
@@ -1300,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] = {
@@ -1315,6 +1572,12 @@ game_specific_trackers: typing.Dict[str, typing.Callable] = {
"Ocarina of Time": __renderOoTTracker,
"Timespinner": __renderTimespinnerTracker,
"A Link to the Past": __renderAlttpTracker,
"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,22 +1,60 @@
import base64
import json
import pickle
import typing
import uuid
import zipfile
from io import BytesIO
import zlib
from io import BytesIO
from flask import request, flash, redirect, url_for, session, render_template, Markup
from pony.orm import flush, select
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", ".z64", ".n64", ".sms", ".gb")
def process_multidata(compressed_multidata, files={}):
decompressed_multidata = MultiServer.Context.decompress(compressed_multidata)
slots: typing.Set[Slot] = set()
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"]
}
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:
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
compressed_multidata = compressed_multidata[0:1] + zlib.compress(pickle.dumps(decompressed_multidata), 9)
return slots, compressed_multidata
def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, sid=None):
if not owner:
@@ -26,7 +64,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
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
@@ -77,18 +115,7 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s
# Load multi data.
if multidata:
decompressed_multidata = MultiServer.Context.decompress(multidata)
if "slot_info" in decompressed_multidata:
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
slots, multidata = process_multidata(multidata, files)
seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner, meta=json.dumps(meta),
id=sid if sid else uuid.uuid4())
@@ -129,11 +156,11 @@ def uploads():
# noinspection PyBroadException
try:
multidata = file.read()
MultiServer.Context.decompress(multidata)
slots, multidata = process_multidata(multidata)
except Exception as e:
flash(f"Could not load multidata. File may be corrupted or incompatible. ({e})")
else:
seed = Seed(multidata=multidata, owner=session["_id"])
seed = Seed(multidata=multidata, slots=slots, owner=session["_id"])
flush() # place into DB and generate ids
return redirect(url_for("view_seed", seed=seed.id))
else:

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 connector_tloz.lua"
CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_tloz.lua is running"
CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_tloz.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()
@@ -396,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():
@@ -439,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"]

BIN
data/discord-mark-blue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

View File

@@ -1,132 +0,0 @@
-----------------------------------------------------------------------------
-- 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.

View File

@@ -1,380 +0,0 @@
--
-- 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

@@ -1,132 +0,0 @@
-----------------------------------------------------------------------------
-- 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.

View File

@@ -1,389 +0,0 @@
--
-- 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
function error(err)
print(err)
end
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
print("invalid table: sparse array")
print(n)
print("VAL:")
print(val)
print("STACK:")
print(stack)
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

109
data/lua/common.lua Normal file
View File

@@ -0,0 +1,109 @@
print("Loading AP lua connector script")
local lua_major, lua_minor = _VERSION:match("Lua (%d+)%.(%d+)")
lua_major = tonumber(lua_major)
lua_minor = tonumber(lua_minor)
-- lua compat shims
if lua_major > 5 or (lua_major == 5 and lua_minor >= 3) then
require("lua_5_3_compat")
end
function table.empty (self)
for _, _ in pairs(self) do
return false
end
return true
end
local bizhawk_version = client.getversion()
local bizhawk_major, bizhawk_minor, bizhawk_patch = bizhawk_version:match("(%d+)%.(%d+)%.?(%d*)")
bizhawk_major = tonumber(bizhawk_major)
bizhawk_minor = tonumber(bizhawk_minor)
if bizhawk_patch == "" then
bizhawk_patch = 0
else
bizhawk_patch = tonumber(bizhawk_patch)
end
local is23Or24Or25 = (bizhawk_version=="2.3.1") or (bizhawk_major == 2 and bizhawk_minor >= 3 and bizhawk_minor <= 5)
local isGreaterOrEqualTo26 = bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor >= 6)
local isUntestedBizhawk = bizhawk_major > 2 or (bizhawk_major == 2 and bizhawk_minor > 9)
local untestedBizhawkMessage = "Warning: this version of bizhawk is newer than we know about. If it doesn't work, consider downgrading to 2.9"
u8 = memory.read_u8
wU8 = memory.write_u8
u16 = memory.read_u16_le
uRange = memory.readbyterange
function getMaxMessageLength()
local denominator = 12
if is23Or24Or25 then
denominator = 11
end
return math.floor(client.screenwidth()/denominator)
end
function drawText(x, y, message, color)
if is23Or24Or25 then
gui.addmessage(message)
elseif isGreaterOrEqualTo26 then
gui.drawText(x, y, message, color, 0xB0000000, 18, "Courier New", "middle", "bottom", nil, "client")
end
end
function clearScreen()
if is23Or24Or25 then
return
elseif isGreaterOrEqualTo26 then
drawText(0, 0, "", "black")
end
end
itemMessages = {}
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 isGreaterOrEqualTo26 then
newTTL = itemMessages[k]["TTL"] - 1
end
itemMessages[k]["TTL"] = newTTL
found = true
end
end
if found == false then
clearScreen()
end
end
function checkBizhawkVersion()
if not is23Or24Or25 and not isGreaterOrEqualTo26 then
print("Must use a version of bizhawk 2.3.1 or higher")
return false
elseif isUntestedBizhawk then
print(untestedBizhawkMessage)
end
return true
end
function stripPrefix(s, p)
return (s:sub(0, #p) == p) and s:sub(#p+1) or s
end

View File

@@ -0,0 +1,738 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
require("common")
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local 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 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
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
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
function getAllRam()
uRangeRAM(0,128);
return data
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 not checkBizhawkVersion() then
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)]
localItemLocations[tostring(carry_item)] = nil
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()

View File

@@ -1,6 +1,7 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
require("common")
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
@@ -102,15 +103,12 @@ local noOverworldItemsLookup = {
[500] = 0x12,
}
local itemMessages = {}
local consumableStacks = nil
local prevstate = ""
local curstate = STATE_UNINITIALIZED
local ff1Socket = nil
local frame = 0
local u8 = nil
local wU8 = nil
local isNesHawk = false
@@ -134,9 +132,6 @@ local function defineMemoryFunctions()
end
local memDomain = defineMemoryFunctions()
u8 = memory.read_u8
wU8 = memory.write_u8
uRange = memory.readbyterange
local function StateOKForMainLoop()
memDomain.saveram()
@@ -146,83 +141,6 @@ local function StateOKForMainLoop()
return A ~= 0x00 and not (A== 0xF2 and B == 0xF2 and C == 0xF2)
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", nil, nil, 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 generateLocationChecked()
memDomain.saveram()
data = uRange(0x01FF, 0x101)
@@ -316,7 +234,14 @@ function getEmptyArmorSlots()
end
return ret
end
local function slice (tbl, s, e)
local pos, new = 1, {}
for i = s + 1, e do
new[pos] = tbl[i]
pos = pos + 1
end
return new
end
function processBlock(block)
local msgBlock = block['messages']
if msgBlock ~= nil then
@@ -448,18 +373,6 @@ function processBlock(block)
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 = ff1Socket:receive()
if e == 'closed' then
@@ -501,8 +414,7 @@ function receive()
end
function main()
if (is23Or24Or25 or is26To28) == false then
print("Must use a version of bizhawk 2.3.1 or higher")
if not checkBizhawkVersion() then
return
end
server, error = socket.bind('localhost', 52980)

View File

@@ -0,0 +1,140 @@
-- 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.socket.udp()
require('common')
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 = stripPrefix(address, "0x")
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 = stripPrefix(address, "0x")
address = tonumber(address, 16)
local to_write = {}
local i = 1
for byte_str in string.gmatch(data, "%S+") do
if i > 2 then
byte_str = stripPrefix(byte_str, "0x")
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

View File

@@ -1,8 +1,9 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
require('common')
local last_modified_date = '2022-11-27' -- Should be the last modified date
local last_modified_date = '2022-4-15' -- Should be the last modified date
local script_version = 3
--------------------------------------------------
@@ -1861,8 +1862,7 @@ function receive()
end
function main()
if (is23Or24Or25 or is26To27) == false then
print("Must use a version of bizhawk 2.3.1 or higher")
if not checkBizhawkVersion() then
return
end
server, error = socket.bind('localhost', 28921)
@@ -1886,7 +1886,7 @@ function main()
ootSocket = client
ootSocket:settimeout(0)
else
print('Connection failed, ensure OoTClient is running and rerun oot_connector.lua')
print('Connection failed, ensure OoTClient is running and rerun connector_oot.lua')
return
end
end
@@ -1895,4 +1895,4 @@ function main()
end
end
main()
main()

View File

@@ -1,13 +1,13 @@
local socket = require("socket")
local json = require('json')
local math = require('math')
require("common")
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local SCRIPT_VERSION = 1
local SCRIPT_VERSION = 3
local APIndex = 0x1A6E
local APDeathLinkAddress = 0x00FD
@@ -16,7 +16,8 @@ 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
@@ -31,9 +32,7 @@ local curstate = STATE_UNINITIALIZED
local gbSocket = nil
local frame = 0
local u8 = nil
local wU8 = nil
local u16
local compat = nil
local function defineMemoryFunctions()
local memDomain = {}
@@ -53,76 +52,42 @@ function uRange(address, bytes)
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
function processBlock(block)
if block == nil then
return
end
local itemsBlock = block["items"]
memDomain.wram()
if itemsBlock ~= nil then
ItemsReceived = itemsBlock
end
deathlink_rec = block["deathlink"]
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 generateLocationsChecked()
memDomain.wram()
events = uRange(EventFlagAddress, 0x140)
missables = uRange(MissableAddress, 0x20)
hiddenitems = uRange(HiddenItemsAddress, 0x0E)
rod = u8(RodAddress)
rod = {u8(RodAddress)}
dexsanity = uRange(DexSanityAddress, 19)
data = {}
table.foreach(events, function(k, v) table.insert(data, v) end)
table.foreach(missables, function(k, v) table.insert(data, v) end)
table.foreach(hiddenitems, function(k, v) table.insert(data, v) end)
table.insert(data, rod)
categories = {events, missables, hiddenitems, rod}
if compat > 1 then
table.insert(categories, dexsanity)
end
for _, category in ipairs(categories) do
for _, v in ipairs(category) do
table.insert(data, v)
end
end
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
if #a1 ~= #a2 then
return false
end
end
return true
for i, v in ipairs(a1) do
if v ~= a2[i] then
return false
end
end
return true
end
function receive()
@@ -141,7 +106,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()
@@ -156,15 +129,31 @@ function receive()
seedName = newSeedName
local retTable = {}
retTable["scriptVersion"] = SCRIPT_VERSION
retTable["clientCompatibilityVersion"] = u8(ClientCompatibilityAddress)
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()
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
@@ -178,8 +167,7 @@ function receive()
end
function main()
if (is23Or24Or25 or is26To28) == false then
print("Must use a version of bizhawk 2.3.1 or higher")
if not checkBizhawkVersion() then
return
end
server, error = socket.bind('localhost', 17242)
@@ -193,16 +181,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 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
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

609
data/lua/connector_tloz.lua Normal file
View File

@@ -0,0 +1,609 @@
--Shamelessly based off the FF1 lua
local socket = require("socket")
local json = require('json')
local math = require('math')
require("common")
local STATE_OK = "Ok"
local STATE_TENTATIVELY_CONNECTED = "Tentatively Connected"
local STATE_INITIAL_CONNECTION_MADE = "Initial Connection Made"
local STATE_UNINITIALIZED = "Uninitialized"
local 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 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 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 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 not checkBizhawkVersion() then
return
end
server, error = socket.bind('localhost', 52980)
while true do
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
frame = frame + 1
drawMessages()
if not (curstate == prevstate) then
-- console.log("Current state: "..curstate)
prevstate = curstate
end
if (curstate == STATE_OK) or (curstate == STATE_INITIAL_CONNECTION_MADE) or (curstate == STATE_TENTATIVELY_CONNECTED) then
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Blue")
receive()
else
gui.drawEllipse(248, 9, 6, 6, "Black", "Green")
end
elseif (curstate == STATE_UNINITIALIZED) then
gui.drawEllipse(248, 9, 6, 6, "Black", "White")
if (frame % 60 == 0) then
gui.drawEllipse(248, 9, 6, 6, "Black", "Yellow")
drawText(5, 8, "Waiting for client", 0xFFFF0000)
drawText(5, 32, "Please start 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()

View File

@@ -0,0 +1,12 @@
function bit.rshift(a, b)
return a >> b
end
function bit.lshift(a, b)
return a << b
end
function bit.bor(a, b)
return a | b
end
function bit.band(a, b)
return a & b
end

View File

@@ -10,8 +10,55 @@
local base = _G
local string = require("string")
local math = require("math")
local socket = require("socket.core")
module("socket")
function get_lua_version()
local major, minor = _VERSION:match("Lua (%d+)%.(%d+)")
assert(tonumber(major) == 5)
if tonumber(minor) >= 4 then
return "5-4"
end
return "5-1"
end
function get_os()
local the_os, ext, arch
if package.config:sub(1,1) == "\\" then
the_os, ext = "windows", "dll"
arch = os.getenv"PROCESSOR_ARCHITECTURE"
else
-- TODO: macos?
the_os, ext = "linux", "so"
arch = "x86_64" -- TODO: read ELF header from /proc/$PID/exe to get arch
end
if arch:find("64") ~= nil then
arch = "x64"
else
arch = "x86"
end
return the_os, ext, arch
end
function get_socket_path()
local the_os, ext, arch = get_os()
-- for some reason ./ isn't working, so use a horrible hack to get the pwd
local pwd = (io.popen and io.popen("cd"):read'*l') or "."
return pwd .. "/" .. arch .. "/socket-" .. the_os .. "-" .. get_lua_version() .. "." .. ext
end
local socket_path = get_socket_path()
local socket = assert(package.loadlib(socket_path, "luaopen_socket_core"))()
-- http://lua-users.org/wiki/ModulesTutorial
local M = {}
if setfenv then
setfenv(1, M) -- for 5.1
else
_ENV = M -- for 5.2
end
M.socket = socket
-----------------------------------------------------------------------------
-- Exported auxiliar functions
@@ -39,7 +86,7 @@ function bind(host, port, backlog)
return sock
end
try = newtry()
try = socket.newtry()
function choose(table)
return function(name, opt1, opt2)
@@ -130,3 +177,5 @@ end
sourcet["default"] = sourcet["until-closed"]
source = choose(sourcet)
return M

View File

@@ -0,0 +1,20 @@
LuaSocket 3.0 license
Copyright <20> 2004-2013 Diego Nehab
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

Binary file not shown.

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